Compare commits

..

29 Commits

Author SHA1 Message Date
mofeng-git
51d7d8b8be chore: bump version to v0.1.7 2026-03-28 22:45:39 +08:00
mofeng-git
f95714d9f0 删除无用测试 2026-03-28 22:41:35 +08:00
mofeng-git
7d52b2e2ea fix(otg): 优化运行时状态监测与未枚举提示 2026-03-28 22:06:53 +08:00
mofeng-git
a2a8b3802d feat(web): 优化视频格式与虚拟键盘显示 2026-03-28 21:34:22 +08:00
mofeng-git
f4283f45a4 refactor(otg): 简化运行时与设置逻辑 2026-03-28 21:09:10 +08:00
mofeng-git
4784cb75e4 调整登录页文案并改为点击切换语言 2026-03-28 20:47:29 +08:00
mofeng-git
abc6bd1677 feat(otg): 增加 libcomposite 自动加载兜底 2026-03-28 17:06:41 +08:00
mofeng-git
1c5288d783 优化控制台与设置页切换时的 WebRTC 会话保活与恢复逻辑 2026-03-27 11:29:27 +08:00
mofeng-git
6bcb54bd22 feat(web): 改为通过 WebSocket 推送 ttyd 状态并清理轮询与冗余接口 2026-03-27 10:49:04 +08:00
mofeng-git
e20136a5ab fix(web): 修复 WebRTC 首帧状态与视频状态判定 2026-03-27 10:44:59 +08:00
mofeng-git
c8fd3648ad refactor(video): 删除废弃视频流水线并收敛 MJPEG/WebRTC 编排与死代码 2026-03-27 08:21:14 +08:00
mofeng-git
6ef2d394d9 fix(video): 启动时丢弃前三帧无效 MJPEG 2026-03-26 23:27:42 +08:00
mofeng-git
762a3b037d chore(hid): 删除 CH9329 收发 trace 日志 2026-03-26 23:05:49 +08:00
mofeng-git
e09a906f93 refactor(hid): 统一 HID 键盘 CanonicalKey 语义并清理前端布局与输入链路冗余代码 2026-03-26 22:51:29 +08:00
mofeng-git
95bf1a852e feat(web): 登录页改为引导卡片样式并增加语言切换按钮 2026-03-26 22:45:28 +08:00
mofeng-git
200f947b5d fix(video): 修复 FFmpeg 硬编码 EAGAIN 刷屏并为编码错误增加日志节流 2026-03-26 22:30:53 +08:00
mofeng-git
46ae0c81e2 refactor(events): 将设备状态广播降级为快照同步并按需订阅 WebSocket 事件,顺带修复相关测试 2026-03-26 22:01:50 +08:00
mofeng-git
779aa180ad refactor: 重构部分事件检查逻辑,修复 ch9329 hid 状态显示异常 2026-03-26 12:33:24 +08:00
mofeng-git
ae26e3c863 feat: 支持自动检测因特尔 GPU 驱动类型 2026-03-25 20:27:26 +08:00
mofeng-git
eeb41159b7 build: 增加硬件编码所需驱动依赖 2026-03-22 20:19:30 +08:00
mofeng-git
24a10aa222 feat: 支持硬件编码能力测试,otg 自检修改为需要手动执行 2026-03-22 20:19:30 +08:00
mofeng-git
c119db4908 perf: 编码器探测测试分辨率由 1080p 调整为 720p 2026-03-22 20:19:30 +08:00
mofeng-git
0db287bf55 refactor: 重构 ffmpeg 编码器探测模块 2026-03-22 20:19:30 +08:00
mofeng-git
e229f35777 fix(web): 修复控制台全屏视频时鼠标定位偏移问题 2026-03-22 20:19:30 +08:00
SilentWind
df647b45cd Merge pull request #230 from a15355447898a/main
修复树莓派4B上 V4L2 编码时 WebRTC 无画面的问题
2026-03-02 19:05:32 +08:00
a15355447898a
b74659dcd4 refactor(video): restore v4l2r and remove temporary debug logs 2026-03-01 01:40:28 +08:00
a15355447898a
4f2fb534a4 fix(video): v4l path + webrtc h264 startup diagnostics 2026-03-01 01:24:26 +08:00
mofeng-git
bd17f8d0f8 chore: 更新版本号到 v0.1.6 2026-02-22 23:03:24 +08:00
mofeng-git
cee43795f8 fix: 添加前端电源状态显示 #226 2026-02-22 22:55:56 +08:00
94 changed files with 6735 additions and 9292 deletions

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "one-kvm" name = "one-kvm"
version = "0.1.5" version = "0.1.7"
edition = "2021" edition = "2021"
authors = ["SilentWind"] authors = ["SilentWind"]
description = "A open and lightweight IP-KVM solution written in Rust" description = "A open and lightweight IP-KVM solution written in Rust"

View File

@@ -12,7 +12,8 @@ ARG TARGETPLATFORM
# Install runtime dependencies in a single layer # Install runtime dependencies in a single layer
# All codec libraries (libx264, libx265, libopus) are now statically linked # All codec libraries (libx264, libx265, libopus) are now statically linked
# Only hardware acceleration drivers and core system libraries remain dynamic # Only hardware acceleration drivers and core system libraries remain dynamic
RUN apt-get update && \ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
# Core runtime (all platforms) - no codec libs needed # Core runtime (all platforms) - no codec libs needed
ca-certificates \ ca-certificates \
@@ -24,7 +25,8 @@ RUN apt-get update && \
# Platform-specific hardware acceleration # Platform-specific hardware acceleration
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1; \ libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1 \
i965-va-driver-shaders intel-media-va-driver-non-free vainfo; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
libdrm2 libva2; \ libdrm2 libva2; \

View File

@@ -12,7 +12,8 @@ ARG TARGETPLATFORM
# Install runtime dependencies in a single layer # Install runtime dependencies in a single layer
# All codec libraries (libx264, libx265, libopus) are now statically linked # All codec libraries (libx264, libx265, libopus) are now statically linked
# Only hardware acceleration drivers and core system libraries remain dynamic # Only hardware acceleration drivers and core system libraries remain dynamic
RUN apt-get update && \ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
# Core runtime (all platforms) - no codec libs needed # Core runtime (all platforms) - no codec libs needed
ca-certificates \ ca-certificates \
@@ -24,7 +25,8 @@ RUN apt-get update && \
# Platform-specific hardware acceleration # Platform-specific hardware acceleration
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1; \ libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1 \
i965-va-driver-shaders intel-media-va-driver-non-free vainfo; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
libdrm2 libva2; \ libdrm2 libva2; \

View File

@@ -4,6 +4,68 @@
set -e set -e
detect_intel_libva_driver() {
if [ -n "${LIBVA_DRIVER_NAME:-}" ]; then
echo "[INFO] Using preconfigured LIBVA_DRIVER_NAME=$LIBVA_DRIVER_NAME"
return
fi
if [ "$(uname -m)" != "x86_64" ]; then
return
fi
local devices=()
if [ -n "${LIBVA_DEVICE:-}" ]; then
devices=("$LIBVA_DEVICE")
else
shopt -s nullglob
devices=(/dev/dri/renderD*)
shopt -u nullglob
fi
if [ ${#devices[@]} -eq 0 ]; then
return
fi
local device=""
local node=""
local vendor=""
local driver=""
for device in "${devices[@]}"; do
if [ ! -e "$device" ]; then
continue
fi
node="$(basename "$device")"
vendor=""
if [ -r "/sys/class/drm/$node/device/vendor" ]; then
vendor="$(cat "/sys/class/drm/$node/device/vendor")"
fi
if [ -n "$vendor" ] && [ "$vendor" != "0x8086" ]; then
echo "[INFO] Skipping VA-API probe for $device (vendor=$vendor)"
continue
fi
for driver in iHD i965; do
if LIBVA_DRIVER_NAME="$driver" vainfo --display drm --device "$device" >/dev/null 2>&1; then
export LIBVA_DRIVER_NAME="$driver"
if [ -n "$vendor" ]; then
echo "[INFO] Detected Intel VA-API driver '$driver' on $device (vendor=$vendor)"
else
echo "[INFO] Detected Intel VA-API driver '$driver' on $device"
fi
return
fi
done
done
echo "[WARN] Unable to auto-detect an Intel VA-API driver; leaving LIBVA_DRIVER_NAME unset"
}
detect_intel_libva_driver
# Start one-kvm with default options. # Start one-kvm with default options.
# Additional options can be passed via environment variables. # Additional options can be passed via environment variables.

View File

@@ -7,6 +7,8 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=root User=root
# Example for older Intel GPUs:
# Environment=LIBVA_DRIVER_NAME=i965
ExecStart=/usr/bin/one-kvm ExecStart=/usr/bin/one-kvm
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5

View File

@@ -126,7 +126,7 @@ EOF
# Create control file # Create control file
BASE_DEPS="libc6 (>= 2.31), libgcc-s1, libstdc++6, libasound2 (>= 1.1), libdrm2 (>= 2.4)" BASE_DEPS="libc6 (>= 2.31), libgcc-s1, libstdc++6, libasound2 (>= 1.1), libdrm2 (>= 2.4)"
AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14)" AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14), i965-va-driver-shaders (>= 2.4), intel-media-va-driver-non-free (>= 21.1)"
DEPS="$BASE_DEPS" DEPS="$BASE_DEPS"
if [ "$DEB_ARCH" = "amd64" ]; then if [ "$DEB_ARCH" = "amd64" ]; then
DEPS="$DEPS, $AMD64_DEPS" DEPS="$DEPS, $AMD64_DEPS"

View File

@@ -271,6 +271,7 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
*out_data = nullptr; *out_data = nullptr;
*out_len = 0; *out_len = 0;
*out_keyframe = 0; *out_keyframe = 0;
bool encoded = false;
av_packet_unref(ctx->dec_pkt); av_packet_unref(ctx->dec_pkt);
int ret = av_new_packet(ctx->dec_pkt, len); int ret = av_new_packet(ctx->dec_pkt, len);
@@ -290,7 +291,7 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
while (true) { while (true) {
ret = avcodec_receive_frame(ctx->dec_ctx, ctx->dec_frame); ret = avcodec_receive_frame(ctx->dec_ctx, ctx->dec_frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
return 0; return encoded ? 1 : 0;
} }
if (ret < 0) { if (ret < 0) {
set_last_error(make_err("avcodec_receive_frame failed", ret)); set_last_error(make_err("avcodec_receive_frame failed", ret));
@@ -370,19 +371,27 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
return -1; return -1;
} }
while (true) {
av_packet_unref(ctx->enc_pkt); av_packet_unref(ctx->enc_pkt);
ret = avcodec_receive_packet(ctx->enc_ctx, ctx->enc_pkt); ret = avcodec_receive_packet(ctx->enc_ctx, ctx->enc_pkt);
if (ret == AVERROR(EAGAIN)) { if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
av_frame_unref(ctx->dec_frame); break;
return 0;
} }
if (ret < 0) { if (ret < 0) {
set_last_error(make_err("avcodec_receive_packet failed", ret)); set_last_error(make_err("avcodec_receive_packet failed", ret));
av_packet_unref(ctx->enc_pkt);
av_frame_unref(ctx->dec_frame); av_frame_unref(ctx->dec_frame);
return -1; return -1;
} }
if (ctx->enc_pkt->size > 0) { if (ctx->enc_pkt->size <= 0) {
set_last_error("avcodec_receive_packet failed, pkt size is 0");
av_packet_unref(ctx->enc_pkt);
av_frame_unref(ctx->dec_frame);
return -1;
}
if (!encoded) {
uint8_t *buf = (uint8_t*)malloc(ctx->enc_pkt->size); uint8_t *buf = (uint8_t*)malloc(ctx->enc_pkt->size);
if (!buf) { if (!buf) {
set_last_error("malloc for output packet failed"); set_last_error("malloc for output packet failed");
@@ -394,9 +403,8 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
*out_data = buf; *out_data = buf;
*out_len = ctx->enc_pkt->size; *out_len = ctx->enc_pkt->size;
*out_keyframe = (ctx->enc_pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0; *out_keyframe = (ctx->enc_pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0;
av_packet_unref(ctx->enc_pkt); encoded = true;
av_frame_unref(ctx->dec_frame); }
return 1;
} }
av_frame_unref(ctx->dec_frame); av_frame_unref(ctx->dec_frame);

View File

@@ -15,12 +15,412 @@ use std::{
slice, slice,
}; };
use super::Priority;
#[cfg(any(windows, target_os = "linux"))] #[cfg(any(windows, target_os = "linux"))]
use crate::common::Driver; use crate::common::Driver;
/// Timeout for encoder test in milliseconds /// Timeout for encoder test in milliseconds
const TEST_TIMEOUT_MS: u64 = 3000; const TEST_TIMEOUT_MS: u64 = 3000;
const PRIORITY_NVENC: i32 = 0;
const PRIORITY_QSV: i32 = 1;
const PRIORITY_AMF: i32 = 2;
const PRIORITY_RKMPP: i32 = 3;
const PRIORITY_VAAPI: i32 = 4;
const PRIORITY_V4L2M2M: i32 = 5;
#[derive(Clone, Copy)]
struct CandidateCodecSpec {
name: &'static str,
format: DataFormat,
priority: i32,
}
fn push_candidate(codecs: &mut Vec<CodecInfo>, candidate: CandidateCodecSpec) {
codecs.push(CodecInfo {
name: candidate.name.to_owned(),
format: candidate.format,
priority: candidate.priority,
..Default::default()
});
}
#[cfg(target_os = "linux")]
fn linux_support_vaapi() -> bool {
let entries = match std::fs::read_dir("/dev/dri") {
Ok(entries) => entries,
Err(_) => return false,
};
entries.flatten().any(|entry| {
entry
.file_name()
.to_str()
.map(|name| name.starts_with("renderD"))
.unwrap_or(false)
})
}
#[cfg(not(target_os = "linux"))]
fn linux_support_vaapi() -> bool {
false
}
#[cfg(target_os = "linux")]
fn linux_support_rkmpp() -> bool {
extern "C" {
fn linux_support_rkmpp() -> c_int;
}
unsafe { linux_support_rkmpp() == 0 }
}
#[cfg(not(target_os = "linux"))]
fn linux_support_rkmpp() -> bool {
false
}
#[cfg(target_os = "linux")]
fn linux_support_v4l2m2m() -> bool {
extern "C" {
fn linux_support_v4l2m2m() -> c_int;
}
unsafe { linux_support_v4l2m2m() == 0 }
}
#[cfg(not(target_os = "linux"))]
fn linux_support_v4l2m2m() -> bool {
false
}
#[cfg(any(windows, target_os = "linux"))]
fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
use log::debug;
let mut codecs = Vec::new();
let contains = |_vendor: Driver, _format: DataFormat| {
// Without VRAM feature, we can't check SDK availability.
// Keep the prefilter coarse and let FFmpeg validation do the real check.
true
};
let (nv, amf, intel) = crate::common::supported_gpu(true);
debug!(
"GPU support detected - NV: {}, AMF: {}, Intel: {}",
nv, amf, intel
);
if nv && contains(Driver::NV, H264) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_nvenc",
format: H264,
priority: PRIORITY_NVENC,
},
);
}
if nv && contains(Driver::NV, H265) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_nvenc",
format: H265,
priority: PRIORITY_NVENC,
},
);
}
if intel && contains(Driver::MFX, H264) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_qsv",
format: H264,
priority: PRIORITY_QSV,
},
);
}
if intel && contains(Driver::MFX, H265) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_qsv",
format: H265,
priority: PRIORITY_QSV,
},
);
}
if amf && contains(Driver::AMF, H264) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_amf",
format: H264,
priority: PRIORITY_AMF,
},
);
}
if amf && contains(Driver::AMF, H265) {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_amf",
format: H265,
priority: PRIORITY_AMF,
},
);
}
if linux_support_rkmpp() {
debug!("RKMPP hardware detected, adding Rockchip encoders");
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_rkmpp",
format: H264,
priority: PRIORITY_RKMPP,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_rkmpp",
format: H265,
priority: PRIORITY_RKMPP,
},
);
}
if cfg!(target_os = "linux") && linux_support_vaapi() {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_vaapi",
format: H264,
priority: PRIORITY_VAAPI,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_vaapi",
format: H265,
priority: PRIORITY_VAAPI,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "vp8_vaapi",
format: VP8,
priority: PRIORITY_VAAPI,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "vp9_vaapi",
format: VP9,
priority: PRIORITY_VAAPI,
},
);
}
if linux_support_v4l2m2m() {
debug!("V4L2 M2M hardware detected, adding V4L2 encoders");
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_v4l2m2m",
format: H264,
priority: PRIORITY_V4L2M2M,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_v4l2m2m",
format: H265,
priority: PRIORITY_V4L2M2M,
},
);
}
codecs.retain(|codec| {
!(ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P && codec.name.contains("qsv"))
});
codecs
}
#[derive(Clone, Copy)]
struct ProbePolicy {
max_attempts: usize,
request_keyframe: bool,
accept_any_output: bool,
}
impl ProbePolicy {
fn for_codec(codec_name: &str) -> Self {
if codec_name.contains("v4l2m2m") {
Self {
max_attempts: 5,
request_keyframe: true,
accept_any_output: true,
}
} else {
Self {
max_attempts: 1,
request_keyframe: false,
accept_any_output: false,
}
}
}
fn prepare_attempt(&self, encoder: &mut Encoder) {
if self.request_keyframe {
encoder.request_keyframe();
}
}
fn passed(&self, frames: &[EncodeFrame], elapsed_ms: u128) -> bool {
if elapsed_ms >= TEST_TIMEOUT_MS as u128 {
return false;
}
if self.accept_any_output {
!frames.is_empty()
} else {
frames.len() == 1 && frames[0].key == 1
}
}
}
fn log_failed_probe_attempt(
codec_name: &str,
policy: ProbePolicy,
attempt: usize,
frames: &[EncodeFrame],
elapsed_ms: u128,
) {
use log::debug;
if policy.accept_any_output {
if frames.is_empty() {
debug!(
"Encoder {} test produced no output on attempt {}",
codec_name, attempt
);
} else {
debug!(
"Encoder {} test failed on attempt {} - frames: {}, timeout: {}ms",
codec_name,
attempt,
frames.len(),
elapsed_ms
);
}
} else if frames.len() == 1 {
debug!(
"Encoder {} test failed on attempt {} - key: {}, timeout: {}ms",
codec_name, attempt, frames[0].key, elapsed_ms
);
} else {
debug!(
"Encoder {} test failed on attempt {} - wrong frame count: {}",
codec_name,
attempt,
frames.len()
);
}
}
fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool {
use log::debug;
debug!("Testing encoder: {}", codec.name);
let test_ctx = EncodeContext {
name: codec.name.clone(),
mc_name: codec.mc_name.clone(),
..ctx.clone()
};
match Encoder::new(test_ctx) {
Ok(mut encoder) => {
debug!("Encoder {} created successfully", codec.name);
let policy = ProbePolicy::for_codec(&codec.name);
let mut last_err: Option<i32> = None;
for attempt in 0..policy.max_attempts {
let attempt_no = attempt + 1;
policy.prepare_attempt(&mut encoder);
let pts = (attempt as i64) * 33;
let start = std::time::Instant::now();
match encoder.encode(yuv, pts) {
Ok(frames) => {
let elapsed = start.elapsed().as_millis();
if policy.passed(frames, elapsed) {
if policy.accept_any_output {
debug!(
"Encoder {} test passed on attempt {} (frames: {})",
codec.name,
attempt_no,
frames.len()
);
} else {
debug!(
"Encoder {} test passed on attempt {}",
codec.name, attempt_no
);
}
return true;
} else {
log_failed_probe_attempt(
&codec.name,
policy,
attempt_no,
frames,
elapsed,
);
}
}
Err(err) => {
last_err = Some(err);
debug!(
"Encoder {} test attempt {} returned error: {}",
codec.name, attempt_no, err
);
}
}
}
debug!(
"Encoder {} test failed after retries{}",
codec.name,
last_err
.map(|e| format!(" (last err: {})", e))
.unwrap_or_default()
);
false
}
Err(_) => {
debug!("Failed to create encoder {}", codec.name);
false
}
}
}
fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
use log::debug;
for fallback in CodecInfo::soft().into_vec() {
if !codecs.iter().any(|codec| codec.format == fallback.format) {
debug!(
"Adding software {:?} encoder: {}",
fallback.format, fallback.name
);
codecs.push(fallback);
}
}
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct EncodeContext { pub struct EncodeContext {
@@ -185,305 +585,21 @@ impl Encoder {
if !(cfg!(windows) || cfg!(target_os = "linux")) { if !(cfg!(windows) || cfg!(target_os = "linux")) {
return vec![]; return vec![];
} }
let mut codecs: Vec<CodecInfo> = vec![];
#[cfg(any(windows, target_os = "linux"))]
{
let contains = |_vendor: Driver, _format: DataFormat| {
// Without VRAM feature, we can't check SDK availability
// Just return true and let FFmpeg handle the actual detection
true
};
let (_nv, amf, _intel) = crate::common::supported_gpu(true);
debug!(
"GPU support detected - NV: {}, AMF: {}, Intel: {}",
_nv, amf, _intel
);
#[cfg(windows)]
if _intel && contains(Driver::MFX, H264) {
codecs.push(CodecInfo {
name: "h264_qsv".to_owned(),
format: H264,
priority: Priority::Best as _,
..Default::default()
});
}
#[cfg(windows)]
if _intel && contains(Driver::MFX, H265) {
codecs.push(CodecInfo {
name: "hevc_qsv".to_owned(),
format: H265,
priority: Priority::Best as _,
..Default::default()
});
}
if _nv && contains(Driver::NV, H264) {
codecs.push(CodecInfo {
name: "h264_nvenc".to_owned(),
format: H264,
priority: Priority::Best as _,
..Default::default()
});
}
if _nv && contains(Driver::NV, H265) {
codecs.push(CodecInfo {
name: "hevc_nvenc".to_owned(),
format: H265,
priority: Priority::Best as _,
..Default::default()
});
}
if amf && contains(Driver::AMF, H264) {
codecs.push(CodecInfo {
name: "h264_amf".to_owned(),
format: H264,
priority: Priority::Best as _,
..Default::default()
});
}
if amf {
codecs.push(CodecInfo {
name: "hevc_amf".to_owned(),
format: H265,
priority: Priority::Best as _,
..Default::default()
});
}
#[cfg(target_os = "linux")]
{
codecs.push(CodecInfo {
name: "h264_vaapi".to_owned(),
format: H264,
priority: Priority::Good as _,
..Default::default()
});
codecs.push(CodecInfo {
name: "hevc_vaapi".to_owned(),
format: H265,
priority: Priority::Good as _,
..Default::default()
});
codecs.push(CodecInfo {
name: "vp8_vaapi".to_owned(),
format: VP8,
priority: Priority::Good as _,
..Default::default()
});
codecs.push(CodecInfo {
name: "vp9_vaapi".to_owned(),
format: VP9,
priority: Priority::Good as _,
..Default::default()
});
// Rockchip MPP hardware encoder support
use std::ffi::c_int;
extern "C" {
fn linux_support_rkmpp() -> c_int;
fn linux_support_v4l2m2m() -> c_int;
}
if unsafe { linux_support_rkmpp() } == 0 {
debug!("RKMPP hardware detected, adding Rockchip encoders");
codecs.push(CodecInfo {
name: "h264_rkmpp".to_owned(),
format: H264,
priority: Priority::Best as _,
..Default::default()
});
codecs.push(CodecInfo {
name: "hevc_rkmpp".to_owned(),
format: H265,
priority: Priority::Best as _,
..Default::default()
});
}
// V4L2 Memory-to-Memory hardware encoder support (generic ARM)
if unsafe { linux_support_v4l2m2m() } == 0 {
debug!("V4L2 M2M hardware detected, adding V4L2 encoders");
codecs.push(CodecInfo {
name: "h264_v4l2m2m".to_owned(),
format: H264,
priority: Priority::Good as _,
..Default::default()
});
codecs.push(CodecInfo {
name: "hevc_v4l2m2m".to_owned(),
format: H265,
priority: Priority::Good as _,
..Default::default()
});
}
}
}
// qsv doesn't support yuv420p
codecs.retain(|c| {
let ctx = ctx.clone();
if ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P && c.name.contains("qsv") {
return false;
}
return true;
});
let mut res = vec![]; let mut res = vec![];
#[cfg(any(windows, target_os = "linux"))]
let codecs = enumerate_candidate_codecs(&ctx);
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) { if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {
for codec in codecs { for codec in codecs {
// Skip if this format already exists in results if validate_candidate(&codec, &ctx, &yuv) {
if res res.push(codec);
.iter()
.any(|existing: &CodecInfo| existing.format == codec.format)
{
continue;
}
debug!("Testing encoder: {}", codec.name);
let c = EncodeContext {
name: codec.name.clone(),
mc_name: codec.mc_name.clone(),
..ctx
};
match Encoder::new(c) {
Ok(mut encoder) => {
debug!("Encoder {} created successfully", codec.name);
let mut passed = false;
let mut last_err: Option<i32> = None;
let is_v4l2m2m = codec.name.contains("v4l2m2m");
let max_attempts = if is_v4l2m2m { 5 } else { 1 };
for attempt in 0..max_attempts {
if is_v4l2m2m {
encoder.request_keyframe();
}
let pts = (attempt as i64) * 33; // 33ms is an approximation for 30 FPS (1000 / 30)
let start = std::time::Instant::now();
match encoder.encode(&yuv, pts) {
Ok(frames) => {
let elapsed = start.elapsed().as_millis();
if is_v4l2m2m {
if !frames.is_empty() && elapsed < TEST_TIMEOUT_MS as _ {
debug!(
"Encoder {} test passed on attempt {} (frames: {})",
codec.name,
attempt + 1,
frames.len()
);
res.push(codec.clone());
passed = true;
break;
} else if frames.is_empty() {
debug!(
"Encoder {} test produced no output on attempt {}",
codec.name,
attempt + 1
);
} else {
debug!(
"Encoder {} test failed on attempt {} - frames: {}, timeout: {}ms",
codec.name,
attempt + 1,
frames.len(),
elapsed
);
}
} else if frames.len() == 1 {
if frames[0].key == 1 && elapsed < TEST_TIMEOUT_MS as _ {
debug!(
"Encoder {} test passed on attempt {}",
codec.name,
attempt + 1
);
res.push(codec.clone());
passed = true;
break;
} else {
debug!(
"Encoder {} test failed on attempt {} - key: {}, timeout: {}ms",
codec.name,
attempt + 1,
frames[0].key,
elapsed
);
}
} else {
debug!(
"Encoder {} test failed on attempt {} - wrong frame count: {}",
codec.name,
attempt + 1,
frames.len()
);
}
}
Err(err) => {
last_err = Some(err);
debug!(
"Encoder {} test attempt {} returned error: {}",
codec.name,
attempt + 1,
err
);
}
}
}
if !passed {
debug!(
"Encoder {} test failed after retries{}",
codec.name,
last_err
.map(|e| format!(" (last err: {})", e))
.unwrap_or_default()
);
}
}
Err(_) => {
debug!("Failed to create encoder {}", codec.name);
}
} }
} }
} else { } else {
debug!("Failed to generate dummy YUV data"); debug!("Failed to generate dummy YUV data");
} }
// Add software encoders as fallback add_software_fallback(&mut res);
let soft_codecs = CodecInfo::soft();
// Add H264 software encoder if not already present
if !res.iter().any(|c| c.format == H264) {
if let Some(h264_soft) = soft_codecs.h264 {
debug!("Adding software H264 encoder: {}", h264_soft.name);
res.push(h264_soft);
}
}
// Add H265 software encoder if not already present
if !res.iter().any(|c| c.format == H265) {
if let Some(h265_soft) = soft_codecs.h265 {
debug!("Adding software H265 encoder: {}", h265_soft.name);
res.push(h265_soft);
}
}
// Add VP8 software encoder if not already present
if !res.iter().any(|c| c.format == VP8) {
if let Some(vp8_soft) = soft_codecs.vp8 {
debug!("Adding software VP8 encoder: {}", vp8_soft.name);
res.push(vp8_soft);
}
}
// Add VP9 software encoder if not already present
if !res.iter().any(|c| c.format == VP9) {
if let Some(vp9_soft) = soft_codecs.vp9 {
debug!("Adding software VP9 encoder: {}", vp9_soft.name);
res.push(vp9_soft);
}
}
res res
} }

View File

@@ -86,6 +86,40 @@ impl Default for CodecInfo {
} }
impl CodecInfo { impl CodecInfo {
pub fn software(format: DataFormat) -> Option<Self> {
match format {
H264 => Some(CodecInfo {
name: "libx264".to_owned(),
mc_name: Default::default(),
format: H264,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
H265 => Some(CodecInfo {
name: "libx265".to_owned(),
mc_name: Default::default(),
format: H265,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
VP8 => Some(CodecInfo {
name: "libvpx".to_owned(),
mc_name: Default::default(),
format: VP8,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
VP9 => Some(CodecInfo {
name: "libvpx-vp9".to_owned(),
mc_name: Default::default(),
format: VP9,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
AV1 => None,
}
}
pub fn prioritized(coders: Vec<CodecInfo>) -> CodecInfos { pub fn prioritized(coders: Vec<CodecInfo>) -> CodecInfos {
let mut h264: Option<CodecInfo> = None; let mut h264: Option<CodecInfo> = None;
let mut h265: Option<CodecInfo> = None; let mut h265: Option<CodecInfo> = None;
@@ -148,34 +182,10 @@ impl CodecInfo {
pub fn soft() -> CodecInfos { pub fn soft() -> CodecInfos {
CodecInfos { CodecInfos {
h264: Some(CodecInfo { h264: CodecInfo::software(H264),
name: "libx264".to_owned(), h265: CodecInfo::software(H265),
mc_name: Default::default(), vp8: CodecInfo::software(VP8),
format: H264, vp9: CodecInfo::software(VP9),
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
h265: Some(CodecInfo {
name: "libx265".to_owned(),
mc_name: Default::default(),
format: H265,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
vp8: Some(CodecInfo {
name: "libvpx".to_owned(),
mc_name: Default::default(),
format: VP8,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
vp9: Some(CodecInfo {
name: "libvpx-vp9".to_owned(),
mc_name: Default::default(),
format: VP9,
hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _,
}),
av1: None, av1: None,
} }
} }
@@ -191,6 +201,23 @@ pub struct CodecInfos {
} }
impl CodecInfos { impl CodecInfos {
pub fn into_vec(self) -> Vec<CodecInfo> {
let mut codecs = Vec::new();
if let Some(codec) = self.h264 {
codecs.push(codec);
}
if let Some(codec) = self.h265 {
codecs.push(codec);
}
if let Some(codec) = self.vp8 {
codecs.push(codec);
}
if let Some(codec) = self.vp9 {
codecs.push(codec);
}
codecs
}
pub fn serialize(&self) -> Result<String, ()> { pub fn serialize(&self) -> Result<String, ()> {
match serde_json::to_string_pretty(self) { match serde_json::to_string_pretty(self) {
Ok(s) => Ok(s), Ok(s) => Ok(s),

View File

@@ -93,11 +93,7 @@ mod tests {
#[test] #[test]
fn test_discover_devices() { fn test_discover_devices() {
let devices = discover_devices(); let _devices = discover_devices();
// Just verify the function runs without error
assert!(devices.gpio_chips.len() >= 0);
assert!(devices.usb_relays.len() >= 0);
assert!(devices.serial_ports.len() >= 0);
} }
#[test] #[test]

View File

@@ -13,7 +13,7 @@ use super::encoder::{OpusConfig, OpusFrame};
use super::monitor::{AudioHealthMonitor, AudioHealthStatus}; use super::monitor::{AudioHealthMonitor, AudioHealthStatus};
use super::streamer::{AudioStreamer, AudioStreamerConfig}; use super::streamer::{AudioStreamer, AudioStreamerConfig};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent}; use crate::events::EventBus;
/// Audio quality presets /// Audio quality presets
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -139,17 +139,15 @@ impl AudioController {
} }
} }
/// Set event bus for publishing audio events /// Set event bus for internal state notifications.
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) { pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
*self.event_bus.write().await = Some(event_bus.clone()); *self.event_bus.write().await = Some(event_bus);
// Also set event bus on the monitor for health notifications
self.monitor.set_event_bus(event_bus).await;
} }
/// Publish an event to the event bus /// Mark the device-info snapshot as stale.
async fn publish_event(&self, event: SystemEvent) { async fn mark_device_info_dirty(&self) {
if let Some(ref bus) = *self.event_bus.read().await { if let Some(ref bus) = *self.event_bus.read().await {
bus.publish(event); bus.mark_device_info_dirty();
} }
} }
@@ -207,12 +205,6 @@ impl AudioController {
config.device = device.to_string(); config.device = device.to_string();
} }
// Publish event
self.publish_event(SystemEvent::AudioDeviceSelected {
device: device.to_string(),
})
.await;
info!("Audio device selected: {}", device); info!("Audio device selected: {}", device);
// If streaming, restart with new device // If streaming, restart with new device
@@ -237,12 +229,6 @@ impl AudioController {
streamer.set_bitrate(quality.bitrate()).await?; streamer.set_bitrate(quality.bitrate()).await?;
} }
// Publish event
self.publish_event(SystemEvent::AudioQualityChanged {
quality: quality.to_string(),
})
.await;
info!( info!(
"Audio quality set to: {:?} ({}bps)", "Audio quality set to: {:?} ({}bps)",
quality, quality,
@@ -290,11 +276,7 @@ impl AudioController {
.report_error(Some(&config.device), &error_msg, "start_failed") .report_error(Some(&config.device), &error_msg, "start_failed")
.await; .await;
self.publish_event(SystemEvent::AudioStateChanged { self.mark_device_info_dirty().await;
streaming: false,
device: None,
})
.await;
return Err(AppError::AudioError(error_msg)); return Err(AppError::AudioError(error_msg));
} }
@@ -306,12 +288,7 @@ impl AudioController {
self.monitor.report_recovered(Some(&config.device)).await; self.monitor.report_recovered(Some(&config.device)).await;
} }
// Publish event self.mark_device_info_dirty().await;
self.publish_event(SystemEvent::AudioStateChanged {
streaming: true,
device: Some(config.device),
})
.await;
info!("Audio streaming started"); info!("Audio streaming started");
Ok(()) Ok(())
@@ -323,12 +300,7 @@ impl AudioController {
streamer.stop().await?; streamer.stop().await?;
} }
// Publish event self.mark_device_info_dirty().await;
self.publish_event(SystemEvent::AudioStateChanged {
streaming: false,
device: None,
})
.await;
info!("Audio streaming stopped"); info!("Audio streaming stopped");
Ok(()) Ok(())
@@ -408,7 +380,6 @@ impl AudioController {
/// Update full configuration /// Update full configuration
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> { pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
let was_streaming = self.is_streaming().await; let was_streaming = self.is_streaming().await;
let old_config = self.config.read().await.clone();
// Stop streaming if running // Stop streaming if running
if was_streaming { if was_streaming {
@@ -423,21 +394,6 @@ impl AudioController {
self.start_streaming().await?; self.start_streaming().await?;
} }
// Publish events for changes
if old_config.device != new_config.device {
self.publish_event(SystemEvent::AudioDeviceSelected {
device: new_config.device.clone(),
})
.await;
}
if old_config.quality != new_config.quality {
self.publish_event(SystemEvent::AudioQualityChanged {
quality: new_config.quality.to_string(),
})
.await;
}
Ok(()) Ok(())
} }

View File

@@ -3,16 +3,14 @@
//! This module provides health monitoring for audio capture devices, including: //! This module provides health monitoring for audio capture devices, including:
//! - Device connectivity checks //! - Device connectivity checks
//! - Automatic reconnection on failure //! - Automatic reconnection on failure
//! - Error tracking and notification //! - Error tracking
//! - Log throttling to prevent log flooding //! - Log throttling to prevent log flooding
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::{debug, info, warn}; use tracing::{info, warn};
use crate::events::{EventBus, SystemEvent};
use crate::utils::LogThrottler; use crate::utils::LogThrottler;
/// Audio health status /// Audio health status
@@ -58,19 +56,13 @@ impl Default for AudioMonitorConfig {
/// Audio health monitor /// Audio health monitor
/// ///
/// Monitors audio device health and manages error recovery. /// Monitors audio device health and manages error recovery.
/// Publishes WebSocket events when device status changes.
pub struct AudioHealthMonitor { pub struct AudioHealthMonitor {
/// Current health status /// Current health status
status: RwLock<AudioHealthStatus>, status: RwLock<AudioHealthStatus>,
/// Event bus for notifications
events: RwLock<Option<Arc<EventBus>>>,
/// Log throttler to prevent log flooding /// Log throttler to prevent log flooding
throttler: LogThrottler, throttler: LogThrottler,
/// Configuration /// Configuration
config: AudioMonitorConfig, config: AudioMonitorConfig,
/// Whether monitoring is active (reserved for future use)
#[allow(dead_code)]
running: AtomicBool,
/// Current retry count /// Current retry count
retry_count: AtomicU32, retry_count: AtomicU32,
/// Last error code (for change detection) /// Last error code (for change detection)
@@ -83,10 +75,8 @@ impl AudioHealthMonitor {
let throttle_secs = config.log_throttle_secs; let throttle_secs = config.log_throttle_secs;
Self { Self {
status: RwLock::new(AudioHealthStatus::Healthy), status: RwLock::new(AudioHealthStatus::Healthy),
events: RwLock::new(None),
throttler: LogThrottler::with_secs(throttle_secs), throttler: LogThrottler::with_secs(throttle_secs),
config, config,
running: AtomicBool::new(false),
retry_count: AtomicU32::new(0), retry_count: AtomicU32::new(0),
last_error_code: RwLock::new(None), last_error_code: RwLock::new(None),
} }
@@ -97,24 +87,19 @@ impl AudioHealthMonitor {
Self::new(AudioMonitorConfig::default()) Self::new(AudioMonitorConfig::default())
} }
/// Set the event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
/// Report an error from audio operations /// Report an error from audio operations
/// ///
/// This method is called when an audio operation fails. It: /// This method is called when an audio operation fails. It:
/// 1. Updates the health status /// 1. Updates the health status
/// 2. Logs the error (with throttling) /// 2. Logs the error (with throttling)
/// 3. Publishes a WebSocket event if the error is new or changed /// 3. Updates in-memory error state
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `device` - The audio device name (if known) /// * `device` - The audio device name (if known)
/// * `reason` - Human-readable error description /// * `reason` - Human-readable error description
/// * `error_code` - Error code for programmatic handling /// * `error_code` - Error code for programmatic handling
pub async fn report_error(&self, device: Option<&str>, reason: &str, error_code: &str) { pub async fn report_error(&self, _device: Option<&str>, reason: &str, error_code: &str) {
let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1; let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1;
// Check if error code changed // Check if error code changed
@@ -141,44 +126,17 @@ impl AudioHealthMonitor {
error_code: error_code.to_string(), error_code: error_code.to_string(),
retry_count: count, retry_count: count,
}; };
// Publish event (only if error changed or first occurrence)
if error_changed || count == 1 {
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::AudioDeviceLost {
device: device.map(|s| s.to_string()),
reason: reason.to_string(),
error_code: error_code.to_string(),
});
}
}
}
/// Report that a reconnection attempt is starting
///
/// Publishes a reconnecting event to notify clients.
pub async fn report_reconnecting(&self) {
let attempt = self.retry_count.load(Ordering::Relaxed);
// Only publish every 5 attempts to avoid event spam
if attempt == 1 || attempt.is_multiple_of(5) {
debug!("Audio reconnecting, attempt {}", attempt);
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::AudioReconnecting { attempt });
}
}
} }
/// Report that the device has recovered /// Report that the device has recovered
/// ///
/// This method is called when the audio device successfully reconnects. /// This method is called when the audio device successfully reconnects.
/// It resets the error state and publishes a recovery event. /// It resets the error state.
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `device` - The audio device name /// * `device` - The audio device name
pub async fn report_recovered(&self, device: Option<&str>) { pub async fn report_recovered(&self, _device: Option<&str>) {
let prev_status = self.status.read().await.clone(); let prev_status = self.status.read().await.clone();
// Only report recovery if we were in an error state // Only report recovery if we were in an error state
@@ -191,13 +149,6 @@ impl AudioHealthMonitor {
self.throttler.clear("audio_"); self.throttler.clear("audio_");
*self.last_error_code.write().await = None; *self.last_error_code.write().await = None;
*self.status.write().await = AudioHealthStatus::Healthy; *self.status.write().await = AudioHealthStatus::Healthy;
// Publish recovery event
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::AudioRecovered {
device: device.map(|s| s.to_string()),
});
}
} }
} }

View File

@@ -148,13 +148,11 @@ impl Default for OtgDescriptorConfig {
pub enum OtgHidProfile { pub enum OtgHidProfile {
/// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) /// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control)
#[default] #[default]
#[serde(alias = "full_no_msd")]
Full, Full,
/// Full HID device set without MSD
FullNoMsd,
/// Full HID device set without consumer control /// Full HID device set without consumer control
#[serde(alias = "full_no_consumer_no_msd")]
FullNoConsumer, FullNoConsumer,
/// Full HID device set without consumer control and MSD
FullNoConsumerNoMsd,
/// Legacy profile: only keyboard /// Legacy profile: only keyboard
LegacyKeyboard, LegacyKeyboard,
/// Legacy profile: only relative mouse /// Legacy profile: only relative mouse
@@ -163,9 +161,52 @@ pub enum OtgHidProfile {
Custom, Custom,
} }
/// OTG endpoint budget policy.
#[typeshare]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum OtgEndpointBudget {
/// Derive a safe default from the selected UDC.
#[default]
Auto,
/// Limit OTG gadget functions to 5 endpoints.
Five,
/// Limit OTG gadget functions to 6 endpoints.
Six,
/// Do not impose a software endpoint budget.
Unlimited,
}
impl OtgEndpointBudget {
pub fn default_for_udc_name(udc: Option<&str>) -> Self {
if udc.is_some_and(crate::otg::configfs::is_low_endpoint_udc) {
Self::Five
} else {
Self::Six
}
}
pub fn resolved(self, udc: Option<&str>) -> Self {
match self {
Self::Auto => Self::default_for_udc_name(udc),
other => other,
}
}
pub fn endpoint_limit(self, udc: Option<&str>) -> Option<u8> {
match self.resolved(udc) {
Self::Five => Some(5),
Self::Six => Some(6),
Self::Unlimited => None,
Self::Auto => unreachable!("auto budget must be resolved before use"),
}
}
}
/// OTG HID function selection (used when profile is Custom) /// OTG HID function selection (used when profile is Custom)
#[typeshare] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)] #[serde(default)]
pub struct OtgHidFunctions { pub struct OtgHidFunctions {
pub keyboard: bool, pub keyboard: bool,
@@ -214,6 +255,26 @@ impl OtgHidFunctions {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
!self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer !self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer
} }
pub fn endpoint_cost(&self, keyboard_leds: bool) -> u8 {
let mut endpoints = 0;
if self.keyboard {
endpoints += 1;
if keyboard_leds {
endpoints += 1;
}
}
if self.mouse_relative {
endpoints += 1;
}
if self.mouse_absolute {
endpoints += 1;
}
if self.consumer {
endpoints += 1;
}
endpoints
}
} }
impl Default for OtgHidFunctions { impl Default for OtgHidFunctions {
@@ -223,12 +284,21 @@ impl Default for OtgHidFunctions {
} }
impl OtgHidProfile { impl OtgHidProfile {
pub fn from_legacy_str(value: &str) -> Option<Self> {
match value {
"full" | "full_no_msd" => Some(Self::Full),
"full_no_consumer" | "full_no_consumer_no_msd" => Some(Self::FullNoConsumer),
"legacy_keyboard" => Some(Self::LegacyKeyboard),
"legacy_mouse_relative" => Some(Self::LegacyMouseRelative),
"custom" => Some(Self::Custom),
_ => None,
}
}
pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions { pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions {
match self { match self {
Self::Full => OtgHidFunctions::full(), Self::Full => OtgHidFunctions::full(),
Self::FullNoMsd => OtgHidFunctions::full(),
Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(), Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(),
Self::FullNoConsumerNoMsd => OtgHidFunctions::full_no_consumer(),
Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(), Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(),
Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(), Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(),
Self::Custom => custom.clone(), Self::Custom => custom.clone(),
@@ -243,10 +313,6 @@ impl OtgHidProfile {
pub struct HidConfig { pub struct HidConfig {
/// HID backend type /// HID backend type
pub backend: HidBackend, pub backend: HidBackend,
/// OTG keyboard device path
pub otg_keyboard: String,
/// OTG mouse device path
pub otg_mouse: String,
/// OTG UDC (USB Device Controller) name /// OTG UDC (USB Device Controller) name
pub otg_udc: Option<String>, pub otg_udc: Option<String>,
/// OTG USB device descriptor configuration /// OTG USB device descriptor configuration
@@ -255,9 +321,15 @@ pub struct HidConfig {
/// OTG HID function profile /// OTG HID function profile
#[serde(default)] #[serde(default)]
pub otg_profile: OtgHidProfile, pub otg_profile: OtgHidProfile,
/// OTG endpoint budget policy
#[serde(default)]
pub otg_endpoint_budget: OtgEndpointBudget,
/// OTG HID function selection (used when profile is Custom) /// OTG HID function selection (used when profile is Custom)
#[serde(default)] #[serde(default)]
pub otg_functions: OtgHidFunctions, pub otg_functions: OtgHidFunctions,
/// Enable keyboard LED/status feedback for OTG keyboard
#[serde(default)]
pub otg_keyboard_leds: bool,
/// CH9329 serial port /// CH9329 serial port
pub ch9329_port: String, pub ch9329_port: String,
/// CH9329 baud rate /// CH9329 baud rate
@@ -270,12 +342,12 @@ impl Default for HidConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
backend: HidBackend::None, backend: HidBackend::None,
otg_keyboard: "/dev/hidg0".to_string(),
otg_mouse: "/dev/hidg1".to_string(),
otg_udc: None, otg_udc: None,
otg_descriptor: OtgDescriptorConfig::default(), otg_descriptor: OtgDescriptorConfig::default(),
otg_profile: OtgHidProfile::default(), otg_profile: OtgHidProfile::default(),
otg_endpoint_budget: OtgEndpointBudget::default(),
otg_functions: OtgHidFunctions::default(), otg_functions: OtgHidFunctions::default(),
otg_keyboard_leds: false,
ch9329_port: "/dev/ttyUSB0".to_string(), ch9329_port: "/dev/ttyUSB0".to_string(),
ch9329_baudrate: 9600, ch9329_baudrate: 9600,
mouse_absolute: true, mouse_absolute: true,
@@ -287,6 +359,62 @@ impl HidConfig {
pub fn effective_otg_functions(&self) -> OtgHidFunctions { pub fn effective_otg_functions(&self) -> OtgHidFunctions {
self.otg_profile.resolve_functions(&self.otg_functions) self.otg_profile.resolve_functions(&self.otg_functions)
} }
pub fn resolved_otg_udc(&self) -> Option<String> {
crate::otg::configfs::resolve_udc_name(self.otg_udc.as_deref())
}
pub fn resolved_otg_endpoint_budget(&self) -> OtgEndpointBudget {
self.otg_endpoint_budget
.resolved(self.resolved_otg_udc().as_deref())
}
pub fn resolved_otg_endpoint_limit(&self) -> Option<u8> {
self.otg_endpoint_budget
.endpoint_limit(self.resolved_otg_udc().as_deref())
}
pub fn effective_otg_keyboard_leds(&self) -> bool {
self.otg_keyboard_leds && self.effective_otg_functions().keyboard
}
pub fn constrained_otg_functions(&self) -> OtgHidFunctions {
self.effective_otg_functions()
}
pub fn effective_otg_required_endpoints(&self, msd_enabled: bool) -> u8 {
let functions = self.effective_otg_functions();
let mut endpoints = functions.endpoint_cost(self.effective_otg_keyboard_leds());
if msd_enabled {
endpoints += 2;
}
endpoints
}
pub fn validate_otg_endpoint_budget(&self, msd_enabled: bool) -> crate::error::Result<()> {
if self.backend != HidBackend::Otg {
return Ok(());
}
let functions = self.effective_otg_functions();
if functions.is_empty() {
return Err(crate::error::AppError::BadRequest(
"OTG HID functions cannot be empty".to_string(),
));
}
let required = self.effective_otg_required_endpoints(msd_enabled);
if let Some(limit) = self.resolved_otg_endpoint_limit() {
if required > limit {
return Err(crate::error::AppError::BadRequest(format!(
"OTG selection requires {} endpoints, but the configured limit is {}",
required, limit
)));
}
}
Ok(())
}
} }
/// MSD configuration /// MSD configuration

View File

@@ -7,7 +7,7 @@ pub mod types;
pub use types::{ pub use types::{
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent, AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
VideoDeviceInfo, TtydDeviceInfo, VideoDeviceInfo,
}; };
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -15,6 +15,39 @@ use tokio::sync::broadcast;
/// Event channel capacity (ring buffer size) /// Event channel capacity (ring buffer size)
const EVENT_CHANNEL_CAPACITY: usize = 256; const EVENT_CHANNEL_CAPACITY: usize = 256;
const EXACT_TOPICS: &[&str] = &[
"stream.mode_switching",
"stream.state_changed",
"stream.config_changing",
"stream.config_applied",
"stream.device_lost",
"stream.reconnecting",
"stream.recovered",
"stream.webrtc_ready",
"stream.stats_update",
"stream.mode_changed",
"stream.mode_ready",
"webrtc.ice_candidate",
"webrtc.ice_complete",
"msd.upload_progress",
"msd.download_progress",
"system.device_info",
"error",
];
const PREFIX_TOPICS: &[&str] = &["stream.*", "webrtc.*", "msd.*", "system.*"];
fn make_sender() -> broadcast::Sender<SystemEvent> {
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
tx
}
fn topic_prefix(event_name: &str) -> Option<String> {
event_name
.split_once('.')
.map(|(prefix, _)| format!("{}.*", prefix))
}
/// Global event bus for broadcasting system events /// Global event bus for broadcasting system events
/// ///
/// The event bus uses tokio's broadcast channel to distribute events /// The event bus uses tokio's broadcast channel to distribute events
@@ -43,13 +76,31 @@ const EVENT_CHANNEL_CAPACITY: usize = 256;
/// ``` /// ```
pub struct EventBus { pub struct EventBus {
tx: broadcast::Sender<SystemEvent>, tx: broadcast::Sender<SystemEvent>,
exact_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
prefix_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
device_info_dirty_tx: broadcast::Sender<()>,
} }
impl EventBus { impl EventBus {
/// Create a new event bus /// Create a new event bus
pub fn new() -> Self { pub fn new() -> Self {
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY); let tx = make_sender();
Self { tx } let exact_topics = EXACT_TOPICS
.iter()
.map(|topic| (*topic, make_sender()))
.collect();
let prefix_topics = PREFIX_TOPICS
.iter()
.map(|topic| (*topic, make_sender()))
.collect();
let (device_info_dirty_tx, _dirty_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
Self {
tx,
exact_topics,
prefix_topics,
device_info_dirty_tx,
}
} }
/// Publish an event to all subscribers /// Publish an event to all subscribers
@@ -57,6 +108,18 @@ impl EventBus {
/// If there are no active subscribers, the event is silently dropped. /// If there are no active subscribers, the event is silently dropped.
/// This is by design - events are fire-and-forget notifications. /// This is by design - events are fire-and-forget notifications.
pub fn publish(&self, event: SystemEvent) { pub fn publish(&self, event: SystemEvent) {
let event_name = event.event_name();
if let Some(tx) = self.exact_topics.get(event_name) {
let _ = tx.send(event.clone());
}
if let Some(prefix) = topic_prefix(event_name) {
if let Some(tx) = self.prefix_topics.get(prefix.as_str()) {
let _ = tx.send(event.clone());
}
}
// If no subscribers, send returns Err which is normal // If no subscribers, send returns Err which is normal
let _ = self.tx.send(event); let _ = self.tx.send(event);
} }
@@ -70,6 +133,35 @@ impl EventBus {
self.tx.subscribe() self.tx.subscribe()
} }
/// Subscribe to a specific topic.
///
/// Supports exact event names, namespace wildcards like `stream.*`, and
/// `*` for the full event stream.
pub fn subscribe_topic(&self, topic: &str) -> Option<broadcast::Receiver<SystemEvent>> {
if topic == "*" {
return Some(self.tx.subscribe());
}
if topic.ends_with(".*") {
return self.prefix_topics.get(topic).map(|tx| tx.subscribe());
}
self.exact_topics.get(topic).map(|tx| tx.subscribe())
}
/// Mark the device-info snapshot as stale.
///
/// This is an internal trigger used to refresh the latest `system.device_info`
/// snapshot without exposing another public WebSocket event.
pub fn mark_device_info_dirty(&self) {
let _ = self.device_info_dirty_tx.send(());
}
/// Subscribe to internal device-info refresh triggers.
pub fn subscribe_device_info_dirty(&self) -> broadcast::Receiver<()> {
self.device_info_dirty_tx.subscribe()
}
/// Get the current number of active subscribers /// Get the current number of active subscribers
/// ///
/// Useful for monitoring and debugging. /// Useful for monitoring and debugging.
@@ -110,17 +202,50 @@ mod tests {
assert_eq!(bus.subscriber_count(), 2); assert_eq!(bus.subscriber_count(), 2);
bus.publish(SystemEvent::SystemError { bus.publish(SystemEvent::StreamStateChanged {
module: "test".to_string(), state: "ready".to_string(),
severity: "info".to_string(), device: Some("/dev/video0".to_string()),
message: "test message".to_string(),
}); });
let event1 = rx1.recv().await.unwrap(); let event1 = rx1.recv().await.unwrap();
let event2 = rx2.recv().await.unwrap(); let event2 = rx2.recv().await.unwrap();
assert!(matches!(event1, SystemEvent::SystemError { .. })); assert!(matches!(event1, SystemEvent::StreamStateChanged { .. }));
assert!(matches!(event2, SystemEvent::SystemError { .. })); assert!(matches!(event2, SystemEvent::StreamStateChanged { .. }));
}
#[tokio::test]
async fn test_subscribe_topic_exact() {
let bus = EventBus::new();
let mut rx = bus.subscribe_topic("stream.state_changed").unwrap();
bus.publish(SystemEvent::StreamStateChanged {
state: "ready".to_string(),
device: None,
});
let event = rx.recv().await.unwrap();
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
}
#[tokio::test]
async fn test_subscribe_topic_prefix() {
let bus = EventBus::new();
let mut rx = bus.subscribe_topic("stream.*").unwrap();
bus.publish(SystemEvent::StreamStateChanged {
state: "ready".to_string(),
device: None,
});
let event = rx.recv().await.unwrap();
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
}
#[test]
fn test_subscribe_topic_unknown() {
let bus = EventBus::new();
assert!(bus.subscribe_topic("unknown.topic").is_none());
} }
#[test] #[test]
@@ -129,10 +254,9 @@ mod tests {
assert_eq!(bus.subscriber_count(), 0); assert_eq!(bus.subscriber_count(), 0);
// Should not panic when publishing with no subscribers // Should not panic when publishing with no subscribers
bus.publish(SystemEvent::SystemError { bus.publish(SystemEvent::StreamStateChanged {
module: "test".to_string(), state: "ready".to_string(),
severity: "info".to_string(), device: None,
message: "test".to_string(),
}); });
} }
} }

View File

@@ -2,12 +2,10 @@
//! //!
//! Defines all event types that can be broadcast through the event bus. //! Defines all event types that can be broadcast through the event bus.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use crate::atx::PowerStatus; use crate::hid::LedState;
use crate::msd::MsdMode;
// ============================================================================ // ============================================================================
// Device Info Structures (for system.device_info event) // Device Info Structures (for system.device_info event)
@@ -45,12 +43,20 @@ pub struct HidDeviceInfo {
pub backend: String, pub backend: String,
/// Whether backend is initialized and ready /// Whether backend is initialized and ready
pub initialized: bool, pub initialized: bool,
/// Whether backend is currently online
pub online: bool,
/// Whether absolute mouse positioning is supported /// Whether absolute mouse positioning is supported
pub supports_absolute_mouse: bool, pub supports_absolute_mouse: bool,
/// Whether keyboard LED/status feedback is enabled.
pub keyboard_leds_enabled: bool,
/// Last known keyboard LED state.
pub led_state: LedState,
/// Device path (e.g., serial port for CH9329) /// Device path (e.g., serial port for CH9329)
pub device: Option<String>, pub device: Option<String>,
/// Error message if any, None if OK /// Error message if any, None if OK
pub error: Option<String>, pub error: Option<String>,
/// Error code if any, None if OK
pub error_code: Option<String>,
} }
/// MSD device information /// MSD device information
@@ -100,6 +106,15 @@ pub struct AudioDeviceInfo {
pub error: Option<String>, pub error: Option<String>,
} }
/// ttyd status information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TtydDeviceInfo {
/// Whether ttyd binary is available
pub available: bool,
/// Whether ttyd is currently running
pub running: bool,
}
/// Per-client statistics /// Per-client statistics
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientStats { pub struct ClientStats {
@@ -275,89 +290,9 @@ pub enum SystemEvent {
mode: String, mode: String,
}, },
// ============================================================================
// HID Events
// ============================================================================
/// HID backend state changed
#[serde(rename = "hid.state_changed")]
HidStateChanged {
/// Backend type: "otg", "ch9329", "none"
backend: String,
/// Whether backend is initialized and ready
initialized: bool,
/// Error message if any, None if OK
error: Option<String>,
/// Error code for programmatic handling: "epipe", "eagain", "port_not_found", etc.
error_code: Option<String>,
},
/// HID backend is being switched
#[serde(rename = "hid.backend_switching")]
HidBackendSwitching {
/// Current backend
from: String,
/// New backend
to: String,
},
/// HID device lost (device file missing or I/O error)
#[serde(rename = "hid.device_lost")]
HidDeviceLost {
/// Backend type: "otg", "ch9329"
backend: String,
/// Device path that was lost (e.g., /dev/hidg0 or /dev/ttyUSB0)
device: Option<String>,
/// Human-readable reason for loss
reason: String,
/// Error code: "epipe", "eshutdown", "eagain", "enxio", "port_not_found", "io_error"
error_code: String,
},
/// HID device is reconnecting
#[serde(rename = "hid.reconnecting")]
HidReconnecting {
/// Backend type: "otg", "ch9329"
backend: String,
/// Current retry attempt number
attempt: u32,
},
/// HID device has recovered after error
#[serde(rename = "hid.recovered")]
HidRecovered {
/// Backend type: "otg", "ch9329"
backend: String,
},
// ============================================================================ // ============================================================================
// MSD (Mass Storage Device) Events // MSD (Mass Storage Device) Events
// ============================================================================ // ============================================================================
/// MSD state changed
#[serde(rename = "msd.state_changed")]
MsdStateChanged {
/// Operating mode
mode: MsdMode,
/// Whether storage is connected to target
connected: bool,
},
/// Image has been mounted
#[serde(rename = "msd.image_mounted")]
MsdImageMounted {
/// Image ID
image_id: String,
/// Image filename
image_name: String,
/// Image size in bytes
size: u64,
/// Mount as CD-ROM (read-only)
cdrom: bool,
},
/// Image has been unmounted
#[serde(rename = "msd.image_unmounted")]
MsdImageUnmounted,
/// File upload progress (for large file uploads) /// File upload progress (for large file uploads)
#[serde(rename = "msd.upload_progress")] #[serde(rename = "msd.upload_progress")]
MsdUploadProgress { MsdUploadProgress {
@@ -392,132 +327,6 @@ pub enum SystemEvent {
status: String, status: String,
}, },
/// USB gadget connection status changed (host connected/disconnected)
#[serde(rename = "msd.usb_status_changed")]
MsdUsbStatusChanged {
/// Whether host is connected to USB device
connected: bool,
/// USB device state from kernel (e.g., "configured", "not attached")
device_state: String,
},
/// MSD operation error (configfs, image mount, etc.)
#[serde(rename = "msd.error")]
MsdError {
/// Human-readable reason for error
reason: String,
/// Error code: "configfs_error", "image_not_found", "mount_failed", "io_error"
error_code: String,
},
/// MSD has recovered after error
#[serde(rename = "msd.recovered")]
MsdRecovered,
// ============================================================================
// ATX (Power Control) Events
// ============================================================================
/// ATX power state changed
#[serde(rename = "atx.state_changed")]
AtxStateChanged {
/// Power status
power_status: PowerStatus,
},
/// ATX action was executed
#[serde(rename = "atx.action_executed")]
AtxActionExecuted {
/// Action: "short", "long", "reset"
action: String,
/// When the action was executed
timestamp: DateTime<Utc>,
},
// ============================================================================
// Audio Events
// ============================================================================
/// Audio state changed (streaming started/stopped)
#[serde(rename = "audio.state_changed")]
AudioStateChanged {
/// Whether audio is currently streaming
streaming: bool,
/// Current device (None if stopped)
device: Option<String>,
},
/// Audio device was selected
#[serde(rename = "audio.device_selected")]
AudioDeviceSelected {
/// Selected device name
device: String,
},
/// Audio quality was changed
#[serde(rename = "audio.quality_changed")]
AudioQualityChanged {
/// New quality setting: "voice", "balanced", "high"
quality: String,
},
/// Audio device lost (capture error or device disconnected)
#[serde(rename = "audio.device_lost")]
AudioDeviceLost {
/// Audio device name (e.g., "hw:0,0")
device: Option<String>,
/// Human-readable reason for loss
reason: String,
/// Error code: "device_busy", "device_disconnected", "capture_error", "io_error"
error_code: String,
},
/// Audio device is reconnecting
#[serde(rename = "audio.reconnecting")]
AudioReconnecting {
/// Current retry attempt number
attempt: u32,
},
/// Audio device has recovered after error
#[serde(rename = "audio.recovered")]
AudioRecovered {
/// Audio device name
device: Option<String>,
},
// ============================================================================
// System Events
// ============================================================================
/// A device was added (hot-plug)
#[serde(rename = "system.device_added")]
SystemDeviceAdded {
/// Device type: "video", "audio", "hid", etc.
device_type: String,
/// Device path
device_path: String,
/// Device name/description
device_name: String,
},
/// A device was removed (hot-unplug)
#[serde(rename = "system.device_removed")]
SystemDeviceRemoved {
/// Device type
device_type: String,
/// Device path that was removed
device_path: String,
},
/// System error or warning
#[serde(rename = "system.error")]
SystemError {
/// Module that generated the error: "stream", "hid", "msd", "atx"
module: String,
/// Severity: "warning", "error", "critical"
severity: String,
/// Error message
message: String,
},
/// Complete device information (sent on WebSocket connect and state changes) /// Complete device information (sent on WebSocket connect and state changes)
#[serde(rename = "system.device_info")] #[serde(rename = "system.device_info")]
DeviceInfo { DeviceInfo {
@@ -531,6 +340,8 @@ pub enum SystemEvent {
atx: Option<AtxDeviceInfo>, atx: Option<AtxDeviceInfo>,
/// Audio device information (None if audio not enabled) /// Audio device information (None if audio not enabled)
audio: Option<AudioDeviceInfo>, audio: Option<AudioDeviceInfo>,
/// ttyd status information
ttyd: TtydDeviceInfo,
}, },
/// WebSocket error notification (for connection-level errors like lag) /// WebSocket error notification (for connection-level errors like lag)
@@ -558,30 +369,8 @@ impl SystemEvent {
Self::StreamModeReady { .. } => "stream.mode_ready", Self::StreamModeReady { .. } => "stream.mode_ready",
Self::WebRTCIceCandidate { .. } => "webrtc.ice_candidate", Self::WebRTCIceCandidate { .. } => "webrtc.ice_candidate",
Self::WebRTCIceComplete { .. } => "webrtc.ice_complete", Self::WebRTCIceComplete { .. } => "webrtc.ice_complete",
Self::HidStateChanged { .. } => "hid.state_changed",
Self::HidBackendSwitching { .. } => "hid.backend_switching",
Self::HidDeviceLost { .. } => "hid.device_lost",
Self::HidReconnecting { .. } => "hid.reconnecting",
Self::HidRecovered { .. } => "hid.recovered",
Self::MsdStateChanged { .. } => "msd.state_changed",
Self::MsdImageMounted { .. } => "msd.image_mounted",
Self::MsdImageUnmounted => "msd.image_unmounted",
Self::MsdUploadProgress { .. } => "msd.upload_progress", Self::MsdUploadProgress { .. } => "msd.upload_progress",
Self::MsdDownloadProgress { .. } => "msd.download_progress", Self::MsdDownloadProgress { .. } => "msd.download_progress",
Self::MsdUsbStatusChanged { .. } => "msd.usb_status_changed",
Self::MsdError { .. } => "msd.error",
Self::MsdRecovered => "msd.recovered",
Self::AtxStateChanged { .. } => "atx.state_changed",
Self::AtxActionExecuted { .. } => "atx.action_executed",
Self::AudioStateChanged { .. } => "audio.state_changed",
Self::AudioDeviceSelected { .. } => "audio.device_selected",
Self::AudioQualityChanged { .. } => "audio.quality_changed",
Self::AudioDeviceLost { .. } => "audio.device_lost",
Self::AudioReconnecting { .. } => "audio.reconnecting",
Self::AudioRecovered { .. } => "audio.recovered",
Self::SystemDeviceAdded { .. } => "system.device_added",
Self::SystemDeviceRemoved { .. } => "system.device_removed",
Self::SystemError { .. } => "system.error",
Self::DeviceInfo { .. } => "system.device_info", Self::DeviceInfo { .. } => "system.device_info",
Self::Error { .. } => "error", Self::Error { .. } => "error",
} }
@@ -620,14 +409,6 @@ mod tests {
device: Some("/dev/video0".to_string()), device: Some("/dev/video0".to_string()),
}; };
assert_eq!(event.event_name(), "stream.state_changed"); assert_eq!(event.event_name(), "stream.state_changed");
let event = SystemEvent::MsdImageMounted {
image_id: "123".to_string(),
image_name: "ubuntu.iso".to_string(),
size: 1024,
cdrom: true,
};
assert_eq!(event.event_name(), "msd.image_mounted");
} }
#[test] #[test]

View File

@@ -10,6 +10,7 @@ use tokio::process::{Child, Command};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use super::types::*; use super::types::*;
use crate::events::EventBus;
/// Maximum number of log lines to keep per extension /// Maximum number of log lines to keep per extension
const LOG_BUFFER_SIZE: usize = 200; const LOG_BUFFER_SIZE: usize = 200;
@@ -31,6 +32,7 @@ pub struct ExtensionManager {
processes: RwLock<HashMap<ExtensionId, ExtensionProcess>>, processes: RwLock<HashMap<ExtensionId, ExtensionProcess>>,
/// Cached availability status (checked once at startup) /// Cached availability status (checked once at startup)
availability: HashMap<ExtensionId, bool>, availability: HashMap<ExtensionId, bool>,
event_bus: RwLock<Option<Arc<EventBus>>>,
} }
impl Default for ExtensionManager { impl Default for ExtensionManager {
@@ -51,6 +53,22 @@ impl ExtensionManager {
Self { Self {
processes: RwLock::new(HashMap::new()), processes: RwLock::new(HashMap::new()),
availability, availability,
event_bus: RwLock::new(None),
}
}
/// Set event bus for ttyd status notifications.
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
*self.event_bus.write().await = Some(event_bus);
}
async fn mark_ttyd_status_dirty(&self, id: ExtensionId) {
if id != ExtensionId::Ttyd {
return;
}
if let Some(ref event_bus) = *self.event_bus.read().await {
event_bus.mark_device_info_dirty();
} }
} }
@@ -65,17 +83,38 @@ impl ExtensionManager {
return ExtensionStatus::Unavailable; return ExtensionStatus::Unavailable;
} }
let processes = self.processes.read().await; let mut processes = self.processes.write().await;
match processes.get(&id) { let exited = {
Some(proc) => { let Some(proc) = processes.get_mut(&id) else {
if let Some(pid) = proc.child.id() { return ExtensionStatus::Stopped;
ExtensionStatus::Running { pid } };
} else {
ExtensionStatus::Stopped match proc.child.try_wait() {
} Ok(Some(status)) => {
tracing::info!("Extension {} exited with status {}", id, status);
true
} }
Ok(None) => {
return match proc.child.id() {
Some(pid) => ExtensionStatus::Running { pid },
None => ExtensionStatus::Stopped, None => ExtensionStatus::Stopped,
};
} }
Err(e) => {
tracing::warn!("Failed to query status for {}: {}", id, e);
return match proc.child.id() {
Some(pid) => ExtensionStatus::Running { pid },
None => ExtensionStatus::Stopped,
};
}
}
};
if exited {
processes.remove(&id);
}
ExtensionStatus::Stopped
} }
/// Start an extension with the given configuration /// Start an extension with the given configuration
@@ -134,6 +173,8 @@ impl ExtensionManager {
let mut processes = self.processes.write().await; let mut processes = self.processes.write().await;
processes.insert(id, ExtensionProcess { child, logs }); processes.insert(id, ExtensionProcess { child, logs });
drop(processes);
self.mark_ttyd_status_dirty(id).await;
Ok(()) Ok(())
} }
@@ -146,6 +187,8 @@ impl ExtensionManager {
if let Err(e) = proc.child.kill().await { if let Err(e) = proc.child.kill().await {
tracing::warn!("Failed to kill {}: {}", id, e); tracing::warn!("Failed to kill {}: {}", id, e);
} }
drop(processes);
self.mark_ttyd_status_dirty(id).await;
} }
Ok(()) Ok(())
} }

View File

@@ -2,7 +2,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use super::otg::LedState;
use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent}; use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent};
use crate::error::Result; use crate::error::Result;
@@ -75,12 +77,32 @@ impl HidBackendType {
} }
} }
/// Current runtime status reported by a HID backend.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HidBackendRuntimeSnapshot {
/// Whether the backend has been initialized and can accept requests.
pub initialized: bool,
/// Whether the backend is currently online and communicating successfully.
pub online: bool,
/// Whether absolute mouse positioning is supported.
pub supports_absolute_mouse: bool,
/// Whether keyboard LED/status feedback is currently enabled.
pub keyboard_leds_enabled: bool,
/// Last known keyboard LED state.
pub led_state: LedState,
/// Screen resolution for absolute mouse mode.
pub screen_resolution: Option<(u32, u32)>,
/// Device identifier associated with the backend, if any.
pub device: Option<String>,
/// Current user-facing error, if any.
pub error: Option<String>,
/// Current programmatic error code, if any.
pub error_code: Option<String>,
}
/// HID backend trait /// HID backend trait
#[async_trait] #[async_trait]
pub trait HidBackend: Send + Sync { pub trait HidBackend: Send + Sync {
/// Get backend name
fn name(&self) -> &'static str;
/// Initialize the backend /// Initialize the backend
async fn init(&self) -> Result<()>; async fn init(&self) -> Result<()>;
@@ -104,22 +126,11 @@ pub trait HidBackend: Send + Sync {
/// Shutdown the backend /// Shutdown the backend
async fn shutdown(&self) -> Result<()>; async fn shutdown(&self) -> Result<()>;
/// Perform backend health check. /// Get the current backend runtime snapshot.
/// fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;
/// Default implementation assumes backend is healthy.
fn health_check(&self) -> Result<()> {
Ok(())
}
/// Check if backend supports absolute mouse positioning /// Subscribe to backend runtime changes.
fn supports_absolute_mouse(&self) -> bool { fn subscribe_runtime(&self) -> watch::Receiver<()>;
false
}
/// Get screen resolution (for absolute mouse)
fn screen_resolution(&self) -> Option<(u32, u32)> {
None
}
/// Set screen resolution (for absolute mouse) /// Set screen resolution (for absolute mouse)
fn set_screen_resolution(&mut self, _width: u32, _height: u32) {} fn set_screen_resolution(&mut self, _width: u32, _height: u32) {}

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
//! //!
//! Keyboard event (type 0x01): //! Keyboard event (type 0x01):
//! - Byte 1: Event type (0x00 = down, 0x01 = up) //! - Byte 1: Event type (0x00 = down, 0x01 = up)
//! - Byte 2: Key code (USB HID usage code) //! - Byte 2: Canonical key code (stable One-KVM key id aligned with HID usage)
//! - Byte 3: Modifiers bitmask //! - Byte 3: Modifiers bitmask
//! - Bit 0: Left Ctrl //! - Bit 0: Left Ctrl
//! - Bit 1: Left Shift //! - Bit 1: Left Shift
@@ -38,7 +38,8 @@ use tracing::warn;
use super::types::ConsumerEvent; use super::types::ConsumerEvent;
use super::{ use super::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, MouseEventType, CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
MouseEventType,
}; };
/// Message types /// Message types
@@ -101,7 +102,13 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
} }
}; };
let key = data[1]; let key = match CanonicalKey::from_hid_usage(data[1]) {
Some(key) => key,
None => {
warn!("Unknown canonical keyboard key code: 0x{:02X}", data[1]);
return None;
}
};
let modifiers_byte = data[2]; let modifiers_byte = data[2];
let modifiers = KeyboardModifiers { let modifiers = KeyboardModifiers {
@@ -119,7 +126,6 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
event_type, event_type,
key, key,
modifiers, modifiers,
is_usb_hid: true, // WebRTC/WebSocket HID channel sends USB HID usages
})) }))
} }
@@ -193,7 +199,12 @@ pub fn encode_keyboard_event(event: &KeyboardEvent) -> Vec<u8> {
let modifiers = event.modifiers.to_hid_byte(); let modifiers = event.modifiers.to_hid_byte();
vec![MSG_KEYBOARD, event_type, event.key, modifiers] vec![
MSG_KEYBOARD,
event_type,
event.key.to_hid_usage(),
modifiers,
]
} }
/// Encode a mouse event to binary format (for sending to client if needed) /// Encode a mouse event to binary format (for sending to client if needed)
@@ -242,10 +253,9 @@ mod tests {
match event { match event {
HidChannelEvent::Keyboard(kb) => { HidChannelEvent::Keyboard(kb) => {
assert!(matches!(kb.event_type, KeyEventType::Down)); assert!(matches!(kb.event_type, KeyEventType::Down));
assert_eq!(kb.key, 0x04); assert_eq!(kb.key, CanonicalKey::KeyA);
assert!(kb.modifiers.left_ctrl); assert!(kb.modifiers.left_ctrl);
assert!(!kb.modifiers.left_shift); assert!(!kb.modifiers.left_shift);
assert!(kb.is_usb_hid);
} }
_ => panic!("Expected keyboard event"), _ => panic!("Expected keyboard event"),
} }
@@ -270,7 +280,7 @@ mod tests {
fn test_encode_keyboard() { fn test_encode_keyboard() {
let event = KeyboardEvent { let event = KeyboardEvent {
event_type: KeyEventType::Down, event_type: KeyEventType::Down,
key: 0x04, key: CanonicalKey::KeyA,
modifiers: KeyboardModifiers { modifiers: KeyboardModifiers {
left_ctrl: true, left_ctrl: true,
left_shift: false, left_shift: false,
@@ -281,7 +291,6 @@ mod tests {
right_alt: false, right_alt: false,
right_meta: false, right_meta: false,
}, },
is_usb_hid: true,
}; };
let encoded = encode_keyboard_event(&event); let encoded = encode_keyboard_event(&event);

409
src/hid/keyboard.rs Normal file
View File

@@ -0,0 +1,409 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
/// Shared canonical keyboard key identifiers used across frontend and backend.
///
/// The enum names intentionally mirror `KeyboardEvent.code` style values so the
/// browser, virtual keyboard, and HID backend can all speak the same language.
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CanonicalKey {
KeyA,
KeyB,
KeyC,
KeyD,
KeyE,
KeyF,
KeyG,
KeyH,
KeyI,
KeyJ,
KeyK,
KeyL,
KeyM,
KeyN,
KeyO,
KeyP,
KeyQ,
KeyR,
KeyS,
KeyT,
KeyU,
KeyV,
KeyW,
KeyX,
KeyY,
KeyZ,
Digit1,
Digit2,
Digit3,
Digit4,
Digit5,
Digit6,
Digit7,
Digit8,
Digit9,
Digit0,
Enter,
Escape,
Backspace,
Tab,
Space,
Minus,
Equal,
BracketLeft,
BracketRight,
Backslash,
Semicolon,
Quote,
Backquote,
Comma,
Period,
Slash,
CapsLock,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
PrintScreen,
ScrollLock,
Pause,
Insert,
Home,
PageUp,
Delete,
End,
PageDown,
ArrowRight,
ArrowLeft,
ArrowDown,
ArrowUp,
NumLock,
NumpadDivide,
NumpadMultiply,
NumpadSubtract,
NumpadAdd,
NumpadEnter,
Numpad1,
Numpad2,
Numpad3,
Numpad4,
Numpad5,
Numpad6,
Numpad7,
Numpad8,
Numpad9,
Numpad0,
NumpadDecimal,
IntlBackslash,
ContextMenu,
F13,
F14,
F15,
F16,
F17,
F18,
F19,
F20,
F21,
F22,
F23,
F24,
ControlLeft,
ShiftLeft,
AltLeft,
MetaLeft,
ControlRight,
ShiftRight,
AltRight,
MetaRight,
}
impl CanonicalKey {
/// Convert the canonical key to a stable wire code.
///
/// The wire code intentionally matches the USB HID usage for keyboard page
/// keys so existing low-level behavior stays intact while the semantic type
/// becomes explicit.
pub const fn to_hid_usage(self) -> u8 {
match self {
Self::KeyA => 0x04,
Self::KeyB => 0x05,
Self::KeyC => 0x06,
Self::KeyD => 0x07,
Self::KeyE => 0x08,
Self::KeyF => 0x09,
Self::KeyG => 0x0A,
Self::KeyH => 0x0B,
Self::KeyI => 0x0C,
Self::KeyJ => 0x0D,
Self::KeyK => 0x0E,
Self::KeyL => 0x0F,
Self::KeyM => 0x10,
Self::KeyN => 0x11,
Self::KeyO => 0x12,
Self::KeyP => 0x13,
Self::KeyQ => 0x14,
Self::KeyR => 0x15,
Self::KeyS => 0x16,
Self::KeyT => 0x17,
Self::KeyU => 0x18,
Self::KeyV => 0x19,
Self::KeyW => 0x1A,
Self::KeyX => 0x1B,
Self::KeyY => 0x1C,
Self::KeyZ => 0x1D,
Self::Digit1 => 0x1E,
Self::Digit2 => 0x1F,
Self::Digit3 => 0x20,
Self::Digit4 => 0x21,
Self::Digit5 => 0x22,
Self::Digit6 => 0x23,
Self::Digit7 => 0x24,
Self::Digit8 => 0x25,
Self::Digit9 => 0x26,
Self::Digit0 => 0x27,
Self::Enter => 0x28,
Self::Escape => 0x29,
Self::Backspace => 0x2A,
Self::Tab => 0x2B,
Self::Space => 0x2C,
Self::Minus => 0x2D,
Self::Equal => 0x2E,
Self::BracketLeft => 0x2F,
Self::BracketRight => 0x30,
Self::Backslash => 0x31,
Self::Semicolon => 0x33,
Self::Quote => 0x34,
Self::Backquote => 0x35,
Self::Comma => 0x36,
Self::Period => 0x37,
Self::Slash => 0x38,
Self::CapsLock => 0x39,
Self::F1 => 0x3A,
Self::F2 => 0x3B,
Self::F3 => 0x3C,
Self::F4 => 0x3D,
Self::F5 => 0x3E,
Self::F6 => 0x3F,
Self::F7 => 0x40,
Self::F8 => 0x41,
Self::F9 => 0x42,
Self::F10 => 0x43,
Self::F11 => 0x44,
Self::F12 => 0x45,
Self::PrintScreen => 0x46,
Self::ScrollLock => 0x47,
Self::Pause => 0x48,
Self::Insert => 0x49,
Self::Home => 0x4A,
Self::PageUp => 0x4B,
Self::Delete => 0x4C,
Self::End => 0x4D,
Self::PageDown => 0x4E,
Self::ArrowRight => 0x4F,
Self::ArrowLeft => 0x50,
Self::ArrowDown => 0x51,
Self::ArrowUp => 0x52,
Self::NumLock => 0x53,
Self::NumpadDivide => 0x54,
Self::NumpadMultiply => 0x55,
Self::NumpadSubtract => 0x56,
Self::NumpadAdd => 0x57,
Self::NumpadEnter => 0x58,
Self::Numpad1 => 0x59,
Self::Numpad2 => 0x5A,
Self::Numpad3 => 0x5B,
Self::Numpad4 => 0x5C,
Self::Numpad5 => 0x5D,
Self::Numpad6 => 0x5E,
Self::Numpad7 => 0x5F,
Self::Numpad8 => 0x60,
Self::Numpad9 => 0x61,
Self::Numpad0 => 0x62,
Self::NumpadDecimal => 0x63,
Self::IntlBackslash => 0x64,
Self::ContextMenu => 0x65,
Self::F13 => 0x68,
Self::F14 => 0x69,
Self::F15 => 0x6A,
Self::F16 => 0x6B,
Self::F17 => 0x6C,
Self::F18 => 0x6D,
Self::F19 => 0x6E,
Self::F20 => 0x6F,
Self::F21 => 0x70,
Self::F22 => 0x71,
Self::F23 => 0x72,
Self::F24 => 0x73,
Self::ControlLeft => 0xE0,
Self::ShiftLeft => 0xE1,
Self::AltLeft => 0xE2,
Self::MetaLeft => 0xE3,
Self::ControlRight => 0xE4,
Self::ShiftRight => 0xE5,
Self::AltRight => 0xE6,
Self::MetaRight => 0xE7,
}
}
/// Convert a wire code / USB HID usage to its canonical key.
pub const fn from_hid_usage(usage: u8) -> Option<Self> {
match usage {
0x04 => Some(Self::KeyA),
0x05 => Some(Self::KeyB),
0x06 => Some(Self::KeyC),
0x07 => Some(Self::KeyD),
0x08 => Some(Self::KeyE),
0x09 => Some(Self::KeyF),
0x0A => Some(Self::KeyG),
0x0B => Some(Self::KeyH),
0x0C => Some(Self::KeyI),
0x0D => Some(Self::KeyJ),
0x0E => Some(Self::KeyK),
0x0F => Some(Self::KeyL),
0x10 => Some(Self::KeyM),
0x11 => Some(Self::KeyN),
0x12 => Some(Self::KeyO),
0x13 => Some(Self::KeyP),
0x14 => Some(Self::KeyQ),
0x15 => Some(Self::KeyR),
0x16 => Some(Self::KeyS),
0x17 => Some(Self::KeyT),
0x18 => Some(Self::KeyU),
0x19 => Some(Self::KeyV),
0x1A => Some(Self::KeyW),
0x1B => Some(Self::KeyX),
0x1C => Some(Self::KeyY),
0x1D => Some(Self::KeyZ),
0x1E => Some(Self::Digit1),
0x1F => Some(Self::Digit2),
0x20 => Some(Self::Digit3),
0x21 => Some(Self::Digit4),
0x22 => Some(Self::Digit5),
0x23 => Some(Self::Digit6),
0x24 => Some(Self::Digit7),
0x25 => Some(Self::Digit8),
0x26 => Some(Self::Digit9),
0x27 => Some(Self::Digit0),
0x28 => Some(Self::Enter),
0x29 => Some(Self::Escape),
0x2A => Some(Self::Backspace),
0x2B => Some(Self::Tab),
0x2C => Some(Self::Space),
0x2D => Some(Self::Minus),
0x2E => Some(Self::Equal),
0x2F => Some(Self::BracketLeft),
0x30 => Some(Self::BracketRight),
0x31 => Some(Self::Backslash),
0x33 => Some(Self::Semicolon),
0x34 => Some(Self::Quote),
0x35 => Some(Self::Backquote),
0x36 => Some(Self::Comma),
0x37 => Some(Self::Period),
0x38 => Some(Self::Slash),
0x39 => Some(Self::CapsLock),
0x3A => Some(Self::F1),
0x3B => Some(Self::F2),
0x3C => Some(Self::F3),
0x3D => Some(Self::F4),
0x3E => Some(Self::F5),
0x3F => Some(Self::F6),
0x40 => Some(Self::F7),
0x41 => Some(Self::F8),
0x42 => Some(Self::F9),
0x43 => Some(Self::F10),
0x44 => Some(Self::F11),
0x45 => Some(Self::F12),
0x46 => Some(Self::PrintScreen),
0x47 => Some(Self::ScrollLock),
0x48 => Some(Self::Pause),
0x49 => Some(Self::Insert),
0x4A => Some(Self::Home),
0x4B => Some(Self::PageUp),
0x4C => Some(Self::Delete),
0x4D => Some(Self::End),
0x4E => Some(Self::PageDown),
0x4F => Some(Self::ArrowRight),
0x50 => Some(Self::ArrowLeft),
0x51 => Some(Self::ArrowDown),
0x52 => Some(Self::ArrowUp),
0x53 => Some(Self::NumLock),
0x54 => Some(Self::NumpadDivide),
0x55 => Some(Self::NumpadMultiply),
0x56 => Some(Self::NumpadSubtract),
0x57 => Some(Self::NumpadAdd),
0x58 => Some(Self::NumpadEnter),
0x59 => Some(Self::Numpad1),
0x5A => Some(Self::Numpad2),
0x5B => Some(Self::Numpad3),
0x5C => Some(Self::Numpad4),
0x5D => Some(Self::Numpad5),
0x5E => Some(Self::Numpad6),
0x5F => Some(Self::Numpad7),
0x60 => Some(Self::Numpad8),
0x61 => Some(Self::Numpad9),
0x62 => Some(Self::Numpad0),
0x63 => Some(Self::NumpadDecimal),
0x64 => Some(Self::IntlBackslash),
0x65 => Some(Self::ContextMenu),
0x68 => Some(Self::F13),
0x69 => Some(Self::F14),
0x6A => Some(Self::F15),
0x6B => Some(Self::F16),
0x6C => Some(Self::F17),
0x6D => Some(Self::F18),
0x6E => Some(Self::F19),
0x6F => Some(Self::F20),
0x70 => Some(Self::F21),
0x71 => Some(Self::F22),
0x72 => Some(Self::F23),
0x73 => Some(Self::F24),
0xE0 => Some(Self::ControlLeft),
0xE1 => Some(Self::ShiftLeft),
0xE2 => Some(Self::AltLeft),
0xE3 => Some(Self::MetaLeft),
0xE4 => Some(Self::ControlRight),
0xE5 => Some(Self::ShiftRight),
0xE6 => Some(Self::AltRight),
0xE7 => Some(Self::MetaRight),
_ => None,
}
}
pub const fn is_modifier(self) -> bool {
matches!(
self,
Self::ControlLeft
| Self::ShiftLeft
| Self::AltLeft
| Self::MetaLeft
| Self::ControlRight
| Self::ShiftRight
| Self::AltRight
| Self::MetaRight
)
}
pub const fn modifier_bit(self) -> Option<u8> {
match self {
Self::ControlLeft => Some(0x01),
Self::ShiftLeft => Some(0x02),
Self::AltLeft => Some(0x04),
Self::MetaLeft => Some(0x08),
Self::ControlRight => Some(0x10),
Self::ShiftRight => Some(0x20),
Self::AltRight => Some(0x40),
Self::MetaRight => Some(0x80),
_ => None,
}
}
}

View File

@@ -1,430 +0,0 @@
//! USB HID keyboard key codes mapping
//!
//! This module provides mapping between JavaScript key codes and USB HID usage codes.
//! Reference: USB HID Usage Tables 1.12, Section 10 (Keyboard/Keypad Page)
/// USB HID key codes (Usage Page 0x07)
#[allow(dead_code)]
pub mod usb {
// Letters A-Z (0x04 - 0x1D)
pub const KEY_A: u8 = 0x04;
pub const KEY_B: u8 = 0x05;
pub const KEY_C: u8 = 0x06;
pub const KEY_D: u8 = 0x07;
pub const KEY_E: u8 = 0x08;
pub const KEY_F: u8 = 0x09;
pub const KEY_G: u8 = 0x0A;
pub const KEY_H: u8 = 0x0B;
pub const KEY_I: u8 = 0x0C;
pub const KEY_J: u8 = 0x0D;
pub const KEY_K: u8 = 0x0E;
pub const KEY_L: u8 = 0x0F;
pub const KEY_M: u8 = 0x10;
pub const KEY_N: u8 = 0x11;
pub const KEY_O: u8 = 0x12;
pub const KEY_P: u8 = 0x13;
pub const KEY_Q: u8 = 0x14;
pub const KEY_R: u8 = 0x15;
pub const KEY_S: u8 = 0x16;
pub const KEY_T: u8 = 0x17;
pub const KEY_U: u8 = 0x18;
pub const KEY_V: u8 = 0x19;
pub const KEY_W: u8 = 0x1A;
pub const KEY_X: u8 = 0x1B;
pub const KEY_Y: u8 = 0x1C;
pub const KEY_Z: u8 = 0x1D;
// Numbers 1-9, 0 (0x1E - 0x27)
pub const KEY_1: u8 = 0x1E;
pub const KEY_2: u8 = 0x1F;
pub const KEY_3: u8 = 0x20;
pub const KEY_4: u8 = 0x21;
pub const KEY_5: u8 = 0x22;
pub const KEY_6: u8 = 0x23;
pub const KEY_7: u8 = 0x24;
pub const KEY_8: u8 = 0x25;
pub const KEY_9: u8 = 0x26;
pub const KEY_0: u8 = 0x27;
// Control keys
pub const KEY_ENTER: u8 = 0x28;
pub const KEY_ESCAPE: u8 = 0x29;
pub const KEY_BACKSPACE: u8 = 0x2A;
pub const KEY_TAB: u8 = 0x2B;
pub const KEY_SPACE: u8 = 0x2C;
pub const KEY_MINUS: u8 = 0x2D;
pub const KEY_EQUAL: u8 = 0x2E;
pub const KEY_LEFT_BRACKET: u8 = 0x2F;
pub const KEY_RIGHT_BRACKET: u8 = 0x30;
pub const KEY_BACKSLASH: u8 = 0x31;
pub const KEY_HASH: u8 = 0x32; // Non-US # and ~
pub const KEY_SEMICOLON: u8 = 0x33;
pub const KEY_APOSTROPHE: u8 = 0x34;
pub const KEY_GRAVE: u8 = 0x35;
pub const KEY_COMMA: u8 = 0x36;
pub const KEY_PERIOD: u8 = 0x37;
pub const KEY_SLASH: u8 = 0x38;
pub const KEY_CAPS_LOCK: u8 = 0x39;
// Function keys F1-F12
pub const KEY_F1: u8 = 0x3A;
pub const KEY_F2: u8 = 0x3B;
pub const KEY_F3: u8 = 0x3C;
pub const KEY_F4: u8 = 0x3D;
pub const KEY_F5: u8 = 0x3E;
pub const KEY_F6: u8 = 0x3F;
pub const KEY_F7: u8 = 0x40;
pub const KEY_F8: u8 = 0x41;
pub const KEY_F9: u8 = 0x42;
pub const KEY_F10: u8 = 0x43;
pub const KEY_F11: u8 = 0x44;
pub const KEY_F12: u8 = 0x45;
// Special keys
pub const KEY_PRINT_SCREEN: u8 = 0x46;
pub const KEY_SCROLL_LOCK: u8 = 0x47;
pub const KEY_PAUSE: u8 = 0x48;
pub const KEY_INSERT: u8 = 0x49;
pub const KEY_HOME: u8 = 0x4A;
pub const KEY_PAGE_UP: u8 = 0x4B;
pub const KEY_DELETE: u8 = 0x4C;
pub const KEY_END: u8 = 0x4D;
pub const KEY_PAGE_DOWN: u8 = 0x4E;
pub const KEY_RIGHT_ARROW: u8 = 0x4F;
pub const KEY_LEFT_ARROW: u8 = 0x50;
pub const KEY_DOWN_ARROW: u8 = 0x51;
pub const KEY_UP_ARROW: u8 = 0x52;
// Numpad
pub const KEY_NUM_LOCK: u8 = 0x53;
pub const KEY_NUMPAD_DIVIDE: u8 = 0x54;
pub const KEY_NUMPAD_MULTIPLY: u8 = 0x55;
pub const KEY_NUMPAD_MINUS: u8 = 0x56;
pub const KEY_NUMPAD_PLUS: u8 = 0x57;
pub const KEY_NUMPAD_ENTER: u8 = 0x58;
pub const KEY_NUMPAD_1: u8 = 0x59;
pub const KEY_NUMPAD_2: u8 = 0x5A;
pub const KEY_NUMPAD_3: u8 = 0x5B;
pub const KEY_NUMPAD_4: u8 = 0x5C;
pub const KEY_NUMPAD_5: u8 = 0x5D;
pub const KEY_NUMPAD_6: u8 = 0x5E;
pub const KEY_NUMPAD_7: u8 = 0x5F;
pub const KEY_NUMPAD_8: u8 = 0x60;
pub const KEY_NUMPAD_9: u8 = 0x61;
pub const KEY_NUMPAD_0: u8 = 0x62;
pub const KEY_NUMPAD_DECIMAL: u8 = 0x63;
// Additional keys
pub const KEY_NON_US_BACKSLASH: u8 = 0x64;
pub const KEY_APPLICATION: u8 = 0x65; // Context menu
pub const KEY_POWER: u8 = 0x66;
pub const KEY_NUMPAD_EQUAL: u8 = 0x67;
// F13-F24
pub const KEY_F13: u8 = 0x68;
pub const KEY_F14: u8 = 0x69;
pub const KEY_F15: u8 = 0x6A;
pub const KEY_F16: u8 = 0x6B;
pub const KEY_F17: u8 = 0x6C;
pub const KEY_F18: u8 = 0x6D;
pub const KEY_F19: u8 = 0x6E;
pub const KEY_F20: u8 = 0x6F;
pub const KEY_F21: u8 = 0x70;
pub const KEY_F22: u8 = 0x71;
pub const KEY_F23: u8 = 0x72;
pub const KEY_F24: u8 = 0x73;
// Modifier keys (these are handled separately in the modifier byte)
pub const KEY_LEFT_CTRL: u8 = 0xE0;
pub const KEY_LEFT_SHIFT: u8 = 0xE1;
pub const KEY_LEFT_ALT: u8 = 0xE2;
pub const KEY_LEFT_META: u8 = 0xE3;
pub const KEY_RIGHT_CTRL: u8 = 0xE4;
pub const KEY_RIGHT_SHIFT: u8 = 0xE5;
pub const KEY_RIGHT_ALT: u8 = 0xE6;
pub const KEY_RIGHT_META: u8 = 0xE7;
}
/// JavaScript key codes (event.keyCode / event.code)
#[allow(dead_code)]
pub mod js {
// Letters
pub const KEY_A: u8 = 65;
pub const KEY_B: u8 = 66;
pub const KEY_C: u8 = 67;
pub const KEY_D: u8 = 68;
pub const KEY_E: u8 = 69;
pub const KEY_F: u8 = 70;
pub const KEY_G: u8 = 71;
pub const KEY_H: u8 = 72;
pub const KEY_I: u8 = 73;
pub const KEY_J: u8 = 74;
pub const KEY_K: u8 = 75;
pub const KEY_L: u8 = 76;
pub const KEY_M: u8 = 77;
pub const KEY_N: u8 = 78;
pub const KEY_O: u8 = 79;
pub const KEY_P: u8 = 80;
pub const KEY_Q: u8 = 81;
pub const KEY_R: u8 = 82;
pub const KEY_S: u8 = 83;
pub const KEY_T: u8 = 84;
pub const KEY_U: u8 = 85;
pub const KEY_V: u8 = 86;
pub const KEY_W: u8 = 87;
pub const KEY_X: u8 = 88;
pub const KEY_Y: u8 = 89;
pub const KEY_Z: u8 = 90;
// Numbers (top row)
pub const KEY_0: u8 = 48;
pub const KEY_1: u8 = 49;
pub const KEY_2: u8 = 50;
pub const KEY_3: u8 = 51;
pub const KEY_4: u8 = 52;
pub const KEY_5: u8 = 53;
pub const KEY_6: u8 = 54;
pub const KEY_7: u8 = 55;
pub const KEY_8: u8 = 56;
pub const KEY_9: u8 = 57;
// Function keys
pub const KEY_F1: u8 = 112;
pub const KEY_F2: u8 = 113;
pub const KEY_F3: u8 = 114;
pub const KEY_F4: u8 = 115;
pub const KEY_F5: u8 = 116;
pub const KEY_F6: u8 = 117;
pub const KEY_F7: u8 = 118;
pub const KEY_F8: u8 = 119;
pub const KEY_F9: u8 = 120;
pub const KEY_F10: u8 = 121;
pub const KEY_F11: u8 = 122;
pub const KEY_F12: u8 = 123;
// Control keys
pub const KEY_BACKSPACE: u8 = 8;
pub const KEY_TAB: u8 = 9;
pub const KEY_ENTER: u8 = 13;
pub const KEY_SHIFT: u8 = 16;
pub const KEY_CTRL: u8 = 17;
pub const KEY_ALT: u8 = 18;
pub const KEY_PAUSE: u8 = 19;
pub const KEY_CAPS_LOCK: u8 = 20;
pub const KEY_ESCAPE: u8 = 27;
pub const KEY_SPACE: u8 = 32;
pub const KEY_PAGE_UP: u8 = 33;
pub const KEY_PAGE_DOWN: u8 = 34;
pub const KEY_END: u8 = 35;
pub const KEY_HOME: u8 = 36;
pub const KEY_LEFT: u8 = 37;
pub const KEY_UP: u8 = 38;
pub const KEY_RIGHT: u8 = 39;
pub const KEY_DOWN: u8 = 40;
pub const KEY_INSERT: u8 = 45;
pub const KEY_DELETE: u8 = 46;
// Punctuation
pub const KEY_SEMICOLON: u8 = 186;
pub const KEY_EQUAL: u8 = 187;
pub const KEY_COMMA: u8 = 188;
pub const KEY_MINUS: u8 = 189;
pub const KEY_PERIOD: u8 = 190;
pub const KEY_SLASH: u8 = 191;
pub const KEY_GRAVE: u8 = 192;
pub const KEY_LEFT_BRACKET: u8 = 219;
pub const KEY_BACKSLASH: u8 = 220;
pub const KEY_RIGHT_BRACKET: u8 = 221;
pub const KEY_APOSTROPHE: u8 = 222;
// Numpad
pub const KEY_NUMPAD_0: u8 = 96;
pub const KEY_NUMPAD_1: u8 = 97;
pub const KEY_NUMPAD_2: u8 = 98;
pub const KEY_NUMPAD_3: u8 = 99;
pub const KEY_NUMPAD_4: u8 = 100;
pub const KEY_NUMPAD_5: u8 = 101;
pub const KEY_NUMPAD_6: u8 = 102;
pub const KEY_NUMPAD_7: u8 = 103;
pub const KEY_NUMPAD_8: u8 = 104;
pub const KEY_NUMPAD_9: u8 = 105;
pub const KEY_NUMPAD_MULTIPLY: u8 = 106;
pub const KEY_NUMPAD_ADD: u8 = 107;
pub const KEY_NUMPAD_SUBTRACT: u8 = 109;
pub const KEY_NUMPAD_DECIMAL: u8 = 110;
pub const KEY_NUMPAD_DIVIDE: u8 = 111;
// Lock keys
pub const KEY_NUM_LOCK: u8 = 144;
pub const KEY_SCROLL_LOCK: u8 = 145;
// Windows keys
pub const KEY_META_LEFT: u8 = 91;
pub const KEY_META_RIGHT: u8 = 92;
pub const KEY_CONTEXT_MENU: u8 = 93;
}
/// JavaScript keyCode to USB HID keyCode mapping table
/// Using a fixed-size array for O(1) lookup instead of HashMap
/// Index = JavaScript keyCode, Value = USB HID keyCode (0 means unmapped)
static JS_TO_USB_TABLE: [u8; 256] = {
let mut table = [0u8; 256];
// Letters A-Z (JS 65-90 -> USB 0x04-0x1D)
let mut i = 0u8;
while i < 26 {
table[(65 + i) as usize] = usb::KEY_A + i;
i += 1;
}
// Numbers 1-9, 0 (JS 49-57, 48 -> USB 0x1E-0x27)
table[49] = usb::KEY_1; // 1
table[50] = usb::KEY_2; // 2
table[51] = usb::KEY_3; // 3
table[52] = usb::KEY_4; // 4
table[53] = usb::KEY_5; // 5
table[54] = usb::KEY_6; // 6
table[55] = usb::KEY_7; // 7
table[56] = usb::KEY_8; // 8
table[57] = usb::KEY_9; // 9
table[48] = usb::KEY_0; // 0
// Function keys F1-F12 (JS 112-123 -> USB 0x3A-0x45)
table[112] = usb::KEY_F1;
table[113] = usb::KEY_F2;
table[114] = usb::KEY_F3;
table[115] = usb::KEY_F4;
table[116] = usb::KEY_F5;
table[117] = usb::KEY_F6;
table[118] = usb::KEY_F7;
table[119] = usb::KEY_F8;
table[120] = usb::KEY_F9;
table[121] = usb::KEY_F10;
table[122] = usb::KEY_F11;
table[123] = usb::KEY_F12;
// Control keys
table[13] = usb::KEY_ENTER; // Enter
table[27] = usb::KEY_ESCAPE; // Escape
table[8] = usb::KEY_BACKSPACE; // Backspace
table[9] = usb::KEY_TAB; // Tab
table[32] = usb::KEY_SPACE; // Space
table[20] = usb::KEY_CAPS_LOCK; // Caps Lock
// Punctuation (JS codes vary by browser/layout)
table[189] = usb::KEY_MINUS; // -
table[187] = usb::KEY_EQUAL; // =
table[219] = usb::KEY_LEFT_BRACKET; // [
table[221] = usb::KEY_RIGHT_BRACKET; // ]
table[220] = usb::KEY_BACKSLASH; // \
table[186] = usb::KEY_SEMICOLON; // ;
table[222] = usb::KEY_APOSTROPHE; // '
table[192] = usb::KEY_GRAVE; // `
table[188] = usb::KEY_COMMA; // ,
table[190] = usb::KEY_PERIOD; // .
table[191] = usb::KEY_SLASH; // /
// Navigation keys
table[45] = usb::KEY_INSERT;
table[46] = usb::KEY_DELETE;
table[36] = usb::KEY_HOME;
table[35] = usb::KEY_END;
table[33] = usb::KEY_PAGE_UP;
table[34] = usb::KEY_PAGE_DOWN;
// Arrow keys
table[39] = usb::KEY_RIGHT_ARROW;
table[37] = usb::KEY_LEFT_ARROW;
table[40] = usb::KEY_DOWN_ARROW;
table[38] = usb::KEY_UP_ARROW;
// Numpad
table[144] = usb::KEY_NUM_LOCK;
table[111] = usb::KEY_NUMPAD_DIVIDE;
table[106] = usb::KEY_NUMPAD_MULTIPLY;
table[109] = usb::KEY_NUMPAD_MINUS;
table[107] = usb::KEY_NUMPAD_PLUS;
table[96] = usb::KEY_NUMPAD_0;
table[97] = usb::KEY_NUMPAD_1;
table[98] = usb::KEY_NUMPAD_2;
table[99] = usb::KEY_NUMPAD_3;
table[100] = usb::KEY_NUMPAD_4;
table[101] = usb::KEY_NUMPAD_5;
table[102] = usb::KEY_NUMPAD_6;
table[103] = usb::KEY_NUMPAD_7;
table[104] = usb::KEY_NUMPAD_8;
table[105] = usb::KEY_NUMPAD_9;
table[110] = usb::KEY_NUMPAD_DECIMAL;
// Special keys
table[19] = usb::KEY_PAUSE;
table[145] = usb::KEY_SCROLL_LOCK;
table[93] = usb::KEY_APPLICATION; // Context menu
// Modifier keys
table[17] = usb::KEY_LEFT_CTRL;
table[16] = usb::KEY_LEFT_SHIFT;
table[18] = usb::KEY_LEFT_ALT;
table[91] = usb::KEY_LEFT_META; // Left Windows/Command
table[92] = usb::KEY_RIGHT_META; // Right Windows/Command
table
};
/// Convert JavaScript keyCode to USB HID keyCode
///
/// Uses a fixed-size lookup table for O(1) performance.
/// Returns None if the key code is not mapped.
#[inline]
pub fn js_to_usb(js_code: u8) -> Option<u8> {
let usb_code = JS_TO_USB_TABLE[js_code as usize];
if usb_code != 0 {
Some(usb_code)
} else {
None
}
}
/// Check if a key code is a modifier key
pub fn is_modifier_key(usb_code: u8) -> bool {
(0xE0..=0xE7).contains(&usb_code)
}
/// Get modifier bit for a modifier key
pub fn modifier_bit(usb_code: u8) -> Option<u8> {
match usb_code {
usb::KEY_LEFT_CTRL => Some(0x01),
usb::KEY_LEFT_SHIFT => Some(0x02),
usb::KEY_LEFT_ALT => Some(0x04),
usb::KEY_LEFT_META => Some(0x08),
usb::KEY_RIGHT_CTRL => Some(0x10),
usb::KEY_RIGHT_SHIFT => Some(0x20),
usb::KEY_RIGHT_ALT => Some(0x40),
usb::KEY_RIGHT_META => Some(0x80),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_letter_mapping() {
assert_eq!(js_to_usb(65), Some(usb::KEY_A)); // A
assert_eq!(js_to_usb(90), Some(usb::KEY_Z)); // Z
}
#[test]
fn test_number_mapping() {
assert_eq!(js_to_usb(48), Some(usb::KEY_0));
assert_eq!(js_to_usb(49), Some(usb::KEY_1));
}
#[test]
fn test_modifier_key() {
assert!(is_modifier_key(usb::KEY_LEFT_CTRL));
assert!(is_modifier_key(usb::KEY_RIGHT_SHIFT));
assert!(!is_modifier_key(usb::KEY_A));
}
}

View File

@@ -15,14 +15,13 @@ pub mod backend;
pub mod ch9329; pub mod ch9329;
pub mod consumer; pub mod consumer;
pub mod datachannel; pub mod datachannel;
pub mod keymap; pub mod keyboard;
pub mod monitor;
pub mod otg; pub mod otg;
pub mod types; pub mod types;
pub mod websocket; pub mod websocket;
pub use backend::{HidBackend, HidBackendType}; pub use backend::{HidBackend, HidBackendRuntimeSnapshot, HidBackendType};
pub use monitor::{HidHealthMonitor, HidHealthStatus, HidMonitorConfig}; pub use keyboard::CanonicalKey;
pub use otg::LedState; pub use otg::LedState;
pub use types::{ pub use types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
@@ -33,7 +32,7 @@ pub use types::{
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct HidInfo { pub struct HidInfo {
/// Backend name /// Backend name
pub name: &'static str, pub name: String,
/// Whether backend is initialized /// Whether backend is initialized
pub initialized: bool, pub initialized: bool,
/// Whether absolute mouse positioning is supported /// Whether absolute mouse positioning is supported
@@ -42,21 +41,103 @@ pub struct HidInfo {
pub screen_resolution: Option<(u32, u32)>, pub screen_resolution: Option<(u32, u32)>,
} }
/// Unified HID runtime state used by snapshots and events.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HidRuntimeState {
/// Whether a backend is configured and expected to exist.
pub available: bool,
/// Stable backend key: "otg", "ch9329", "none".
pub backend: String,
/// Whether the backend is currently initialized and operational.
pub initialized: bool,
/// Whether the backend is currently online.
pub online: bool,
/// Whether absolute mouse positioning is supported.
pub supports_absolute_mouse: bool,
/// Whether keyboard LED/status feedback is enabled.
pub keyboard_leds_enabled: bool,
/// Last known keyboard LED state.
pub led_state: LedState,
/// Screen resolution for absolute mouse mode.
pub screen_resolution: Option<(u32, u32)>,
/// Device path associated with the backend, if any.
pub device: Option<String>,
/// Current user-facing error, if any.
pub error: Option<String>,
/// Current programmatic error code, if any.
pub error_code: Option<String>,
}
impl HidRuntimeState {
fn from_backend_type(backend_type: &HidBackendType) -> Self {
Self {
available: !matches!(backend_type, HidBackendType::None),
backend: backend_type.name_str().to_string(),
initialized: false,
online: false,
supports_absolute_mouse: false,
keyboard_leds_enabled: false,
led_state: LedState::default(),
screen_resolution: None,
device: device_for_backend_type(backend_type),
error: None,
error_code: None,
}
}
fn from_backend(backend_type: &HidBackendType, snapshot: HidBackendRuntimeSnapshot) -> Self {
Self {
available: !matches!(backend_type, HidBackendType::None),
backend: backend_type.name_str().to_string(),
initialized: snapshot.initialized,
online: snapshot.online,
supports_absolute_mouse: snapshot.supports_absolute_mouse,
keyboard_leds_enabled: snapshot.keyboard_leds_enabled,
led_state: snapshot.led_state,
screen_resolution: snapshot.screen_resolution,
device: snapshot
.device
.or_else(|| device_for_backend_type(backend_type)),
error: snapshot.error,
error_code: snapshot.error_code,
}
}
fn with_error(
backend_type: &HidBackendType,
current: &Self,
reason: impl Into<String>,
error_code: impl Into<String>,
) -> Self {
let mut next = current.clone();
next.available = !matches!(backend_type, HidBackendType::None);
next.backend = backend_type.name_str().to_string();
next.initialized = false;
next.online = false;
next.keyboard_leds_enabled = false;
next.led_state = LedState::default();
next.device = device_for_backend_type(backend_type);
next.error = Some(reason.into());
next.error_code = Some(error_code.into());
next
}
}
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::events::EventBus;
use crate::otg::OtgService; use crate::otg::OtgService;
use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
const HID_EVENT_QUEUE_CAPACITY: usize = 64; const HID_EVENT_QUEUE_CAPACITY: usize = 64;
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30; const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
const HID_HEALTH_CHECK_INTERVAL_MS: u64 = 1000;
#[derive(Debug)] #[derive(Debug)]
enum HidEvent { enum HidEvent {
@@ -75,9 +156,9 @@ pub struct HidController {
/// Backend type (mutable for reload) /// Backend type (mutable for reload)
backend_type: Arc<RwLock<HidBackendType>>, backend_type: Arc<RwLock<HidBackendType>>,
/// Event bus for broadcasting state changes (optional) /// Event bus for broadcasting state changes (optional)
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>, events: Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
/// Health monitor for error tracking and recovery /// Unified HID runtime state.
monitor: Arc<HidHealthMonitor>, runtime_state: Arc<RwLock<HidRuntimeState>>,
/// HID event queue sender (non-blocking) /// HID event queue sender (non-blocking)
hid_tx: mpsc::Sender<HidEvent>, hid_tx: mpsc::Sender<HidEvent>,
/// HID event queue receiver (moved into worker on first start) /// HID event queue receiver (moved into worker on first start)
@@ -88,10 +169,10 @@ pub struct HidController {
pending_move_flag: Arc<AtomicBool>, pending_move_flag: Arc<AtomicBool>,
/// Worker task handle /// Worker task handle
hid_worker: Mutex<Option<JoinHandle<()>>>, hid_worker: Mutex<Option<JoinHandle<()>>>,
/// Health check task handle /// Backend runtime subscription task handle
hid_health_checker: Mutex<Option<JoinHandle<()>>>, runtime_worker: Mutex<Option<JoinHandle<()>>>,
/// Backend availability fast flag /// Backend initialization fast flag
backend_available: AtomicBool, backend_available: Arc<AtomicBool>,
} }
impl HidController { impl HidController {
@@ -103,24 +184,24 @@ impl HidController {
Self { Self {
otg_service, otg_service,
backend: Arc::new(RwLock::new(None)), backend: Arc::new(RwLock::new(None)),
backend_type: Arc::new(RwLock::new(backend_type)), backend_type: Arc::new(RwLock::new(backend_type.clone())),
events: tokio::sync::RwLock::new(None), events: Arc::new(tokio::sync::RwLock::new(None)),
monitor: Arc::new(HidHealthMonitor::with_defaults()), runtime_state: Arc::new(RwLock::new(HidRuntimeState::from_backend_type(
&backend_type,
))),
hid_tx, hid_tx,
hid_rx: Mutex::new(Some(hid_rx)), hid_rx: Mutex::new(Some(hid_rx)),
pending_move: Arc::new(parking_lot::Mutex::new(None)), pending_move: Arc::new(parking_lot::Mutex::new(None)),
pending_move_flag: Arc::new(AtomicBool::new(false)), pending_move_flag: Arc::new(AtomicBool::new(false)),
hid_worker: Mutex::new(None), hid_worker: Mutex::new(None),
hid_health_checker: Mutex::new(None), runtime_worker: Mutex::new(None),
backend_available: AtomicBool::new(false), backend_available: Arc::new(AtomicBool::new(false)),
} }
} }
/// Set event bus for broadcasting state changes /// Set event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<crate::events::EventBus>) { pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events.clone()); *self.events.write().await = Some(events);
// Also set event bus on the monitor for health notifications
self.monitor.set_event_bus(events).await;
} }
/// Initialize the HID backend /// Initialize the HID backend
@@ -128,16 +209,15 @@ impl HidController {
let backend_type = self.backend_type.read().await.clone(); let backend_type = self.backend_type.read().await.clone();
let backend: Arc<dyn HidBackend> = match backend_type { let backend: Arc<dyn HidBackend> = match backend_type {
HidBackendType::Otg => { HidBackendType::Otg => {
// Request HID functions from OtgService
let otg_service = self let otg_service = self
.otg_service .otg_service
.as_ref() .as_ref()
.ok_or_else(|| AppError::Internal("OtgService not available".into()))?; .ok_or_else(|| AppError::Internal("OtgService not available".into()))?;
info!("Requesting HID functions from OtgService"); let handles = otg_service.hid_device_paths().await.ok_or_else(|| {
let handles = otg_service.enable_hid().await?; AppError::Config("OTG HID paths are not available".to_string())
})?;
// Create OtgBackend from handles (no longer manages gadget itself)
info!("Creating OTG HID backend from device paths"); info!("Creating OTG HID backend from device paths");
Arc::new(otg::OtgBackend::from_handles(handles)?) Arc::new(otg::OtgBackend::from_handles(handles)?)
} }
@@ -157,13 +237,28 @@ impl HidController {
} }
}; };
backend.init().await?; if let Err(e) = backend.init().await {
self.backend_available.store(false, Ordering::Release);
let error_state = {
let backend_type = self.backend_type.read().await.clone();
let current = self.runtime_state.read().await.clone();
HidRuntimeState::with_error(
&backend_type,
&current,
format!("Failed to initialize HID backend: {}", e),
"init_failed",
)
};
self.apply_runtime_state(error_state).await;
return Err(e);
}
*self.backend.write().await = Some(backend); *self.backend.write().await = Some(backend);
self.backend_available.store(true, Ordering::Release); self.sync_runtime_state_from_backend().await;
// Start HID event worker (once) // Start HID event worker (once)
self.start_event_worker().await; self.start_event_worker().await;
self.start_health_checker().await; self.restart_runtime_worker().await;
info!("HID backend initialized: {:?}", backend_type); info!("HID backend initialized: {:?}", backend_type);
Ok(()) Ok(())
@@ -172,20 +267,24 @@ impl HidController {
/// Shutdown the HID backend and release resources /// Shutdown the HID backend and release resources
pub async fn shutdown(&self) -> Result<()> { pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down HID controller"); info!("Shutting down HID controller");
self.stop_health_checker().await; self.stop_runtime_worker().await;
// Close the backend // Close the backend
*self.backend.write().await = None; if let Some(backend) = self.backend.write().await.take() {
if let Err(e) = backend.shutdown().await {
warn!("Error shutting down HID backend: {}", e);
}
}
self.backend_available.store(false, Ordering::Release); self.backend_available.store(false, Ordering::Release);
// If OTG backend, notify OtgService to disable HID
let backend_type = self.backend_type.read().await.clone(); let backend_type = self.backend_type.read().await.clone();
if matches!(backend_type, HidBackendType::Otg) { let mut shutdown_state = HidRuntimeState::from_backend_type(&backend_type);
if let Some(ref otg_service) = self.otg_service { if matches!(backend_type, HidBackendType::None) {
info!("Disabling HID functions in OtgService"); shutdown_state.available = false;
otg_service.disable_hid().await?; } else {
} shutdown_state.error = Some("HID backend stopped".to_string());
shutdown_state.error_code = Some("shutdown".to_string());
} }
self.apply_runtime_state(shutdown_state).await;
info!("HID controller shutdown complete"); info!("HID controller shutdown complete");
Ok(()) Ok(())
@@ -241,7 +340,7 @@ impl HidController {
/// Check if backend is available /// Check if backend is available
pub async fn is_available(&self) -> bool { pub async fn is_available(&self) -> bool {
self.backend.read().await.is_some() self.backend_available.load(Ordering::Acquire)
} }
/// Get backend type /// Get backend type
@@ -251,60 +350,29 @@ impl HidController {
/// Get backend info /// Get backend info
pub async fn info(&self) -> Option<HidInfo> { pub async fn info(&self) -> Option<HidInfo> {
let backend = self.backend.read().await; let state = self.runtime_state.read().await.clone();
backend.as_ref().map(|b| HidInfo { if !state.available {
name: b.name(), return None;
initialized: true, }
supports_absolute_mouse: b.supports_absolute_mouse(),
screen_resolution: b.screen_resolution(), Some(HidInfo {
name: state.backend,
initialized: state.initialized,
supports_absolute_mouse: state.supports_absolute_mouse,
screen_resolution: state.screen_resolution,
}) })
} }
/// Get current state as SystemEvent /// Get current HID runtime state snapshot.
pub async fn current_state_event(&self) -> crate::events::SystemEvent { pub async fn snapshot(&self) -> HidRuntimeState {
let backend = self.backend.read().await; self.runtime_state.read().await.clone()
let backend_type = self.backend_type().await;
let (backend_name, initialized) = match backend.as_ref() {
Some(b) => (b.name(), true),
None => (backend_type.name_str(), false),
};
// Include error information from monitor
let (error, error_code) = match self.monitor.status().await {
HidHealthStatus::Error {
reason, error_code, ..
} => (Some(reason), Some(error_code)),
_ => (None, None),
};
crate::events::SystemEvent::HidStateChanged {
backend: backend_name.to_string(),
initialized,
error,
error_code,
}
}
/// Get the health monitor reference
pub fn monitor(&self) -> &Arc<HidHealthMonitor> {
&self.monitor
}
/// Get current health status
pub async fn health_status(&self) -> HidHealthStatus {
self.monitor.status().await
}
/// Check if the HID backend is healthy
pub async fn is_healthy(&self) -> bool {
self.monitor.is_healthy().await
} }
/// Reload the HID backend with new type /// Reload the HID backend with new type
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> { pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
info!("Reloading HID backend: {:?}", new_backend_type); info!("Reloading HID backend: {:?}", new_backend_type);
self.backend_available.store(false, Ordering::Release); self.backend_available.store(false, Ordering::Release);
self.stop_health_checker().await; self.stop_runtime_worker().await;
// Shutdown existing backend first // Shutdown existing backend first
if let Some(backend) = self.backend.write().await.take() { if let Some(backend) = self.backend.write().await.take() {
@@ -329,9 +397,8 @@ impl HidController {
} }
}; };
// Request HID functions from OtgService match otg_service.hid_device_paths().await {
match otg_service.enable_hid().await { Some(handles) => {
Ok(handles) => {
// Create OtgBackend from handles // Create OtgBackend from handles
match otg::OtgBackend::from_handles(handles) { match otg::OtgBackend::from_handles(handles) {
Ok(backend) => { Ok(backend) => {
@@ -343,29 +410,18 @@ impl HidController {
} }
Err(e) => { Err(e) => {
warn!("Failed to initialize OTG backend: {}", e); warn!("Failed to initialize OTG backend: {}", e);
// Cleanup: disable HID in OtgService
if let Err(e2) = otg_service.disable_hid().await {
warn!(
"Failed to cleanup HID after init failure: {}",
e2
);
}
None None
} }
} }
} }
Err(e) => { Err(e) => {
warn!("Failed to create OTG backend: {}", e); warn!("Failed to create OTG backend: {}", e);
// Cleanup: disable HID in OtgService
if let Err(e2) = otg_service.disable_hid().await {
warn!("Failed to cleanup HID after creation failure: {}", e2);
}
None None
} }
} }
} }
Err(e) => { None => {
warn!("Failed to enable HID in OtgService: {}", e); warn!("OTG HID paths are not available");
None None
} }
} }
@@ -403,27 +459,22 @@ impl HidController {
*self.backend.write().await = new_backend; *self.backend.write().await = new_backend;
if matches!(new_backend_type, HidBackendType::None) {
*self.backend_type.write().await = HidBackendType::None;
self.apply_runtime_state(HidRuntimeState::from_backend_type(&HidBackendType::None))
.await;
return Ok(());
}
if self.backend.read().await.is_some() { if self.backend.read().await.is_some() {
info!("HID backend reloaded successfully: {:?}", new_backend_type); info!("HID backend reloaded successfully: {:?}", new_backend_type);
self.backend_available.store(true, Ordering::Release);
self.start_event_worker().await; self.start_event_worker().await;
self.start_health_checker().await;
// Update backend_type on success // Update backend_type on success
*self.backend_type.write().await = new_backend_type.clone(); *self.backend_type.write().await = new_backend_type.clone();
// Reset monitor state on successful reload self.sync_runtime_state_from_backend().await;
self.monitor.reset().await; self.restart_runtime_worker().await;
// Publish HID state changed event
let backend_name = new_backend_type.name_str().to_string();
self.publish_event(crate::events::SystemEvent::HidStateChanged {
backend: backend_name,
initialized: true,
error: None,
error_code: None,
})
.await;
Ok(()) Ok(())
} else { } else {
@@ -433,14 +484,14 @@ impl HidController {
// Update backend_type even on failure (to reflect the attempted change) // Update backend_type even on failure (to reflect the attempted change)
*self.backend_type.write().await = new_backend_type.clone(); *self.backend_type.write().await = new_backend_type.clone();
// Publish event with initialized=false let current = self.runtime_state.read().await.clone();
self.publish_event(crate::events::SystemEvent::HidStateChanged { let error_state = HidRuntimeState::with_error(
backend: new_backend_type.name_str().to_string(), &new_backend_type,
initialized: false, &current,
error: Some("Failed to initialize HID backend".to_string()), "Failed to initialize HID backend",
error_code: Some("init_failed".to_string()), "init_failed",
}) );
.await; self.apply_runtime_state(error_state).await;
Err(AppError::Internal( Err(AppError::Internal(
"Failed to reload HID backend".to_string(), "Failed to reload HID backend".to_string(),
@@ -448,11 +499,20 @@ impl HidController {
} }
} }
/// Publish event to event bus if available async fn apply_runtime_state(&self, next: HidRuntimeState) {
async fn publish_event(&self, event: crate::events::SystemEvent) { apply_runtime_state(&self.runtime_state, &self.events, next).await;
if let Some(events) = self.events.read().await.as_ref() {
events.publish(event);
} }
async fn sync_runtime_state_from_backend(&self) {
let backend_opt = self.backend.read().await.clone();
apply_backend_runtime_state(
&self.backend_type,
&self.runtime_state,
&self.events,
self.backend_available.as_ref(),
backend_opt.as_deref(),
)
.await;
} }
async fn start_event_worker(&self) { async fn start_event_worker(&self) {
@@ -468,8 +528,6 @@ impl HidController {
}; };
let backend = self.backend.clone(); let backend = self.backend.clone();
let monitor = self.monitor.clone();
let backend_type = self.backend_type.clone();
let pending_move = self.pending_move.clone(); let pending_move = self.pending_move.clone();
let pending_move_flag = self.pending_move_flag.clone(); let pending_move_flag = self.pending_move_flag.clone();
@@ -481,19 +539,13 @@ impl HidController {
None => break, None => break,
}; };
process_hid_event(event, &backend, &monitor, &backend_type).await; process_hid_event(event, &backend).await;
// After each event, flush latest move if pending // After each event, flush latest move if pending
if pending_move_flag.swap(false, Ordering::AcqRel) { if pending_move_flag.swap(false, Ordering::AcqRel) {
let move_event = { pending_move.lock().take() }; let move_event = { pending_move.lock().take() };
if let Some(move_event) = move_event { if let Some(move_event) = move_event {
process_hid_event( process_hid_event(HidEvent::Mouse(move_event), &backend).await;
HidEvent::Mouse(move_event),
&backend,
&monitor,
&backend_type,
)
.await;
} }
} }
} }
@@ -502,84 +554,43 @@ impl HidController {
*worker_guard = Some(handle); *worker_guard = Some(handle);
} }
async fn start_health_checker(&self) { async fn restart_runtime_worker(&self) {
let mut checker_guard = self.hid_health_checker.lock().await; self.stop_runtime_worker().await;
if checker_guard.is_some() {
let backend_opt = self.backend.read().await.clone();
let Some(backend) = backend_opt else {
return; return;
}
let backend = self.backend.clone();
let backend_type = self.backend_type.clone();
let monitor = self.monitor.clone();
let handle = tokio::spawn(async move {
let mut ticker =
tokio::time::interval(Duration::from_millis(HID_HEALTH_CHECK_INTERVAL_MS));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
let backend_opt = backend.read().await.clone();
let Some(active_backend) = backend_opt else {
continue;
}; };
let backend_name = backend_type.read().await.name_str().to_string(); let mut runtime_rx = backend.subscribe_runtime();
let result = let runtime_state = self.runtime_state.clone();
tokio::task::spawn_blocking(move || active_backend.health_check()).await; let events = self.events.clone();
let backend_available = self.backend_available.clone();
let backend_type = self.backend_type.clone();
match result { let handle = tokio::spawn(async move {
Ok(Ok(())) => { loop {
if monitor.is_error().await { if runtime_rx.changed().await.is_err() {
monitor.report_recovered(&backend_name).await; break;
} }
}
Ok(Err(AppError::HidError { apply_backend_runtime_state(
backend, &backend_type,
reason, &runtime_state,
error_code, &events,
})) => { backend_available.as_ref(),
monitor Some(backend.as_ref()),
.report_error(&backend, None, &reason, &error_code)
.await;
}
Ok(Err(e)) => {
monitor
.report_error(
&backend_name,
None,
&format!("HID health check failed: {}", e),
"health_check_failed",
) )
.await; .await;
} }
Err(e) => {
monitor
.report_error(
&backend_name,
None,
&format!("HID health check task failed: {}", e),
"health_check_join_failed",
)
.await;
}
}
}
}); });
*checker_guard = Some(handle); *self.runtime_worker.lock().await = Some(handle);
} }
async fn stop_health_checker(&self) { async fn stop_runtime_worker(&self) {
let handle_opt = { if let Some(handle) = self.runtime_worker.lock().await.take() {
let mut checker_guard = self.hid_health_checker.lock().await;
checker_guard.take()
};
if let Some(handle) = handle_opt {
handle.abort(); handle.abort();
let _ = handle.await;
} }
} }
@@ -622,25 +633,37 @@ impl HidController {
} }
} }
async fn process_hid_event( async fn apply_backend_runtime_state(
event: HidEvent,
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
monitor: &Arc<HidHealthMonitor>,
backend_type: &Arc<RwLock<HidBackendType>>, backend_type: &Arc<RwLock<HidBackendType>>,
runtime_state: &Arc<RwLock<HidRuntimeState>>,
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
backend_available: &AtomicBool,
backend: Option<&dyn HidBackend>,
) { ) {
let backend_kind = backend_type.read().await.clone();
let next = match backend {
Some(backend) => HidRuntimeState::from_backend(&backend_kind, backend.runtime_snapshot()),
None => HidRuntimeState::from_backend_type(&backend_kind),
};
backend_available.store(next.initialized, Ordering::Release);
apply_runtime_state(runtime_state, events, next).await;
}
async fn process_hid_event(event: HidEvent, backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>) {
let backend_opt = backend.read().await.clone(); let backend_opt = backend.read().await.clone();
let backend = match backend_opt { let backend = match backend_opt {
Some(b) => b, Some(b) => b,
None => return, None => return,
}; };
let backend_for_send = backend.clone();
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
futures::executor::block_on(async move { futures::executor::block_on(async move {
match event { match event {
HidEvent::Keyboard(ev) => backend.send_keyboard(ev).await, HidEvent::Keyboard(ev) => backend_for_send.send_keyboard(ev).await,
HidEvent::Mouse(ev) => backend.send_mouse(ev).await, HidEvent::Mouse(ev) => backend_for_send.send_mouse(ev).await,
HidEvent::Consumer(ev) => backend.send_consumer(ev).await, HidEvent::Consumer(ev) => backend_for_send.send_consumer(ev).await,
HidEvent::Reset => backend.reset().await, HidEvent::Reset => backend_for_send.reset().await,
} }
}) })
}) })
@@ -652,25 +675,9 @@ async fn process_hid_event(
}; };
match result { match result {
Ok(_) => { Ok(_) => {}
if monitor.is_error().await {
let backend_type = backend_type.read().await;
monitor.report_recovered(backend_type.name_str()).await;
}
}
Err(e) => { Err(e) => {
if let AppError::HidError { warn!("HID event processing failed: {}", e);
ref backend,
ref reason,
ref error_code,
} = e
{
if error_code != "eagain_retry" {
monitor
.report_error(backend, None, reason, error_code)
.await;
}
}
} }
} }
} }
@@ -680,3 +687,34 @@ impl Default for HidController {
Self::new(HidBackendType::None, None) Self::new(HidBackendType::None, None)
} }
} }
fn device_for_backend_type(backend_type: &HidBackendType) -> Option<String> {
match backend_type {
HidBackendType::Ch9329 { port, .. } => Some(port.clone()),
_ => None,
}
}
async fn apply_runtime_state(
runtime_state: &Arc<RwLock<HidRuntimeState>>,
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
next: HidRuntimeState,
) {
let changed = {
let mut guard = runtime_state.write().await;
if *guard == next {
false
} else {
*guard = next.clone();
true
}
};
if !changed {
return;
}
if let Some(events) = events.read().await.as_ref() {
events.mark_device_info_dirty();
}
}

View File

@@ -1,416 +0,0 @@
//! HID device health monitoring
//!
//! This module provides health monitoring for HID devices, including:
//! - Device connectivity checks
//! - Automatic reconnection on failure
//! - Error tracking and notification
//! - Log throttling to prevent log flooding
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, warn};
use crate::events::{EventBus, SystemEvent};
use crate::utils::LogThrottler;
/// HID health status
#[derive(Debug, Clone, PartialEq, Default)]
pub enum HidHealthStatus {
/// Device is healthy and operational
#[default]
Healthy,
/// Device has an error, attempting recovery
Error {
/// Human-readable error reason
reason: String,
/// Error code for programmatic handling
error_code: String,
/// Number of recovery attempts made
retry_count: u32,
},
/// Device is disconnected
Disconnected,
}
/// HID health monitor configuration
#[derive(Debug, Clone)]
pub struct HidMonitorConfig {
/// Health check interval in milliseconds
pub check_interval_ms: u64,
/// Retry interval when device is lost (milliseconds)
pub retry_interval_ms: u64,
/// Maximum retry attempts before giving up (0 = infinite)
pub max_retries: u32,
/// Log throttle interval in seconds
pub log_throttle_secs: u64,
/// Recovery cooldown in milliseconds (suppress logs after recovery)
pub recovery_cooldown_ms: u64,
}
impl Default for HidMonitorConfig {
fn default() -> Self {
Self {
check_interval_ms: 1000,
retry_interval_ms: 1000,
max_retries: 0, // infinite retry
log_throttle_secs: 5,
recovery_cooldown_ms: 1000, // 1 second cooldown after recovery
}
}
}
/// HID health monitor
///
/// Monitors HID device health and manages error recovery.
/// Publishes WebSocket events when device status changes.
pub struct HidHealthMonitor {
/// Current health status
status: RwLock<HidHealthStatus>,
/// Event bus for notifications
events: RwLock<Option<Arc<EventBus>>>,
/// Log throttler to prevent log flooding
throttler: LogThrottler,
/// Configuration
config: HidMonitorConfig,
/// Whether monitoring is active (reserved for future use)
#[allow(dead_code)]
running: AtomicBool,
/// Current retry count
retry_count: AtomicU32,
/// Last error code (for change detection)
last_error_code: RwLock<Option<String>>,
/// Last recovery timestamp (milliseconds since start, for cooldown)
last_recovery_ms: AtomicU64,
/// Start instant for timing
start_instant: Instant,
}
impl HidHealthMonitor {
/// Create a new HID health monitor with the specified configuration
pub fn new(config: HidMonitorConfig) -> Self {
let throttle_secs = config.log_throttle_secs;
Self {
status: RwLock::new(HidHealthStatus::Healthy),
events: RwLock::new(None),
throttler: LogThrottler::with_secs(throttle_secs),
config,
running: AtomicBool::new(false),
retry_count: AtomicU32::new(0),
last_error_code: RwLock::new(None),
last_recovery_ms: AtomicU64::new(0),
start_instant: Instant::now(),
}
}
/// Create a new HID health monitor with default configuration
pub fn with_defaults() -> Self {
Self::new(HidMonitorConfig::default())
}
/// Set the event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
/// Report an error from HID operations
///
/// This method is called when an HID operation fails. It:
/// 1. Updates the health status
/// 2. Logs the error (with throttling and cooldown respect)
/// 3. Publishes a WebSocket event if the error is new or changed
///
/// # Arguments
///
/// * `backend` - The HID backend type ("otg" or "ch9329")
/// * `device` - The device path (if known)
/// * `reason` - Human-readable error description
/// * `error_code` - Error code for programmatic handling
pub async fn report_error(
&self,
backend: &str,
device: Option<&str>,
reason: &str,
error_code: &str,
) {
let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1;
// Check if we're in cooldown period after recent recovery
let current_ms = self.start_instant.elapsed().as_millis() as u64;
let last_recovery = self.last_recovery_ms.load(Ordering::Relaxed);
let in_cooldown =
last_recovery > 0 && current_ms < last_recovery + self.config.recovery_cooldown_ms;
// Check if error code changed
let error_changed = {
let last = self.last_error_code.read().await;
last.as_ref().map(|s| s.as_str()) != Some(error_code)
};
// Log with throttling (skip if in cooldown period unless error type changed)
let throttle_key = format!("hid_{}_{}", backend, error_code);
if !in_cooldown && (error_changed || self.throttler.should_log(&throttle_key)) {
warn!(
"HID {} error: {} (code: {}, attempt: {})",
backend, reason, error_code, count
);
}
// Update last error code
*self.last_error_code.write().await = Some(error_code.to_string());
// Update status
*self.status.write().await = HidHealthStatus::Error {
reason: reason.to_string(),
error_code: error_code.to_string(),
retry_count: count,
};
// Publish event (only if error changed or first occurrence, and not in cooldown)
if !in_cooldown && (error_changed || count == 1) {
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::HidDeviceLost {
backend: backend.to_string(),
device: device.map(|s| s.to_string()),
reason: reason.to_string(),
error_code: error_code.to_string(),
});
}
}
}
/// Report that a reconnection attempt is starting
///
/// Publishes a reconnecting event to notify clients.
///
/// # Arguments
///
/// * `backend` - The HID backend type
pub async fn report_reconnecting(&self, backend: &str) {
let attempt = self.retry_count.load(Ordering::Relaxed);
// Only publish every 5 attempts to avoid event spam
if attempt == 1 || attempt.is_multiple_of(5) {
debug!("HID {} reconnecting, attempt {}", backend, attempt);
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::HidReconnecting {
backend: backend.to_string(),
attempt,
});
}
}
}
/// Report that the device has recovered
///
/// This method is called when the HID device successfully reconnects.
/// It resets the error state and publishes a recovery event.
///
/// # Arguments
///
/// * `backend` - The HID backend type
pub async fn report_recovered(&self, backend: &str) {
let prev_status = self.status.read().await.clone();
// Only report recovery if we were in an error state
if prev_status != HidHealthStatus::Healthy {
let retry_count = self.retry_count.load(Ordering::Relaxed);
// Set cooldown timestamp
let current_ms = self.start_instant.elapsed().as_millis() as u64;
self.last_recovery_ms.store(current_ms, Ordering::Relaxed);
// Only log and publish events if there were multiple retries
// (avoid log spam for transient single-retry recoveries)
if retry_count > 1 {
debug!("HID {} recovered after {} retries", backend, retry_count);
// Publish recovery event
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::HidRecovered {
backend: backend.to_string(),
});
// Also publish state changed to indicate healthy state
events.publish(SystemEvent::HidStateChanged {
backend: backend.to_string(),
initialized: true,
error: None,
error_code: None,
});
}
}
// Reset state (always reset, even for single-retry recoveries)
self.retry_count.store(0, Ordering::Relaxed);
*self.last_error_code.write().await = None;
*self.status.write().await = HidHealthStatus::Healthy;
}
}
/// Get the current health status
pub async fn status(&self) -> HidHealthStatus {
self.status.read().await.clone()
}
/// Get the current retry count
pub fn retry_count(&self) -> u32 {
self.retry_count.load(Ordering::Relaxed)
}
/// Check if the monitor is in an error state
pub async fn is_error(&self) -> bool {
matches!(*self.status.read().await, HidHealthStatus::Error { .. })
}
/// Check if the monitor is healthy
pub async fn is_healthy(&self) -> bool {
matches!(*self.status.read().await, HidHealthStatus::Healthy)
}
/// Reset the monitor to healthy state without publishing events
///
/// This is useful during initialization.
pub async fn reset(&self) {
self.retry_count.store(0, Ordering::Relaxed);
*self.last_error_code.write().await = None;
*self.status.write().await = HidHealthStatus::Healthy;
self.throttler.clear_all();
}
/// Get the configuration
pub fn config(&self) -> &HidMonitorConfig {
&self.config
}
/// Check if we should continue retrying
///
/// Returns `false` if max_retries is set and we've exceeded it.
pub fn should_retry(&self) -> bool {
if self.config.max_retries == 0 {
return true; // Infinite retry
}
self.retry_count.load(Ordering::Relaxed) < self.config.max_retries
}
/// Get the retry interval
pub fn retry_interval(&self) -> Duration {
Duration::from_millis(self.config.retry_interval_ms)
}
}
impl Default for HidHealthMonitor {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_initial_status() {
let monitor = HidHealthMonitor::with_defaults();
assert!(monitor.is_healthy().await);
assert!(!monitor.is_error().await);
assert_eq!(monitor.retry_count(), 0);
}
#[tokio::test]
async fn test_report_error() {
let monitor = HidHealthMonitor::with_defaults();
monitor
.report_error("otg", Some("/dev/hidg0"), "Device not found", "enoent")
.await;
assert!(monitor.is_error().await);
assert_eq!(monitor.retry_count(), 1);
if let HidHealthStatus::Error {
reason,
error_code,
retry_count,
} = monitor.status().await
{
assert_eq!(reason, "Device not found");
assert_eq!(error_code, "enoent");
assert_eq!(retry_count, 1);
} else {
panic!("Expected Error status");
}
}
#[tokio::test]
async fn test_report_recovered() {
let monitor = HidHealthMonitor::with_defaults();
// First report an error
monitor
.report_error("ch9329", None, "Port not found", "port_not_found")
.await;
assert!(monitor.is_error().await);
// Then report recovery
monitor.report_recovered("ch9329").await;
assert!(monitor.is_healthy().await);
assert_eq!(monitor.retry_count(), 0);
}
#[tokio::test]
async fn test_retry_count_increments() {
let monitor = HidHealthMonitor::with_defaults();
for i in 1..=5 {
monitor.report_error("otg", None, "Error", "io_error").await;
assert_eq!(monitor.retry_count(), i);
}
}
#[tokio::test]
async fn test_should_retry_infinite() {
let monitor = HidHealthMonitor::new(HidMonitorConfig {
max_retries: 0, // infinite
..Default::default()
});
for _ in 0..100 {
monitor.report_error("otg", None, "Error", "io_error").await;
assert!(monitor.should_retry());
}
}
#[tokio::test]
async fn test_should_retry_limited() {
let monitor = HidHealthMonitor::new(HidMonitorConfig {
max_retries: 3,
..Default::default()
});
assert!(monitor.should_retry());
monitor.report_error("otg", None, "Error", "io_error").await;
assert!(monitor.should_retry()); // 1 < 3
monitor.report_error("otg", None, "Error", "io_error").await;
assert!(monitor.should_retry()); // 2 < 3
monitor.report_error("otg", None, "Error", "io_error").await;
assert!(!monitor.should_retry()); // 3 >= 3
}
#[tokio::test]
async fn test_reset() {
let monitor = HidHealthMonitor::with_defaults();
monitor.report_error("otg", None, "Error", "io_error").await;
assert!(monitor.is_error().await);
monitor.reset().await;
assert!(monitor.is_healthy().await);
assert_eq!(monitor.retry_count(), 0);
}
}

View File

@@ -1,10 +1,12 @@
//! OTG USB Gadget HID backend //! OTG USB Gadget HID backend
//! //!
//! This backend uses Linux USB Gadget API to emulate USB HID devices. //! This backend uses Linux USB Gadget API to emulate USB HID devices.
//! It creates and manages three HID devices: //! It opens the HID gadget device nodes created by `OtgService`.
//! - hidg0: Keyboard (8-byte reports, with LED feedback) //! Depending on the configured OTG profile, this may include:
//! - hidg1: Relative Mouse (4-byte reports) //! - hidg0: Keyboard
//! - hidg2: Absolute Mouse (6-byte reports) //! - hidg1: Relative Mouse
//! - hidg2: Absolute Mouse
//! - hidg3: Consumer Control Keyboard
//! //!
//! Requirements: //! Requirements:
//! - USB OTG/Device controller (UDC) //! - USB OTG/Device controller (UDC)
@@ -20,16 +22,20 @@
use async_trait::async_trait; use async_trait::async_trait;
use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions}; use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsFd; use std::os::unix::io::AsFd;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use tokio::sync::watch;
use tracing::{debug, info, trace, warn}; use tracing::{debug, info, trace, warn};
use super::backend::HidBackend; use super::backend::{HidBackend, HidBackendRuntimeSnapshot};
use super::keymap;
use super::types::{ use super::types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType, ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
}; };
@@ -46,7 +52,7 @@ enum DeviceType {
} }
/// Keyboard LED state /// Keyboard LED state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct LedState { pub struct LedState {
/// Num Lock LED /// Num Lock LED
pub num_lock: bool, pub num_lock: bool,
@@ -124,24 +130,36 @@ pub struct OtgBackend {
mouse_abs_dev: Mutex<Option<File>>, mouse_abs_dev: Mutex<Option<File>>,
/// Consumer control device file /// Consumer control device file
consumer_dev: Mutex<Option<File>>, consumer_dev: Mutex<Option<File>>,
/// Whether keyboard LED/status feedback is enabled.
keyboard_leds_enabled: bool,
/// Current keyboard state /// Current keyboard state
keyboard_state: Mutex<KeyboardReport>, keyboard_state: Mutex<KeyboardReport>,
/// Current mouse button state /// Current mouse button state
mouse_buttons: AtomicU8, mouse_buttons: AtomicU8,
/// Last known LED state (using parking_lot::RwLock for sync access) /// Last known LED state (using parking_lot::RwLock for sync access)
led_state: parking_lot::RwLock<LedState>, led_state: Arc<parking_lot::RwLock<LedState>>,
/// Screen resolution for absolute mouse (using parking_lot::RwLock for sync access) /// Screen resolution for absolute mouse (using parking_lot::RwLock for sync access)
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>, screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
/// UDC name for state checking (e.g., "fcc00000.usb") /// UDC name for state checking (e.g., "fcc00000.usb")
udc_name: parking_lot::RwLock<Option<String>>, udc_name: Arc<parking_lot::RwLock<Option<String>>>,
/// Whether the backend has been initialized.
initialized: AtomicBool,
/// Whether the device is currently online (UDC configured and devices accessible) /// Whether the device is currently online (UDC configured and devices accessible)
online: AtomicBool, online: AtomicBool,
/// Last backend error state.
last_error: parking_lot::RwLock<Option<(String, String)>>,
/// Last error log time for throttling (using parking_lot for sync) /// Last error log time for throttling (using parking_lot for sync)
last_error_log: parking_lot::Mutex<std::time::Instant>, last_error_log: parking_lot::Mutex<std::time::Instant>,
/// Error count since last successful operation (for log throttling) /// Error count since last successful operation (for log throttling)
error_count: AtomicU8, error_count: AtomicU8,
/// Consecutive EAGAIN count (for offline threshold detection) /// Consecutive EAGAIN count (for offline threshold detection)
eagain_count: AtomicU8, eagain_count: AtomicU8,
/// Runtime change notifier.
runtime_notify_tx: watch::Sender<()>,
/// Runtime monitor stop flag.
runtime_worker_stop: Arc<AtomicBool>,
/// Runtime monitor thread.
runtime_worker: Mutex<Option<thread::JoinHandle<()>>>,
} }
/// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout) /// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout)
@@ -153,6 +171,7 @@ impl OtgBackend {
/// This is the ONLY way to create an OtgBackend - it no longer manages /// This is the ONLY way to create an OtgBackend - it no longer manages
/// the USB gadget itself. The gadget must already be set up by OtgService. /// the USB gadget itself. The gadget must already be set up by OtgService.
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> { pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(());
Ok(Self { Ok(Self {
keyboard_path: paths.keyboard, keyboard_path: paths.keyboard,
mouse_rel_path: paths.mouse_relative, mouse_rel_path: paths.mouse_relative,
@@ -162,18 +181,59 @@ impl OtgBackend {
mouse_rel_dev: Mutex::new(None), mouse_rel_dev: Mutex::new(None),
mouse_abs_dev: Mutex::new(None), mouse_abs_dev: Mutex::new(None),
consumer_dev: Mutex::new(None), consumer_dev: Mutex::new(None),
keyboard_leds_enabled: paths.keyboard_leds_enabled,
keyboard_state: Mutex::new(KeyboardReport::default()), keyboard_state: Mutex::new(KeyboardReport::default()),
mouse_buttons: AtomicU8::new(0), mouse_buttons: AtomicU8::new(0),
led_state: parking_lot::RwLock::new(LedState::default()), led_state: Arc::new(parking_lot::RwLock::new(LedState::default())),
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))), screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
udc_name: parking_lot::RwLock::new(None), udc_name: Arc::new(parking_lot::RwLock::new(paths.udc)),
initialized: AtomicBool::new(false),
online: AtomicBool::new(false), online: AtomicBool::new(false),
last_error: parking_lot::RwLock::new(None),
last_error_log: parking_lot::Mutex::new(std::time::Instant::now()), last_error_log: parking_lot::Mutex::new(std::time::Instant::now()),
error_count: AtomicU8::new(0), error_count: AtomicU8::new(0),
eagain_count: AtomicU8::new(0), eagain_count: AtomicU8::new(0),
runtime_notify_tx,
runtime_worker_stop: Arc::new(AtomicBool::new(false)),
runtime_worker: Mutex::new(None),
}) })
} }
fn notify_runtime_changed(&self) {
let _ = self.runtime_notify_tx.send(());
}
fn clear_error(&self) {
let mut error = self.last_error.write();
if error.is_some() {
*error = None;
self.notify_runtime_changed();
}
}
fn record_error(&self, reason: impl Into<String>, error_code: impl Into<String>) {
let reason = reason.into();
let error_code = error_code.into();
let was_online = self.online.swap(false, Ordering::Relaxed);
let mut error = self.last_error.write();
let changed = error.as_ref() != Some(&(reason.clone(), error_code.clone()));
*error = Some((reason, error_code));
drop(error);
if was_online || changed {
self.notify_runtime_changed();
}
}
fn mark_online(&self) {
let was_online = self.online.swap(true, Ordering::Relaxed);
let mut error = self.last_error.write();
let cleared_error = error.take().is_some();
drop(error);
if !was_online || cleared_error {
self.notify_runtime_changed();
}
}
/// Log throttled error message (max once per second) /// Log throttled error message (max once per second)
fn log_throttled_error(&self, msg: &str) { fn log_throttled_error(&self, msg: &str) {
let mut last_log = self.last_error_log.lock(); let mut last_log = self.last_error_log.lock();
@@ -237,13 +297,16 @@ impl OtgBackend {
*self.udc_name.write() = Some(udc.to_string()); *self.udc_name.write() = Some(udc.to_string());
} }
/// Check if the UDC is in "configured" state fn read_udc_configured(udc_name: &parking_lot::RwLock<Option<String>>) -> bool {
/// let current_udc = udc_name.read().clone().or_else(Self::find_udc);
/// This is based on PiKVM's `__is_udc_configured()` method. if let Some(udc) = current_udc {
/// The UDC state file indicates whether the USB host has enumerated and configured the gadget. {
pub fn is_udc_configured(&self) -> bool { let mut guard = udc_name.write();
let udc_name = self.udc_name.read(); if guard.as_ref() != Some(&udc) {
if let Some(ref udc) = *udc_name { *guard = Some(udc.clone());
}
}
let state_path = format!("/sys/class/udc/{}/state", udc); let state_path = format!("/sys/class/udc/{}/state", udc);
match fs::read_to_string(&state_path) { match fs::read_to_string(&state_path) {
Ok(content) => { Ok(content) => {
@@ -253,24 +316,20 @@ impl OtgBackend {
} }
Err(e) => { Err(e) => {
debug!("Failed to read UDC state from {}: {}", state_path, e); debug!("Failed to read UDC state from {}: {}", state_path, e);
// If we can't read the state, assume it might be configured
// to avoid blocking operations unnecessarily
true true
} }
} }
} else {
// No UDC name set, try to auto-detect
if let Some(udc) = Self::find_udc() {
drop(udc_name);
*self.udc_name.write() = Some(udc.clone());
let state_path = format!("/sys/class/udc/{}/state", udc);
fs::read_to_string(&state_path)
.map(|s| s.trim().to_lowercase() == "configured")
.unwrap_or(true)
} else { } else {
true true
} }
} }
/// Check if the UDC is in "configured" state
///
/// This is based on PiKVM's `__is_udc_configured()` method.
/// The UDC state file indicates whether the USB host has enumerated and configured the gadget.
pub fn is_udc_configured(&self) -> bool {
Self::read_udc_configured(&self.udc_name)
} }
/// Find the first available UDC /// Find the first available UDC
@@ -286,11 +345,6 @@ impl OtgBackend {
None None
} }
/// Check if device is online
pub fn is_online(&self) -> bool {
self.online.load(Ordering::Relaxed)
}
/// Ensure a device is open and ready for I/O /// Ensure a device is open and ready for I/O
/// ///
/// This method is based on PiKVM's `__ensure_device()` pattern: /// This method is based on PiKVM's `__ensure_device()` pattern:
@@ -308,12 +362,13 @@ impl OtgBackend {
let path = match path_opt { let path = match path_opt {
Some(p) => p, Some(p) => p,
None => { None => {
self.online.store(false, Ordering::Relaxed); let err = AppError::HidError {
return Err(AppError::HidError {
backend: "otg".to_string(), backend: "otg".to_string(),
reason: "Device disabled".to_string(), reason: "Device disabled".to_string(),
error_code: "disabled".to_string(), error_code: "disabled".to_string(),
}); };
self.record_error("Device disabled", "disabled");
return Err(err);
} }
}; };
@@ -328,10 +383,11 @@ impl OtgBackend {
); );
*dev = None; *dev = None;
} }
self.online.store(false, Ordering::Relaxed); let reason = format!("Device not found: {}", path.display());
self.record_error(reason.clone(), "enoent");
return Err(AppError::HidError { return Err(AppError::HidError {
backend: "otg".to_string(), backend: "otg".to_string(),
reason: format!("Device not found: {}", path.display()), reason,
error_code: "enoent".to_string(), error_code: "enoent".to_string(),
}); });
} }
@@ -346,12 +402,16 @@ impl OtgBackend {
} }
Err(e) => { Err(e) => {
warn!("Failed to reopen HID device {}: {}", path.display(), e); warn!("Failed to reopen HID device {}: {}", path.display(), e);
self.record_error(
format!("Failed to reopen HID device {}: {}", path.display(), e),
"not_opened",
);
return Err(e); return Err(e);
} }
} }
} }
self.online.store(true, Ordering::Relaxed); self.mark_online();
Ok(()) Ok(())
} }
@@ -372,8 +432,8 @@ impl OtgBackend {
} }
/// Convert I/O error to HidError with appropriate error code /// Convert I/O error to HidError with appropriate error code
fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError { fn io_error_code(e: &std::io::Error) -> &'static str {
let error_code = match e.raw_os_error() { match e.raw_os_error() {
Some(32) => "epipe", // EPIPE - broken pipe Some(32) => "epipe", // EPIPE - broken pipe
Some(108) => "eshutdown", // ESHUTDOWN - transport endpoint shutdown Some(108) => "eshutdown", // ESHUTDOWN - transport endpoint shutdown
Some(11) => "eagain", // EAGAIN - resource temporarily unavailable Some(11) => "eagain", // EAGAIN - resource temporarily unavailable
@@ -382,7 +442,11 @@ impl OtgBackend {
Some(5) => "eio", // EIO - I/O error Some(5) => "eio", // EIO - I/O error
Some(2) => "enoent", // ENOENT - no such file or directory Some(2) => "enoent", // ENOENT - no such file or directory
_ => "io_error", _ => "io_error",
}; }
}
fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError {
let error_code = Self::io_error_code(&e);
AppError::HidError { AppError::HidError {
backend: "otg".to_string(), backend: "otg".to_string(),
@@ -438,7 +502,7 @@ impl OtgBackend {
let data = report.to_bytes(); let data = report.to_bytes();
match self.write_with_timeout(file, &data) { match self.write_with_timeout(file, &data) {
Ok(true) => { Ok(true) => {
self.online.store(true, Ordering::Relaxed); self.mark_online();
self.reset_error_count(); self.reset_error_count();
debug!("Sent keyboard report: {:02X?}", data); debug!("Sent keyboard report: {:02X?}", data);
Ok(()) Ok(())
@@ -454,10 +518,13 @@ impl OtgBackend {
match error_code { match error_code {
Some(108) => { Some(108) => {
// ESHUTDOWN - endpoint closed, need to reopen device // ESHUTDOWN - endpoint closed, need to reopen device
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed);
debug!("Keyboard ESHUTDOWN, closing for recovery"); debug!("Keyboard ESHUTDOWN, closing for recovery");
*dev = None; *dev = None;
self.record_error(
format!("Failed to write keyboard report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error( Err(Self::io_error_to_hid_error(
e, e,
"Failed to write keyboard report", "Failed to write keyboard report",
@@ -469,9 +536,12 @@ impl OtgBackend {
Ok(()) Ok(())
} }
_ => { _ => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed);
warn!("Keyboard write error: {}", e); warn!("Keyboard write error: {}", e);
self.record_error(
format!("Failed to write keyboard report: {}", e),
Self::io_error_code(&e),
);
Err(Self::io_error_to_hid_error( Err(Self::io_error_to_hid_error(
e, e,
"Failed to write keyboard report", "Failed to write keyboard report",
@@ -507,7 +577,7 @@ impl OtgBackend {
let data = [buttons, dx as u8, dy as u8, wheel as u8]; let data = [buttons, dx as u8, dy as u8, wheel as u8];
match self.write_with_timeout(file, &data) { match self.write_with_timeout(file, &data) {
Ok(true) => { Ok(true) => {
self.online.store(true, Ordering::Relaxed); self.mark_online();
self.reset_error_count(); self.reset_error_count();
trace!("Sent relative mouse report: {:02X?}", data); trace!("Sent relative mouse report: {:02X?}", data);
Ok(()) Ok(())
@@ -521,10 +591,13 @@ impl OtgBackend {
match error_code { match error_code {
Some(108) => { Some(108) => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed);
debug!("Relative mouse ESHUTDOWN, closing for recovery"); debug!("Relative mouse ESHUTDOWN, closing for recovery");
*dev = None; *dev = None;
self.record_error(
format!("Failed to write mouse report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error( Err(Self::io_error_to_hid_error(
e, e,
"Failed to write mouse report", "Failed to write mouse report",
@@ -535,9 +608,12 @@ impl OtgBackend {
Ok(()) Ok(())
} }
_ => { _ => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed);
warn!("Relative mouse write error: {}", e); warn!("Relative mouse write error: {}", e);
self.record_error(
format!("Failed to write mouse report: {}", e),
Self::io_error_code(&e),
);
Err(Self::io_error_to_hid_error( Err(Self::io_error_to_hid_error(
e, e,
"Failed to write mouse report", "Failed to write mouse report",
@@ -580,7 +656,7 @@ impl OtgBackend {
]; ];
match self.write_with_timeout(file, &data) { match self.write_with_timeout(file, &data) {
Ok(true) => { Ok(true) => {
self.online.store(true, Ordering::Relaxed); self.mark_online();
self.reset_error_count(); self.reset_error_count();
Ok(()) Ok(())
} }
@@ -593,10 +669,13 @@ impl OtgBackend {
match error_code { match error_code {
Some(108) => { Some(108) => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed);
debug!("Absolute mouse ESHUTDOWN, closing for recovery"); debug!("Absolute mouse ESHUTDOWN, closing for recovery");
*dev = None; *dev = None;
self.record_error(
format!("Failed to write mouse report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error( Err(Self::io_error_to_hid_error(
e, e,
"Failed to write mouse report", "Failed to write mouse report",
@@ -607,9 +686,12 @@ impl OtgBackend {
Ok(()) Ok(())
} }
_ => { _ => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed); self.eagain_count.store(0, Ordering::Relaxed);
warn!("Absolute mouse write error: {}", e); warn!("Absolute mouse write error: {}", e);
self.record_error(
format!("Failed to write mouse report: {}", e),
Self::io_error_code(&e),
);
Err(Self::io_error_to_hid_error( Err(Self::io_error_to_hid_error(
e, e,
"Failed to write mouse report", "Failed to write mouse report",
@@ -648,7 +730,7 @@ impl OtgBackend {
// Send release (0x0000) // Send release (0x0000)
let release = [0u8, 0u8]; let release = [0u8, 0u8];
let _ = self.write_with_timeout(file, &release); let _ = self.write_with_timeout(file, &release);
self.online.store(true, Ordering::Relaxed); self.mark_online();
self.reset_error_count(); self.reset_error_count();
Ok(()) Ok(())
} }
@@ -660,9 +742,12 @@ impl OtgBackend {
let error_code = e.raw_os_error(); let error_code = e.raw_os_error();
match error_code { match error_code {
Some(108) => { Some(108) => {
self.online.store(false, Ordering::Relaxed);
debug!("Consumer control ESHUTDOWN, closing for recovery"); debug!("Consumer control ESHUTDOWN, closing for recovery");
*dev = None; *dev = None;
self.record_error(
format!("Failed to write consumer report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error( Err(Self::io_error_to_hid_error(
e, e,
"Failed to write consumer report", "Failed to write consumer report",
@@ -673,8 +758,11 @@ impl OtgBackend {
Ok(()) Ok(())
} }
_ => { _ => {
self.online.store(false, Ordering::Relaxed);
warn!("Consumer control write error: {}", e); warn!("Consumer control write error: {}", e);
self.record_error(
format!("Failed to write consumer report: {}", e),
Self::io_error_code(&e),
);
Err(Self::io_error_to_hid_error( Err(Self::io_error_to_hid_error(
e, e,
"Failed to write consumer report", "Failed to write consumer report",
@@ -697,50 +785,205 @@ impl OtgBackend {
self.send_consumer_report(event.usage) self.send_consumer_report(event.usage)
} }
/// Read keyboard LED state (non-blocking)
pub fn read_led_state(&self) -> Result<Option<LedState>> {
let mut dev = self.keyboard_dev.lock();
if let Some(ref mut file) = *dev {
let mut buf = [0u8; 1];
match file.read(&mut buf) {
Ok(1) => {
let state = LedState::from_byte(buf[0]);
// Update LED state (using parking_lot RwLock)
*self.led_state.write() = state;
Ok(Some(state))
}
Ok(_) => Ok(None), // No data available
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(e) => Err(AppError::Internal(format!(
"Failed to read LED state: {}",
e
))),
}
} else {
Ok(None)
}
}
/// Get last known LED state /// Get last known LED state
pub fn led_state(&self) -> LedState { pub fn led_state(&self) -> LedState {
*self.led_state.read() *self.led_state.read()
} }
fn build_runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
let initialized = self.initialized.load(Ordering::Relaxed);
let mut online = initialized && self.online.load(Ordering::Relaxed);
let mut error = self.last_error.read().clone();
if initialized && !self.check_devices_exist() {
online = false;
let missing = self.get_missing_devices();
error = Some((
format!("HID device node missing: {}", missing.join(", ")),
"enoent".to_string(),
));
} else if initialized && !self.is_udc_configured() {
online = false;
error = Some((
"UDC is not in configured state".to_string(),
"udc_not_configured".to_string(),
));
}
HidBackendRuntimeSnapshot {
initialized,
online,
supports_absolute_mouse: self.mouse_abs_path.as_ref().is_some_and(|p| p.exists()),
keyboard_leds_enabled: self.keyboard_leds_enabled,
led_state: self.led_state(),
screen_resolution: *self.screen_resolution.read(),
device: self.udc_name.read().clone(),
error: error.as_ref().map(|(reason, _)| reason.clone()),
error_code: error.as_ref().map(|(_, code)| code.clone()),
}
}
fn poll_keyboard_led_once(
file: &mut Option<File>,
path: &PathBuf,
led_state: &Arc<parking_lot::RwLock<LedState>>,
) -> bool {
if file.is_none() {
match OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
{
Ok(opened) => {
*file = Some(opened);
}
Err(err) => {
warn!(
"Failed to open OTG keyboard LED listener {}: {}",
path.display(),
err
);
thread::sleep(Duration::from_millis(500));
return false;
}
}
}
let Some(file_ref) = file.as_mut() else {
return false;
};
let mut pollfd = [PollFd::new(
file_ref.as_fd(),
PollFlags::POLLIN | PollFlags::POLLERR | PollFlags::POLLHUP,
)];
match poll(&mut pollfd, PollTimeout::from(500u16)) {
Ok(0) => false,
Ok(_) => {
let Some(revents) = pollfd[0].revents() else {
return false;
};
if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP) {
*file = None;
return true;
}
if !revents.contains(PollFlags::POLLIN) {
return false;
}
let mut buf = [0u8; 1];
match file_ref.read(&mut buf) {
Ok(1) => {
let next = LedState::from_byte(buf[0]);
let mut guard = led_state.write();
if *guard == next {
false
} else {
*guard = next;
true
}
}
Ok(_) => false,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => false,
Err(err) => {
warn!("OTG keyboard LED listener read failed: {}", err);
*file = None;
true
}
}
}
Err(err) => {
warn!("OTG keyboard LED listener poll failed: {}", err);
*file = None;
true
}
}
}
fn start_runtime_worker(&self) {
let mut worker = self.runtime_worker.lock();
if worker.is_some() {
return;
}
self.runtime_worker_stop.store(false, Ordering::Relaxed);
let stop = self.runtime_worker_stop.clone();
let keyboard_leds_enabled = self.keyboard_leds_enabled;
let keyboard_path = self.keyboard_path.clone();
let led_state = self.led_state.clone();
let udc_name = self.udc_name.clone();
let runtime_notify_tx = self.runtime_notify_tx.clone();
let handle = thread::Builder::new()
.name("otg-runtime-monitor".to_string())
.spawn(move || {
let mut last_udc_configured = Some(Self::read_udc_configured(&udc_name));
let mut keyboard_led_file: Option<File> = None;
while !stop.load(Ordering::Relaxed) {
let mut changed = false;
let current_udc_configured = Self::read_udc_configured(&udc_name);
if last_udc_configured != Some(current_udc_configured) {
last_udc_configured = Some(current_udc_configured);
changed = true;
}
if keyboard_leds_enabled {
if let Some(path) = keyboard_path.as_ref() {
changed |= Self::poll_keyboard_led_once(
&mut keyboard_led_file,
path,
&led_state,
);
} else {
thread::sleep(Duration::from_millis(500));
}
} else {
thread::sleep(Duration::from_millis(500));
}
if changed {
let _ = runtime_notify_tx.send(());
}
}
});
match handle {
Ok(handle) => {
*worker = Some(handle);
}
Err(err) => {
warn!("Failed to spawn OTG runtime monitor: {}", err);
}
}
}
fn stop_runtime_worker(&self) {
self.runtime_worker_stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.runtime_worker.lock().take() {
let _ = handle.join();
}
}
} }
#[async_trait] #[async_trait]
impl HidBackend for OtgBackend { impl HidBackend for OtgBackend {
fn name(&self) -> &'static str {
"OTG USB Gadget"
}
async fn init(&self) -> Result<()> { async fn init(&self) -> Result<()> {
info!("Initializing OTG HID backend"); info!("Initializing OTG HID backend");
// Auto-detect UDC name for state checking // Auto-detect UDC name for state checking only if OtgService did not provide one
if self.udc_name.read().is_none() {
if let Some(udc) = Self::find_udc() { if let Some(udc) = Self::find_udc() {
info!("Auto-detected UDC: {}", udc); info!("Auto-detected UDC: {}", udc);
self.set_udc_name(&udc); self.set_udc_name(&udc);
} }
} else if let Some(udc) = self.udc_name.read().clone() {
info!("Using configured UDC: {}", udc);
}
// Wait for devices to appear (they should already exist from OtgService) // Wait for devices to appear (they should already exist from OtgService)
let mut device_paths = Vec::new(); let mut device_paths = Vec::new();
@@ -812,24 +1055,22 @@ impl HidBackend for OtgBackend {
} }
// Mark as online if all devices opened successfully // Mark as online if all devices opened successfully
self.online.store(true, Ordering::Relaxed); self.initialized.store(true, Ordering::Relaxed);
self.notify_runtime_changed();
self.start_runtime_worker();
self.mark_online();
Ok(()) Ok(())
} }
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> { async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
// Convert JS keycode to USB HID if needed (skip if already USB HID) let usb_key = event.key.to_hid_usage();
let usb_key = if event.is_usb_hid {
event.key
} else {
keymap::js_to_usb(event.key).unwrap_or(event.key)
};
// Handle modifier keys separately // Handle modifier keys separately
if keymap::is_modifier_key(usb_key) { if event.key.is_modifier() {
let mut state = self.keyboard_state.lock(); let mut state = self.keyboard_state.lock();
if let Some(bit) = keymap::modifier_bit(usb_key) { if let Some(bit) = event.key.modifier_bit() {
match event.event_type { match event.event_type {
KeyEventType::Down => state.modifiers |= bit, KeyEventType::Down => state.modifiers |= bit,
KeyEventType::Up => state.modifiers &= !bit, KeyEventType::Up => state.modifiers &= !bit,
@@ -925,6 +1166,8 @@ impl HidBackend for OtgBackend {
} }
async fn shutdown(&self) -> Result<()> { async fn shutdown(&self) -> Result<()> {
self.stop_runtime_worker();
// Reset before closing // Reset before closing
self.reset().await?; self.reset().await?;
@@ -935,49 +1178,30 @@ impl HidBackend for OtgBackend {
*self.consumer_dev.lock() = None; *self.consumer_dev.lock() = None;
// Gadget cleanup is handled by OtgService, not here // Gadget cleanup is handled by OtgService, not here
self.initialized.store(false, Ordering::Relaxed);
self.online.store(false, Ordering::Relaxed);
self.clear_error();
self.notify_runtime_changed();
info!("OTG backend shutdown"); info!("OTG backend shutdown");
Ok(()) Ok(())
} }
fn health_check(&self) -> Result<()> { fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
if !self.check_devices_exist() { self.build_runtime_snapshot()
let missing = self.get_missing_devices();
self.online.store(false, Ordering::Relaxed);
return Err(AppError::HidError {
backend: "otg".to_string(),
reason: format!("HID device node missing: {}", missing.join(", ")),
error_code: "enoent".to_string(),
});
} }
if !self.is_udc_configured() { fn subscribe_runtime(&self) -> watch::Receiver<()> {
self.online.store(false, Ordering::Relaxed); self.runtime_notify_tx.subscribe()
return Err(AppError::HidError {
backend: "otg".to_string(),
reason: "UDC is not in configured state".to_string(),
error_code: "udc_not_configured".to_string(),
});
}
self.online.store(true, Ordering::Relaxed);
Ok(())
}
fn supports_absolute_mouse(&self) -> bool {
self.mouse_abs_path.as_ref().is_some_and(|p| p.exists())
} }
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> { async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
self.send_consumer_report(event.usage) self.send_consumer_report(event.usage)
} }
fn screen_resolution(&self) -> Option<(u32, u32)> {
*self.screen_resolution.read()
}
fn set_screen_resolution(&mut self, width: u32, height: u32) { fn set_screen_resolution(&mut self, width: u32, height: u32) {
*self.screen_resolution.write() = Some((width, height)); *self.screen_resolution.write() = Some((width, height));
self.notify_runtime_changed();
} }
} }
@@ -994,6 +1218,10 @@ pub fn is_otg_available() -> bool {
/// Implement Drop for OtgBackend to close device files /// Implement Drop for OtgBackend to close device files
impl Drop for OtgBackend { impl Drop for OtgBackend {
fn drop(&mut self) { fn drop(&mut self) {
self.runtime_worker_stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.runtime_worker.get_mut().take() {
let _ = handle.join();
}
// Close device files // Close device files
// Note: Gadget cleanup is handled by OtgService, not here // Note: Gadget cleanup is handled by OtgService, not here
*self.keyboard_dev.lock() = None; *self.keyboard_dev.lock() = None;

View File

@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::keyboard::CanonicalKey;
/// Keyboard event type /// Keyboard event type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -105,34 +107,29 @@ pub struct KeyboardEvent {
/// Event type (down/up) /// Event type (down/up)
#[serde(rename = "type")] #[serde(rename = "type")]
pub event_type: KeyEventType, pub event_type: KeyEventType,
/// Key code (USB HID usage code or JavaScript key code) /// Canonical keyboard key identifier shared across frontend and backend
pub key: u8, pub key: CanonicalKey,
/// Modifier keys state /// Modifier keys state
#[serde(default)] #[serde(default)]
pub modifiers: KeyboardModifiers, pub modifiers: KeyboardModifiers,
/// If true, key is already USB HID code (skip js_to_usb conversion)
#[serde(default)]
pub is_usb_hid: bool,
} }
impl KeyboardEvent { impl KeyboardEvent {
/// Create a key down event (JS keycode, needs conversion) /// Create a key down event
pub fn key_down(key: u8, modifiers: KeyboardModifiers) -> Self { pub fn key_down(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
Self { Self {
event_type: KeyEventType::Down, event_type: KeyEventType::Down,
key, key,
modifiers, modifiers,
is_usb_hid: false,
} }
} }
/// Create a key up event (JS keycode, needs conversion) /// Create a key up event
pub fn key_up(key: u8, modifiers: KeyboardModifiers) -> Self { pub fn key_up(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
Self { Self {
event_type: KeyEventType::Up, event_type: KeyEventType::Up,
key, key,
modifiers, modifiers,
is_usb_hid: false,
} }
} }
} }

View File

@@ -7,7 +7,7 @@ use axum_server::tls_rustls::RustlsConfig;
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use futures::{stream::FuturesUnordered, StreamExt}; use futures::{stream::FuturesUnordered, StreamExt};
use rustls::crypto::{ring, CryptoProvider}; use rustls::crypto::{ring, CryptoProvider};
use tokio::sync::broadcast; use tokio::sync::{broadcast, mpsc};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use one_kvm::atx::AtxController; use one_kvm::atx::AtxController;
@@ -18,7 +18,7 @@ use one_kvm::events::EventBus;
use one_kvm::extensions::ExtensionManager; use one_kvm::extensions::ExtensionManager;
use one_kvm::hid::{HidBackendType, HidController}; use one_kvm::hid::{HidBackendType, HidController};
use one_kvm::msd::MsdController; use one_kvm::msd::MsdController;
use one_kvm::otg::{configfs, OtgService}; use one_kvm::otg::OtgService;
use one_kvm::rtsp::RtspService; use one_kvm::rtsp::RtspService;
use one_kvm::rustdesk::RustDeskService; use one_kvm::rustdesk::RustDeskService;
use one_kvm::state::AppState; use one_kvm::state::AppState;
@@ -319,32 +319,9 @@ async fn main() -> anyhow::Result<()> {
let otg_service = Arc::new(OtgService::new()); let otg_service = Arc::new(OtgService::new());
tracing::info!("OTG Service created"); tracing::info!("OTG Service created");
// Pre-enable OTG functions to avoid gadget recreation (prevents kernel crashes) // Reconcile OTG once from the persisted config so controllers only consume its result.
let will_use_otg_hid = matches!(config.hid.backend, config::HidBackend::Otg); if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await {
let will_use_msd = config.msd.enabled; tracing::warn!("Failed to apply OTG config: {}", e);
if will_use_otg_hid {
let mut hid_functions = config.hid.effective_otg_functions();
if let Some(udc) = configfs::resolve_udc_name(config.hid.otg_udc.as_deref()) {
if configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer {
tracing::warn!(
"UDC {} has low endpoint resources, disabling consumer control",
udc
);
hid_functions.consumer = false;
}
}
if let Err(e) = otg_service.update_hid_functions(hid_functions).await {
tracing::warn!("Failed to apply HID functions: {}", e);
}
if let Err(e) = otg_service.enable_hid().await {
tracing::warn!("Failed to pre-enable HID: {}", e);
}
}
if will_use_msd {
if let Err(e) = otg_service.enable_msd().await {
tracing::warn!("Failed to pre-enable MSD: {}", e);
}
} }
// Create HID controller based on config // Create HID controller based on config
@@ -576,6 +553,8 @@ async fn main() -> anyhow::Result<()> {
data_dir.clone(), data_dir.clone(),
); );
extensions.set_event_bus(events.clone()).await;
// Start RustDesk service if enabled // Start RustDesk service if enabled
if let Some(ref service) = rustdesk { if let Some(ref service) = rustdesk {
if let Err(e) = service.start().await { if let Err(e) = service.start().await {
@@ -646,6 +625,8 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Extension health check task started"); tracing::info!("Extension health check task started");
} }
state.publish_device_info().await;
// Start device info broadcast task // Start device info broadcast task
// This monitors state change events and broadcasts DeviceInfo to all clients // This monitors state change events and broadcasts DeviceInfo to all clients
spawn_device_info_broadcaster(state.clone(), events); spawn_device_info_broadcaster(state.clone(), events);
@@ -854,12 +835,86 @@ fn generate_self_signed_cert() -> anyhow::Result<rcgen::CertifiedKey<rcgen::KeyP
/// Spawn a background task that monitors state change events /// Spawn a background task that monitors state change events
/// and broadcasts DeviceInfo to all WebSocket clients with debouncing /// and broadcasts DeviceInfo to all WebSocket clients with debouncing
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) { fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
use one_kvm::events::SystemEvent;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
let mut rx = events.subscribe(); enum DeviceInfoTrigger {
Event,
Lagged { topic: &'static str, count: u64 },
}
const DEVICE_INFO_TOPICS: &[&str] = &[
"stream.state_changed",
"stream.config_applied",
"stream.mode_ready",
];
const DEBOUNCE_MS: u64 = 100; const DEBOUNCE_MS: u64 = 100;
let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel();
for topic in DEVICE_INFO_TOPICS {
let Some(mut rx) = events.subscribe_topic(topic) else {
tracing::warn!(
"DeviceInfo broadcaster missing topic subscription: {}",
topic
);
continue;
};
let trigger_tx = trigger_tx.clone();
let topic_name = *topic;
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(_) => {
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
if trigger_tx
.send(DeviceInfoTrigger::Lagged {
topic: topic_name,
count,
})
.is_err()
{
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
{
let mut dirty_rx = events.subscribe_device_info_dirty();
let trigger_tx = trigger_tx.clone();
tokio::spawn(async move {
loop {
match dirty_rx.recv().await {
Ok(()) => {
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
if trigger_tx
.send(DeviceInfoTrigger::Lagged {
topic: "device_info_dirty",
count,
})
.is_err()
{
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
tokio::spawn(async move { tokio::spawn(async move {
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS); let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
let mut pending_broadcast = false; let mut pending_broadcast = false;
@@ -869,32 +924,24 @@ fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
let recv_result = if pending_broadcast { let recv_result = if pending_broadcast {
let remaining = let remaining =
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64); DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
tokio::time::timeout(Duration::from_millis(remaining), rx.recv()).await tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await
} else { } else {
Ok(rx.recv().await) Ok(trigger_rx.recv().await)
}; };
match recv_result { match recv_result {
Ok(Ok(event)) => { Ok(Some(DeviceInfoTrigger::Event)) => {
let should_broadcast = matches!( pending_broadcast = true;
event, }
SystemEvent::StreamStateChanged { .. } Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
| SystemEvent::StreamConfigApplied { .. } tracing::warn!(
| SystemEvent::StreamModeReady { .. } "DeviceInfo broadcaster lagged by {} events on topic {}",
| SystemEvent::HidStateChanged { .. } count,
| SystemEvent::MsdStateChanged { .. } topic
| SystemEvent::AtxStateChanged { .. }
| SystemEvent::AudioStateChanged { .. }
); );
if should_broadcast {
pending_broadcast = true; pending_broadcast = true;
} }
} Ok(None) => {
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(n))) => {
tracing::warn!("DeviceInfo broadcaster lagged by {} events", n);
pending_broadcast = true;
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
tracing::info!("Event bus closed, stopping DeviceInfo broadcaster"); tracing::info!("Event bus closed, stopping DeviceInfo broadcaster");
break; break;
} }

View File

@@ -19,9 +19,6 @@ use super::types::{DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MsdMo
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::otg::{MsdFunction, MsdLunConfig, OtgService}; use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
/// USB Gadget path (system constant)
const GADGET_PATH: &str = "/sys/kernel/config/usb_gadget/one-kvm";
/// MSD Controller /// MSD Controller
pub struct MsdController { pub struct MsdController {
/// OTG Service reference /// OTG Service reference
@@ -83,9 +80,11 @@ impl MsdController {
warn!("Failed to create ventoy directory: {}", e); warn!("Failed to create ventoy directory: {}", e);
} }
// 2. Request MSD function from OtgService // 2. Get active MSD function from OtgService
info!("Requesting MSD function from OtgService"); info!("Fetching MSD function from OtgService");
let msd_func = self.otg_service.enable_msd().await?; let msd_func = self.otg_service.msd_function().await.ok_or_else(|| {
AppError::Internal("MSD function is not active in OtgService".to_string())
})?;
// 3. Store function handle // 3. Store function handle
*self.msd_function.write().await = Some(msd_func); *self.msd_function.write().await = Some(msd_func);
@@ -115,15 +114,6 @@ impl MsdController {
Ok(()) Ok(())
} }
/// Get current state as SystemEvent
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
let state = self.state.read().await;
crate::events::SystemEvent::MsdStateChanged {
mode: state.mode.clone(),
connected: state.connected,
}
}
/// Get current MSD state /// Get current MSD state
pub async fn state(&self) -> MsdState { pub async fn state(&self) -> MsdState {
self.state.read().await.clone() self.state.read().await.clone()
@@ -131,9 +121,7 @@ impl MsdController {
/// Set event bus for broadcasting state changes /// Set event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: std::sync::Arc<crate::events::EventBus>) { pub async fn set_event_bus(&self, events: std::sync::Arc<crate::events::EventBus>) {
*self.events.write().await = Some(events.clone()); *self.events.write().await = Some(events);
// Also set event bus on the monitor for health notifications
self.monitor.set_event_bus(events).await;
} }
/// Publish an event to the event bus /// Publish an event to the event bus
@@ -143,6 +131,12 @@ impl MsdController {
} }
} }
async fn mark_device_info_dirty(&self) {
if let Some(ref bus) = *self.events.read().await {
bus.mark_device_info_dirty();
}
}
/// Check if MSD is available /// Check if MSD is available
pub async fn is_available(&self) -> bool { pub async fn is_available(&self) -> bool {
self.state.read().await.available self.state.read().await.available
@@ -195,7 +189,7 @@ impl MsdController {
MsdLunConfig::disk(image.path.clone(), read_only) MsdLunConfig::disk(image.path.clone(), read_only)
}; };
let gadget_path = PathBuf::from(GADGET_PATH); let gadget_path = self.active_gadget_path().await?;
if let Some(ref msd) = *self.msd_function.read().await { if let Some(ref msd) = *self.msd_function.read().await {
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await { if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
let error_msg = format!("Failed to configure LUN: {}", e); let error_msg = format!("Failed to configure LUN: {}", e);
@@ -230,20 +224,7 @@ impl MsdController {
self.monitor.report_recovered().await; self.monitor.report_recovered().await;
} }
// Publish events self.mark_device_info_dirty().await;
self.publish_event(crate::events::SystemEvent::MsdImageMounted {
image_id: image.id.clone(),
image_name: image.name.clone(),
size: image.size,
cdrom,
})
.await;
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
mode: MsdMode::Image,
connected: true,
})
.await;
Ok(()) Ok(())
} }
@@ -282,7 +263,7 @@ impl MsdController {
// Configure LUN as read-write disk // Configure LUN as read-write disk
let config = MsdLunConfig::disk(self.drive_path.clone(), false); let config = MsdLunConfig::disk(self.drive_path.clone(), false);
let gadget_path = PathBuf::from(GADGET_PATH); let gadget_path = self.active_gadget_path().await?;
if let Some(ref msd) = *self.msd_function.read().await { if let Some(ref msd) = *self.msd_function.read().await {
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await { if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
let error_msg = format!("Failed to configure LUN: {}", e); let error_msg = format!("Failed to configure LUN: {}", e);
@@ -314,12 +295,7 @@ impl MsdController {
self.monitor.report_recovered().await; self.monitor.report_recovered().await;
} }
// Publish event self.mark_device_info_dirty().await;
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
mode: MsdMode::Drive,
connected: true,
})
.await;
Ok(()) Ok(())
} }
@@ -336,7 +312,7 @@ impl MsdController {
return Ok(()); return Ok(());
} }
let gadget_path = PathBuf::from(GADGET_PATH); let gadget_path = self.active_gadget_path().await?;
if let Some(ref msd) = *self.msd_function.read().await { if let Some(ref msd) = *self.msd_function.read().await {
msd.disconnect_lun_async(&gadget_path, 0).await?; msd.disconnect_lun_async(&gadget_path, 0).await?;
} }
@@ -351,15 +327,7 @@ impl MsdController {
drop(state); drop(state);
drop(_op_guard); drop(_op_guard);
// Publish events self.mark_device_info_dirty().await;
self.publish_event(crate::events::SystemEvent::MsdImageUnmounted)
.await;
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
mode: MsdMode::None,
connected: false,
})
.await;
Ok(()) Ok(())
} }
@@ -543,6 +511,13 @@ impl MsdController {
downloads.keys().cloned().collect() downloads.keys().cloned().collect()
} }
async fn active_gadget_path(&self) -> Result<PathBuf> {
self.otg_service
.gadget_path()
.await
.ok_or_else(|| AppError::Internal("OTG gadget path is not available".to_string()))
}
/// Shutdown the controller /// Shutdown the controller
pub async fn shutdown(&self) -> Result<()> { pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down MSD controller"); info!("Shutting down MSD controller");
@@ -552,11 +527,7 @@ impl MsdController {
warn!("Error disconnecting during shutdown: {}", e); warn!("Error disconnecting during shutdown: {}", e);
} }
// 2. Notify OtgService to disable MSD // 2. Clear local state
info!("Disabling MSD function in OtgService");
self.otg_service.disable_msd().await?;
// 3. Clear local state
*self.msd_function.write().await = None; *self.msd_function.write().await = None;
let mut state = self.state.write().await; let mut state = self.state.write().await;

View File

@@ -3,15 +3,13 @@
//! This module provides health monitoring for MSD operations, including: //! This module provides health monitoring for MSD operations, including:
//! - ConfigFS operation error tracking //! - ConfigFS operation error tracking
//! - Image mount/unmount error tracking //! - Image mount/unmount error tracking
//! - Error notification //! - Error state tracking
//! - Log throttling to prevent log flooding //! - Log throttling to prevent log flooding
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::events::{EventBus, SystemEvent};
use crate::utils::LogThrottler; use crate::utils::LogThrottler;
/// MSD health status /// MSD health status
@@ -46,21 +44,12 @@ impl Default for MsdMonitorConfig {
/// MSD health monitor /// MSD health monitor
/// ///
/// Monitors MSD operation health and manages error notifications. /// Monitors MSD operation health and manages error state.
/// Publishes WebSocket events when operation status changes.
pub struct MsdHealthMonitor { pub struct MsdHealthMonitor {
/// Current health status /// Current health status
status: RwLock<MsdHealthStatus>, status: RwLock<MsdHealthStatus>,
/// Event bus for notifications
events: RwLock<Option<Arc<EventBus>>>,
/// Log throttler to prevent log flooding /// Log throttler to prevent log flooding
throttler: LogThrottler, throttler: LogThrottler,
/// Configuration
#[allow(dead_code)]
config: MsdMonitorConfig,
/// Whether monitoring is active (reserved for future use)
#[allow(dead_code)]
running: AtomicBool,
/// Error count (for tracking) /// Error count (for tracking)
error_count: AtomicU32, error_count: AtomicU32,
/// Last error code (for change detection) /// Last error code (for change detection)
@@ -73,10 +62,7 @@ impl MsdHealthMonitor {
let throttle_secs = config.log_throttle_secs; let throttle_secs = config.log_throttle_secs;
Self { Self {
status: RwLock::new(MsdHealthStatus::Healthy), status: RwLock::new(MsdHealthStatus::Healthy),
events: RwLock::new(None),
throttler: LogThrottler::with_secs(throttle_secs), throttler: LogThrottler::with_secs(throttle_secs),
config,
running: AtomicBool::new(false),
error_count: AtomicU32::new(0), error_count: AtomicU32::new(0),
last_error_code: RwLock::new(None), last_error_code: RwLock::new(None),
} }
@@ -87,17 +73,12 @@ impl MsdHealthMonitor {
Self::new(MsdMonitorConfig::default()) Self::new(MsdMonitorConfig::default())
} }
/// Set the event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
/// Report an error from MSD operations /// Report an error from MSD operations
/// ///
/// This method is called when an MSD operation fails. It: /// This method is called when an MSD operation fails. It:
/// 1. Updates the health status /// 1. Updates the health status
/// 2. Logs the error (with throttling) /// 2. Logs the error (with throttling)
/// 3. Publishes a WebSocket event if the error is new or changed /// 3. Updates in-memory error state
/// ///
/// # Arguments /// # Arguments
/// ///
@@ -129,22 +110,12 @@ impl MsdHealthMonitor {
reason: reason.to_string(), reason: reason.to_string(),
error_code: error_code.to_string(), error_code: error_code.to_string(),
}; };
// Publish event (only if error changed or first occurrence)
if error_changed || count == 1 {
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::MsdError {
reason: reason.to_string(),
error_code: error_code.to_string(),
});
}
}
} }
/// Report that the MSD has recovered from error /// Report that the MSD has recovered from error
/// ///
/// This method is called when an MSD operation succeeds after errors. /// This method is called when an MSD operation succeeds after errors.
/// It resets the error state and publishes a recovery event. /// It resets the error state.
pub async fn report_recovered(&self) { pub async fn report_recovered(&self) {
let prev_status = self.status.read().await.clone(); let prev_status = self.status.read().await.clone();
@@ -158,11 +129,6 @@ impl MsdHealthMonitor {
self.throttler.clear_all(); self.throttler.clear_all();
*self.last_error_code.write().await = None; *self.last_error_code.write().await = None;
*self.status.write().await = MsdHealthStatus::Healthy; *self.status.write().await = MsdHealthStatus::Healthy;
// Publish recovery event
if let Some(ref events) = *self.events.read().await {
events.publish(SystemEvent::MsdRecovered);
}
} }
} }

View File

@@ -3,6 +3,7 @@
use std::fs::{self, File, OpenOptions}; use std::fs::{self, File, OpenOptions};
use std::io::Write; use std::io::Write;
use std::path::Path; use std::path::Path;
use std::process::Command;
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
@@ -29,6 +30,42 @@ pub fn is_configfs_available() -> bool {
Path::new(CONFIGFS_PATH).exists() Path::new(CONFIGFS_PATH).exists()
} }
/// Ensure libcomposite support is available for USB gadget operations.
///
/// This is a best-effort runtime fallback for systems where `libcomposite`
/// is built as a module and not loaded yet. It does not try to mount configfs;
/// mounting remains an explicit system responsibility.
pub fn ensure_libcomposite_loaded() -> Result<()> {
if is_configfs_available() {
return Ok(());
}
if !Path::new("/sys/module/libcomposite").exists() {
let status = Command::new("modprobe")
.arg("libcomposite")
.status()
.map_err(|e| {
AppError::Internal(format!("Failed to run modprobe libcomposite: {}", e))
})?;
if !status.success() {
return Err(AppError::Internal(format!(
"modprobe libcomposite failed with status {}",
status
)));
}
}
if is_configfs_available() {
Ok(())
} else {
Err(AppError::Internal(
"libcomposite is not available after modprobe; check configfs mount and kernel support"
.to_string(),
))
}
}
/// Find available UDC (USB Device Controller) /// Find available UDC (USB Device Controller)
pub fn find_udc() -> Option<String> { pub fn find_udc() -> Option<String> {
let udc_path = Path::new("/sys/class/udc"); let udc_path = Path::new("/sys/class/udc");

View File

@@ -7,14 +7,15 @@ use super::configfs::{
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file, create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
}; };
use super::function::{FunctionMeta, GadgetFunction}; use super::function::{FunctionMeta, GadgetFunction};
use super::report_desc::{CONSUMER_CONTROL, KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE}; use super::report_desc::{
CONSUMER_CONTROL, KEYBOARD, KEYBOARD_WITH_LED, MOUSE_ABSOLUTE, MOUSE_RELATIVE,
};
use crate::error::Result; use crate::error::Result;
/// HID function type /// HID function type
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum HidFunctionType { pub enum HidFunctionType {
/// Keyboard (no LED feedback) /// Keyboard
/// Uses 1 endpoint: IN
Keyboard, Keyboard,
/// Relative mouse (traditional mouse movement) /// Relative mouse (traditional mouse movement)
/// Uses 1 endpoint: IN /// Uses 1 endpoint: IN
@@ -28,7 +29,7 @@ pub enum HidFunctionType {
} }
impl HidFunctionType { impl HidFunctionType {
/// Get endpoints required for this function type /// Get the base endpoint cost for this function type.
pub fn endpoints(&self) -> u8 { pub fn endpoints(&self) -> u8 {
match self { match self {
HidFunctionType::Keyboard => 1, HidFunctionType::Keyboard => 1,
@@ -59,7 +60,7 @@ impl HidFunctionType {
} }
/// Get report length in bytes /// Get report length in bytes
pub fn report_length(&self) -> u8 { pub fn report_length(&self, _keyboard_leds: bool) -> u8 {
match self { match self {
HidFunctionType::Keyboard => 8, HidFunctionType::Keyboard => 8,
HidFunctionType::MouseRelative => 4, HidFunctionType::MouseRelative => 4,
@@ -69,9 +70,15 @@ impl HidFunctionType {
} }
/// Get report descriptor /// Get report descriptor
pub fn report_desc(&self) -> &'static [u8] { pub fn report_desc(&self, keyboard_leds: bool) -> &'static [u8] {
match self { match self {
HidFunctionType::Keyboard => KEYBOARD, HidFunctionType::Keyboard => {
if keyboard_leds {
KEYBOARD_WITH_LED
} else {
KEYBOARD
}
}
HidFunctionType::MouseRelative => MOUSE_RELATIVE, HidFunctionType::MouseRelative => MOUSE_RELATIVE,
HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE, HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE,
HidFunctionType::ConsumerControl => CONSUMER_CONTROL, HidFunctionType::ConsumerControl => CONSUMER_CONTROL,
@@ -98,15 +105,18 @@ pub struct HidFunction {
func_type: HidFunctionType, func_type: HidFunctionType,
/// Cached function name (avoids repeated allocation) /// Cached function name (avoids repeated allocation)
name: String, name: String,
/// Whether keyboard LED/status feedback is enabled.
keyboard_leds: bool,
} }
impl HidFunction { impl HidFunction {
/// Create a keyboard function /// Create a keyboard function
pub fn keyboard(instance: u8) -> Self { pub fn keyboard(instance: u8, keyboard_leds: bool) -> Self {
Self { Self {
instance, instance,
func_type: HidFunctionType::Keyboard, func_type: HidFunctionType::Keyboard,
name: format!("hid.usb{}", instance), name: format!("hid.usb{}", instance),
keyboard_leds,
} }
} }
@@ -116,6 +126,7 @@ impl HidFunction {
instance, instance,
func_type: HidFunctionType::MouseRelative, func_type: HidFunctionType::MouseRelative,
name: format!("hid.usb{}", instance), name: format!("hid.usb{}", instance),
keyboard_leds: false,
} }
} }
@@ -125,6 +136,7 @@ impl HidFunction {
instance, instance,
func_type: HidFunctionType::MouseAbsolute, func_type: HidFunctionType::MouseAbsolute,
name: format!("hid.usb{}", instance), name: format!("hid.usb{}", instance),
keyboard_leds: false,
} }
} }
@@ -134,6 +146,7 @@ impl HidFunction {
instance, instance,
func_type: HidFunctionType::ConsumerControl, func_type: HidFunctionType::ConsumerControl,
name: format!("hid.usb{}", instance), name: format!("hid.usb{}", instance),
keyboard_leds: false,
} }
} }
@@ -181,11 +194,14 @@ impl GadgetFunction for HidFunction {
)?; )?;
write_file( write_file(
&func_path.join("report_length"), &func_path.join("report_length"),
&self.func_type.report_length().to_string(), &self.func_type.report_length(self.keyboard_leds).to_string(),
)?; )?;
// Write report descriptor // Write report descriptor
write_bytes(&func_path.join("report_desc"), self.func_type.report_desc())?; write_bytes(
&func_path.join("report_desc"),
self.func_type.report_desc(self.keyboard_leds),
)?;
debug!( debug!(
"Created HID function: {} at {}", "Created HID function: {} at {}",
@@ -232,14 +248,15 @@ mod tests {
assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1); assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1);
assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1); assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1);
assert_eq!(HidFunctionType::Keyboard.report_length(), 8); assert_eq!(HidFunctionType::Keyboard.report_length(false), 8);
assert_eq!(HidFunctionType::MouseRelative.report_length(), 4); assert_eq!(HidFunctionType::Keyboard.report_length(true), 8);
assert_eq!(HidFunctionType::MouseAbsolute.report_length(), 6); assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4);
assert_eq!(HidFunctionType::MouseAbsolute.report_length(false), 6);
} }
#[test] #[test]
fn test_hid_function_names() { fn test_hid_function_names() {
let kb = HidFunction::keyboard(0); let kb = HidFunction::keyboard(0, false);
assert_eq!(kb.name(), "hid.usb0"); assert_eq!(kb.name(), "hid.usb0");
assert_eq!(kb.device_path(), PathBuf::from("/dev/hidg0")); assert_eq!(kb.device_path(), PathBuf::from("/dev/hidg0"));

View File

@@ -19,7 +19,7 @@ use crate::error::{AppError, Result};
const REBIND_DELAY_MS: u64 = 300; const REBIND_DELAY_MS: u64 = 300;
/// USB Gadget device descriptor configuration /// USB Gadget device descriptor configuration
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct GadgetDescriptor { pub struct GadgetDescriptor {
pub vendor_id: u16, pub vendor_id: u16,
pub product_id: u16, pub product_id: u16,
@@ -131,8 +131,8 @@ impl OtgGadgetManager {
/// Add keyboard function /// Add keyboard function
/// Returns the expected device path (e.g., /dev/hidg0) /// Returns the expected device path (e.g., /dev/hidg0)
pub fn add_keyboard(&mut self) -> Result<PathBuf> { pub fn add_keyboard(&mut self, keyboard_leds: bool) -> Result<PathBuf> {
let func = HidFunction::keyboard(self.hid_instance); let func = HidFunction::keyboard(self.hid_instance, keyboard_leds);
let device_path = func.device_path(); let device_path = func.device_path();
self.add_function(Box::new(func))?; self.add_function(Box::new(func))?;
self.hid_instance += 1; self.hid_instance += 1;
@@ -245,12 +245,8 @@ impl OtgGadgetManager {
Ok(()) Ok(())
} }
/// Bind gadget to UDC /// Bind gadget to a specific UDC
pub fn bind(&mut self) -> Result<()> { pub fn bind(&mut self, udc: &str) -> Result<()> {
let udc = Self::find_udc().ok_or_else(|| {
AppError::Internal("No USB Device Controller (UDC) found".to_string())
})?;
// Recreate config symlinks before binding to avoid kernel gadget issues after rebind // Recreate config symlinks before binding to avoid kernel gadget issues after rebind
if let Err(e) = self.recreate_config_links() { if let Err(e) = self.recreate_config_links() {
warn!("Failed to recreate gadget config links before bind: {}", e); warn!("Failed to recreate gadget config links before bind: {}", e);
@@ -258,7 +254,7 @@ impl OtgGadgetManager {
info!("Binding gadget to UDC: {}", udc); info!("Binding gadget to UDC: {}", udc);
write_file(&self.gadget_path.join("UDC"), &udc)?; write_file(&self.gadget_path.join("UDC"), &udc)?;
self.bound_udc = Some(udc); self.bound_udc = Some(udc.to_string());
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS)); std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
Ok(()) Ok(())
@@ -504,7 +500,7 @@ mod tests {
let mut manager = OtgGadgetManager::with_config("test", 8); let mut manager = OtgGadgetManager::with_config("test", 8);
// Keyboard uses 1 endpoint // Keyboard uses 1 endpoint
let _ = manager.add_keyboard(); let _ = manager.add_keyboard(false);
assert_eq!(manager.endpoint_allocator.used(), 1); assert_eq!(manager.endpoint_allocator.used(), 1);
// Mouse uses 1 endpoint each // Mouse uses 1 endpoint each

View File

@@ -32,4 +32,4 @@ pub use hid::{HidFunction, HidFunctionType};
pub use manager::{wait_for_hid_devices, OtgGadgetManager}; pub use manager::{wait_for_hid_devices, OtgGadgetManager};
pub use msd::{MsdFunction, MsdLunConfig}; pub use msd::{MsdFunction, MsdLunConfig};
pub use report_desc::{KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE}; pub use report_desc::{KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
pub use service::{HidDevicePaths, OtgService, OtgServiceState}; pub use service::{HidDevicePaths, OtgDesiredState, OtgService, OtgServiceState};

View File

@@ -1,6 +1,6 @@
//! HID Report Descriptors //! HID Report Descriptors
/// Keyboard HID Report Descriptor (no LED output - saves 1 endpoint) /// Keyboard HID Report Descriptor (no LED output)
/// Report format (8 bytes input): /// Report format (8 bytes input):
/// [0] Modifier keys (8 bits) /// [0] Modifier keys (8 bits)
/// [1] Reserved /// [1] Reserved
@@ -34,6 +34,53 @@ pub const KEYBOARD: &[u8] = &[
0xC0, // End Collection 0xC0, // End Collection
]; ];
/// Keyboard HID Report Descriptor with LED output support.
/// Input report format (8 bytes):
/// [0] Modifier keys (8 bits)
/// [1] Reserved
/// [2-7] Key codes (6 keys)
/// Output report format (1 byte):
/// [0] Num Lock / Caps Lock / Scroll Lock / Compose / Kana
pub const KEYBOARD_WITH_LED: &[u8] = &[
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
// Modifier keys input (8 bits)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (224) - Left Control
0x29, 0xE7, // Usage Maximum (231) - Right GUI
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data, Variable, Absolute) - Modifier byte
// Reserved byte
0x95, 0x01, // Report Count (1)
0x75, 0x08, // Report Size (8)
0x81, 0x01, // Input (Constant) - Reserved byte
// LED output bits
0x95, 0x05, // Report Count (5)
0x75, 0x01, // Report Size (1)
0x05, 0x08, // Usage Page (LEDs)
0x19, 0x01, // Usage Minimum (1)
0x29, 0x05, // Usage Maximum (5)
0x91, 0x02, // Output (Data, Variable, Absolute)
// LED padding
0x95, 0x01, // Report Count (1)
0x75, 0x03, // Report Size (3)
0x91, 0x01, // Output (Constant)
// Key array (6 bytes)
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0)
0x2A, 0xFF, 0x00, // Usage Maximum (255)
0x81, 0x00, // Input (Data, Array) - Key array (6 keys)
0xC0, // End Collection
];
/// Relative Mouse HID Report Descriptor (4 bytes report) /// Relative Mouse HID Report Descriptor (4 bytes report)
/// Report format: /// Report format:
/// [0] Buttons (5 bits) + padding (3 bits) /// [0] Buttons (5 bits) + padding (3 bits)
@@ -155,6 +202,7 @@ mod tests {
#[test] #[test]
fn test_report_descriptor_sizes() { fn test_report_descriptor_sizes() {
assert!(!KEYBOARD.is_empty()); assert!(!KEYBOARD.is_empty());
assert!(!KEYBOARD_WITH_LED.is_empty());
assert!(!MOUSE_RELATIVE.is_empty()); assert!(!MOUSE_RELATIVE.is_empty());
assert!(!MOUSE_ABSOLUTE.is_empty()); assert!(!MOUSE_ABSOLUTE.is_empty());
assert!(!CONSUMER_CONTROL.is_empty()); assert!(!CONSUMER_CONTROL.is_empty());

View File

@@ -1,39 +1,18 @@
//! OTG Service - unified gadget lifecycle management //! OTG Service - unified gadget lifecycle management
//! //!
//! This module provides centralized management for USB OTG gadget functions. //! This module provides centralized management for USB OTG gadget functions.
//! It solves the ownership problem where both HID and MSD need access to the //! It is the single owner of the USB gadget desired state and reconciles
//! same USB gadget but should be independently configurable. //! ConfigFS to match that state.
//!
//! Architecture:
//! ```text
//! ┌─────────────────────────┐
//! │ OtgService │
//! │ ┌───────────────────┐ │
//! │ │ OtgGadgetManager │ │
//! │ └───────────────────┘ │
//! │ ↓ ↓ │
//! │ ┌─────┐ ┌─────┐ │
//! │ │ HID │ │ MSD │ │
//! │ └─────┘ └─────┘ │
//! └─────────────────────────┘
//! ↑ ↑
//! HidController MsdController
//! ```
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU8, Ordering};
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager}; use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
use super::msd::MsdFunction; use super::msd::MsdFunction;
use crate::config::{OtgDescriptorConfig, OtgHidFunctions}; use crate::config::{HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
/// Bitflags for requested functions (lock-free)
const FLAG_HID: u8 = 0b01;
const FLAG_MSD: u8 = 0b10;
/// HID device paths /// HID device paths
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct HidDevicePaths { pub struct HidDevicePaths {
@@ -41,6 +20,8 @@ pub struct HidDevicePaths {
pub mouse_relative: Option<PathBuf>, pub mouse_relative: Option<PathBuf>,
pub mouse_absolute: Option<PathBuf>, pub mouse_absolute: Option<PathBuf>,
pub consumer: Option<PathBuf>, pub consumer: Option<PathBuf>,
pub udc: Option<String>,
pub keyboard_leds_enabled: bool,
} }
impl HidDevicePaths { impl HidDevicePaths {
@@ -62,6 +43,59 @@ impl HidDevicePaths {
} }
} }
/// Desired OTG gadget state derived from configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OtgDesiredState {
pub udc: Option<String>,
pub descriptor: GadgetDescriptor,
pub hid_functions: Option<OtgHidFunctions>,
pub keyboard_leds: bool,
pub msd_enabled: bool,
pub max_endpoints: u8,
}
impl Default for OtgDesiredState {
fn default() -> Self {
Self {
udc: None,
descriptor: GadgetDescriptor::default(),
hid_functions: None,
keyboard_leds: false,
msd_enabled: false,
max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS,
}
}
}
impl OtgDesiredState {
pub fn from_config(hid: &HidConfig, msd: &MsdConfig) -> Result<Self> {
let hid_functions = if hid.backend == HidBackend::Otg {
let functions = hid.constrained_otg_functions();
Some(functions)
} else {
None
};
hid.validate_otg_endpoint_budget(msd.enabled)?;
Ok(Self {
udc: hid.resolved_otg_udc(),
descriptor: GadgetDescriptor::from(&hid.otg_descriptor),
hid_functions,
keyboard_leds: hid.effective_otg_keyboard_leds(),
msd_enabled: msd.enabled,
max_endpoints: hid
.resolved_otg_endpoint_limit()
.unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS),
})
}
#[inline]
pub fn hid_enabled(&self) -> bool {
self.hid_functions.is_some()
}
}
/// OTG Service state /// OTG Service state
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct OtgServiceState { pub struct OtgServiceState {
@@ -71,19 +105,23 @@ pub struct OtgServiceState {
pub hid_enabled: bool, pub hid_enabled: bool,
/// Whether MSD function is enabled /// Whether MSD function is enabled
pub msd_enabled: bool, pub msd_enabled: bool,
/// Bound UDC name
pub configured_udc: Option<String>,
/// HID device paths (set after gadget setup) /// HID device paths (set after gadget setup)
pub hid_paths: Option<HidDevicePaths>, pub hid_paths: Option<HidDevicePaths>,
/// HID function selection (set after gadget setup) /// HID function selection (set after gadget setup)
pub hid_functions: Option<OtgHidFunctions>, pub hid_functions: Option<OtgHidFunctions>,
/// Whether keyboard LED/status feedback is enabled.
pub keyboard_leds_enabled: bool,
/// Applied endpoint budget.
pub max_endpoints: u8,
/// Applied descriptor configuration
pub descriptor: Option<GadgetDescriptor>,
/// Error message if setup failed /// Error message if setup failed
pub error: Option<String>, pub error: Option<String>,
} }
/// OTG Service - unified gadget lifecycle management /// OTG Service - unified gadget lifecycle management
///
/// This service owns the OtgGadgetManager and provides a high-level interface
/// for enabling/disabling HID and MSD functions. It ensures proper coordination
/// between the two subsystems and handles gadget lifecycle management.
pub struct OtgService { pub struct OtgService {
/// The underlying gadget manager /// The underlying gadget manager
manager: Mutex<Option<OtgGadgetManager>>, manager: Mutex<Option<OtgGadgetManager>>,
@@ -91,12 +129,8 @@ pub struct OtgService {
state: RwLock<OtgServiceState>, state: RwLock<OtgServiceState>,
/// MSD function handle (for runtime LUN configuration) /// MSD function handle (for runtime LUN configuration)
msd_function: RwLock<Option<MsdFunction>>, msd_function: RwLock<Option<MsdFunction>>,
/// Requested functions flags (atomic, lock-free read/write) /// Desired OTG state
requested_flags: AtomicU8, desired: RwLock<OtgDesiredState>,
/// Requested HID function set
hid_functions: RwLock<OtgHidFunctions>,
/// Current descriptor configuration
current_descriptor: RwLock<GadgetDescriptor>,
} }
impl OtgService { impl OtgService {
@@ -106,41 +140,7 @@ impl OtgService {
manager: Mutex::new(None), manager: Mutex::new(None),
state: RwLock::new(OtgServiceState::default()), state: RwLock::new(OtgServiceState::default()),
msd_function: RwLock::new(None), msd_function: RwLock::new(None),
requested_flags: AtomicU8::new(0), desired: RwLock::new(OtgDesiredState::default()),
hid_functions: RwLock::new(OtgHidFunctions::default()),
current_descriptor: RwLock::new(GadgetDescriptor::default()),
}
}
/// Check if HID is requested (lock-free)
#[inline]
fn is_hid_requested(&self) -> bool {
self.requested_flags.load(Ordering::Acquire) & FLAG_HID != 0
}
/// Check if MSD is requested (lock-free)
#[inline]
fn is_msd_requested(&self) -> bool {
self.requested_flags.load(Ordering::Acquire) & FLAG_MSD != 0
}
/// Set HID requested flag (lock-free)
#[inline]
fn set_hid_requested(&self, requested: bool) {
if requested {
self.requested_flags.fetch_or(FLAG_HID, Ordering::Release);
} else {
self.requested_flags.fetch_and(!FLAG_HID, Ordering::Release);
}
}
/// Set MSD requested flag (lock-free)
#[inline]
fn set_msd_requested(&self, requested: bool) {
if requested {
self.requested_flags.fetch_or(FLAG_MSD, Ordering::Release);
} else {
self.requested_flags.fetch_and(!FLAG_MSD, Ordering::Release);
} }
} }
@@ -180,258 +180,119 @@ impl OtgService {
self.state.read().await.hid_paths.clone() self.state.read().await.hid_paths.clone()
} }
/// Get current HID function selection
pub async fn hid_functions(&self) -> OtgHidFunctions {
self.hid_functions.read().await.clone()
}
/// Update HID function selection
pub async fn update_hid_functions(&self, functions: OtgHidFunctions) -> Result<()> {
if functions.is_empty() {
return Err(AppError::BadRequest(
"OTG HID functions cannot be empty".to_string(),
));
}
{
let mut current = self.hid_functions.write().await;
if *current == functions {
return Ok(());
}
*current = functions;
}
// If HID is active, recreate gadget with new function set
if self.is_hid_requested() {
self.recreate_gadget().await?;
}
Ok(())
}
/// Get MSD function handle (for LUN configuration) /// Get MSD function handle (for LUN configuration)
pub async fn msd_function(&self) -> Option<MsdFunction> { pub async fn msd_function(&self) -> Option<MsdFunction> {
self.msd_function.read().await.clone() self.msd_function.read().await.clone()
} }
/// Enable HID functions /// Apply desired OTG state derived from the current application config.
/// pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> {
/// This will create the gadget if not already created, add HID functions, let desired = OtgDesiredState::from_config(hid, msd)?;
/// and bind the gadget to UDC. self.apply_desired_state(desired).await
pub async fn enable_hid(&self) -> Result<HidDevicePaths> { }
info!("Enabling HID functions via OtgService");
// Mark HID as requested (lock-free) /// Apply a fully materialized desired OTG state.
self.set_hid_requested(true); pub async fn apply_desired_state(&self, desired: OtgDesiredState) -> Result<()> {
// Check if already enabled and function set unchanged
let requested_functions = self.hid_functions.read().await.clone();
{ {
let state = self.state.read().await; let mut current = self.desired.write().await;
if state.hid_enabled && state.hid_functions.as_ref() == Some(&requested_functions) { *current = desired;
if let Some(ref paths) = state.hid_paths {
info!("HID already enabled, returning existing paths");
return Ok(paths.clone());
}
}
} }
// Recreate gadget with both HID and MSD if needed self.reconcile_gadget().await
self.recreate_gadget().await?;
// Get HID paths from state
let state = self.state.read().await;
state
.hid_paths
.clone()
.ok_or_else(|| AppError::Internal("HID paths not set after gadget setup".to_string()))
} }
/// Disable HID functions async fn reconcile_gadget(&self) -> Result<()> {
/// let desired = self.desired.read().await.clone();
/// This will unbind the gadget, remove HID functions, and optionally
/// recreate the gadget with only MSD if MSD is still enabled.
pub async fn disable_hid(&self) -> Result<()> {
info!("Disabling HID functions via OtgService");
// Mark HID as not requested (lock-free)
self.set_hid_requested(false);
// Check if HID is enabled
{
let state = self.state.read().await;
if !state.hid_enabled {
info!("HID already disabled");
return Ok(());
}
}
// Recreate gadget without HID (or destroy if MSD also disabled)
self.recreate_gadget().await
}
/// Enable MSD function
///
/// This will create the gadget if not already created, add MSD function,
/// and bind the gadget to UDC.
pub async fn enable_msd(&self) -> Result<MsdFunction> {
info!("Enabling MSD function via OtgService");
// Mark MSD as requested (lock-free)
self.set_msd_requested(true);
// Check if already enabled
{
let state = self.state.read().await;
if state.msd_enabled {
let msd = self.msd_function.read().await;
if let Some(ref func) = *msd {
info!("MSD already enabled, returning existing function");
return Ok(func.clone());
}
}
}
// Recreate gadget with both HID and MSD if needed
self.recreate_gadget().await?;
// Get MSD function
let msd = self.msd_function.read().await;
msd.clone().ok_or_else(|| {
AppError::Internal("MSD function not set after gadget setup".to_string())
})
}
/// Disable MSD function
///
/// This will unbind the gadget, remove MSD function, and optionally
/// recreate the gadget with only HID if HID is still enabled.
pub async fn disable_msd(&self) -> Result<()> {
info!("Disabling MSD function via OtgService");
// Mark MSD as not requested (lock-free)
self.set_msd_requested(false);
// Check if MSD is enabled
{
let state = self.state.read().await;
if !state.msd_enabled {
info!("MSD already disabled");
return Ok(());
}
}
// Recreate gadget without MSD (or destroy if HID also disabled)
self.recreate_gadget().await
}
/// Recreate the gadget with currently requested functions
///
/// This is called whenever the set of enabled functions changes.
/// It will:
/// 1. Check if recreation is needed (function set changed)
/// 2. If needed: cleanup existing gadget
/// 3. Create new gadget with requested functions
/// 4. Setup and bind
async fn recreate_gadget(&self) -> Result<()> {
// Read requested flags atomically (lock-free)
let hid_requested = self.is_hid_requested();
let msd_requested = self.is_msd_requested();
let hid_functions = if hid_requested {
self.hid_functions.read().await.clone()
} else {
OtgHidFunctions::default()
};
info!( info!(
"Recreating gadget with: HID={}, MSD={}", "Reconciling OTG gadget: HID={}, MSD={}, UDC={:?}",
hid_requested, msd_requested desired.hid_enabled(),
desired.msd_enabled,
desired.udc
); );
// Check if gadget already matches requested state
{ {
let state = self.state.read().await; let state = self.state.read().await;
let functions_match = if hid_requested {
state.hid_functions.as_ref() == Some(&hid_functions)
} else {
state.hid_functions.is_none()
};
if state.gadget_active if state.gadget_active
&& state.hid_enabled == hid_requested && state.hid_enabled == desired.hid_enabled()
&& state.msd_enabled == msd_requested && state.msd_enabled == desired.msd_enabled
&& functions_match && state.configured_udc == desired.udc
&& state.hid_functions == desired.hid_functions
&& state.keyboard_leds_enabled == desired.keyboard_leds
&& state.max_endpoints == desired.max_endpoints
&& state.descriptor.as_ref() == Some(&desired.descriptor)
{ {
info!("Gadget already has requested functions, skipping recreate"); info!("OTG gadget already matches desired state");
return Ok(()); return Ok(());
} }
} }
// Cleanup existing gadget
{ {
let mut manager = self.manager.lock().await; let mut manager = self.manager.lock().await;
if let Some(mut m) = manager.take() { if let Some(mut m) = manager.take() {
info!("Cleaning up existing gadget before recreate"); info!("Cleaning up existing gadget before OTG reconcile");
if let Err(e) = m.cleanup() { if let Err(e) = m.cleanup() {
warn!("Error cleaning up existing gadget: {}", e); warn!("Error cleaning up existing gadget: {}", e);
} }
} }
} }
// Clear MSD function
*self.msd_function.write().await = None; *self.msd_function.write().await = None;
// Update state to inactive
{ {
let mut state = self.state.write().await; let mut state = self.state.write().await;
state.gadget_active = false; state.gadget_active = false;
state.hid_enabled = false; state.hid_enabled = false;
state.msd_enabled = false; state.msd_enabled = false;
state.configured_udc = None;
state.hid_paths = None; state.hid_paths = None;
state.hid_functions = None; state.hid_functions = None;
state.keyboard_leds_enabled = false;
state.max_endpoints = super::endpoint::DEFAULT_MAX_ENDPOINTS;
state.descriptor = None;
state.error = None; state.error = None;
} }
// If nothing requested, we're done if !desired.hid_enabled() && !desired.msd_enabled {
if !hid_requested && !msd_requested { info!("OTG desired state is empty, gadget removed");
info!("No functions requested, gadget destroyed");
return Ok(()); return Ok(());
} }
// Check if OTG is available if let Err(e) = super::configfs::ensure_libcomposite_loaded() {
if !Self::is_available() { warn!("Failed to ensure libcomposite is available: {}", e);
let error = "OTG not available: ConfigFS not mounted or no UDC found".to_string(); }
let mut state = self.state.write().await;
state.error = Some(error.clone()); if !OtgGadgetManager::is_available() {
let error = "OTG not available: ConfigFS not mounted".to_string();
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
// Create new gadget manager with current descriptor let udc = desired.udc.clone().ok_or_else(|| {
let descriptor = self.current_descriptor.read().await.clone(); let error = "OTG not available: no UDC found".to_string();
AppError::Internal(error)
})?;
let mut manager = OtgGadgetManager::with_descriptor( let mut manager = OtgGadgetManager::with_descriptor(
super::configfs::DEFAULT_GADGET_NAME, super::configfs::DEFAULT_GADGET_NAME,
super::endpoint::DEFAULT_MAX_ENDPOINTS, desired.max_endpoints,
descriptor, desired.descriptor.clone(),
); );
let mut hid_paths = None; let mut hid_paths = None;
if let Some(hid_functions) = desired.hid_functions.clone() {
// Add HID functions if requested let mut paths = HidDevicePaths {
if hid_requested { udc: Some(udc.clone()),
if hid_functions.is_empty() { keyboard_leds_enabled: desired.keyboard_leds,
let error = "HID functions set is empty".to_string(); ..Default::default()
let mut state = self.state.write().await; };
state.error = Some(error.clone());
return Err(AppError::BadRequest(error));
}
let mut paths = HidDevicePaths::default();
if hid_functions.keyboard { if hid_functions.keyboard {
match manager.add_keyboard() { match manager.add_keyboard(desired.keyboard_leds) {
Ok(kb) => paths.keyboard = Some(kb), Ok(kb) => paths.keyboard = Some(kb),
Err(e) => { Err(e) => {
let error = format!("Failed to add keyboard HID function: {}", e); let error = format!("Failed to add keyboard HID function: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
} }
@@ -442,8 +303,7 @@ impl OtgService {
Ok(rel) => paths.mouse_relative = Some(rel), Ok(rel) => paths.mouse_relative = Some(rel),
Err(e) => { Err(e) => {
let error = format!("Failed to add relative mouse HID function: {}", e); let error = format!("Failed to add relative mouse HID function: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
} }
@@ -454,8 +314,7 @@ impl OtgService {
Ok(abs) => paths.mouse_absolute = Some(abs), Ok(abs) => paths.mouse_absolute = Some(abs),
Err(e) => { Err(e) => {
let error = format!("Failed to add absolute mouse HID function: {}", e); let error = format!("Failed to add absolute mouse HID function: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
} }
@@ -466,8 +325,7 @@ impl OtgService {
Ok(consumer) => paths.consumer = Some(consumer), Ok(consumer) => paths.consumer = Some(consumer),
Err(e) => { Err(e) => {
let error = format!("Failed to add consumer HID function: {}", e); let error = format!("Failed to add consumer HID function: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
} }
@@ -477,8 +335,7 @@ impl OtgService {
debug!("HID functions added to gadget"); debug!("HID functions added to gadget");
} }
// Add MSD function if requested let msd_func = if desired.msd_enabled {
let msd_func = if msd_requested {
match manager.add_msd() { match manager.add_msd() {
Ok(func) => { Ok(func) => {
debug!("MSD function added to gadget"); debug!("MSD function added to gadget");
@@ -486,8 +343,7 @@ impl OtgService {
} }
Err(e) => { Err(e) => {
let error = format!("Failed to add MSD function: {}", e); let error = format!("Failed to add MSD function: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
} }
@@ -495,25 +351,19 @@ impl OtgService {
None None
}; };
// Setup gadget
if let Err(e) = manager.setup() { if let Err(e) = manager.setup() {
let error = format!("Failed to setup gadget: {}", e); let error = format!("Failed to setup gadget: {}", e);
let mut state = self.state.write().await; self.state.write().await.error = Some(error.clone());
state.error = Some(error.clone());
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
// Bind to UDC if let Err(e) = manager.bind(&udc) {
if let Err(e) = manager.bind() { let error = format!("Failed to bind gadget to UDC {}: {}", udc, e);
let error = format!("Failed to bind gadget to UDC: {}", e); self.state.write().await.error = Some(error.clone());
let mut state = self.state.write().await;
state.error = Some(error.clone());
// Cleanup on failure
let _ = manager.cleanup(); let _ = manager.cleanup();
return Err(AppError::Internal(error)); return Err(AppError::Internal(error));
} }
// Wait for HID devices to appear
if let Some(ref paths) = hid_paths { if let Some(ref paths) = hid_paths {
let device_paths = paths.existing_paths(); let device_paths = paths.existing_paths();
if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await { if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await {
@@ -521,103 +371,36 @@ impl OtgService {
} }
} }
// Store manager and update state
{
*self.manager.lock().await = Some(manager); *self.manager.lock().await = Some(manager);
}
{
*self.msd_function.write().await = msd_func; *self.msd_function.write().await = msd_func;
}
{ {
let mut state = self.state.write().await; let mut state = self.state.write().await;
state.gadget_active = true; state.gadget_active = true;
state.hid_enabled = hid_requested; state.hid_enabled = desired.hid_enabled();
state.msd_enabled = msd_requested; state.msd_enabled = desired.msd_enabled;
state.configured_udc = Some(udc);
state.hid_paths = hid_paths; state.hid_paths = hid_paths;
state.hid_functions = if hid_requested { state.hid_functions = desired.hid_functions;
Some(hid_functions) state.keyboard_leds_enabled = desired.keyboard_leds;
} else { state.max_endpoints = desired.max_endpoints;
None state.descriptor = Some(desired.descriptor);
};
state.error = None; state.error = None;
} }
info!("Gadget created successfully"); info!("OTG gadget reconciled successfully");
Ok(()) Ok(())
} }
/// Update the descriptor configuration
///
/// This updates the stored descriptor and triggers a gadget recreation
/// if the gadget is currently active.
pub async fn update_descriptor(&self, config: &OtgDescriptorConfig) -> Result<()> {
let new_descriptor = GadgetDescriptor {
vendor_id: config.vendor_id,
product_id: config.product_id,
device_version: super::configfs::DEFAULT_USB_BCD_DEVICE,
manufacturer: config.manufacturer.clone(),
product: config.product.clone(),
serial_number: config
.serial_number
.clone()
.unwrap_or_else(|| "0123456789".to_string()),
};
// Update stored descriptor
*self.current_descriptor.write().await = new_descriptor;
// If gadget is active, recreate it with new descriptor
let state = self.state.read().await;
if state.gadget_active {
drop(state); // Release read lock before calling recreate
info!("Descriptor changed, recreating gadget");
self.force_recreate_gadget().await?;
}
Ok(())
}
/// Force recreate the gadget (used when descriptor changes)
async fn force_recreate_gadget(&self) -> Result<()> {
// Cleanup existing gadget
{
let mut manager = self.manager.lock().await;
if let Some(mut m) = manager.take() {
info!("Cleaning up existing gadget for descriptor change");
if let Err(e) = m.cleanup() {
warn!("Error cleaning up existing gadget: {}", e);
}
}
}
// Clear MSD function
*self.msd_function.write().await = None;
// Update state to inactive
{
let mut state = self.state.write().await;
state.gadget_active = false;
state.hid_enabled = false;
state.msd_enabled = false;
state.hid_paths = None;
state.hid_functions = None;
state.error = None;
}
// Recreate with current requested functions
self.recreate_gadget().await
}
/// Shutdown the OTG service and cleanup all resources /// Shutdown the OTG service and cleanup all resources
pub async fn shutdown(&self) -> Result<()> { pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down OTG service"); info!("Shutting down OTG service");
// Mark nothing as requested (lock-free) {
self.requested_flags.store(0, Ordering::Release); let mut desired = self.desired.write().await;
*desired = OtgDesiredState::default();
}
// Cleanup gadget
let mut manager = self.manager.lock().await; let mut manager = self.manager.lock().await;
if let Some(mut m) = manager.take() { if let Some(mut m) = manager.take() {
if let Err(e) = m.cleanup() { if let Err(e) = m.cleanup() {
@@ -625,7 +408,6 @@ impl OtgService {
} }
} }
// Clear state
*self.msd_function.write().await = None; *self.msd_function.write().await = None;
{ {
let mut state = self.state.write().await; let mut state = self.state.write().await;
@@ -645,11 +427,26 @@ impl Default for OtgService {
impl Drop for OtgService { impl Drop for OtgService {
fn drop(&mut self) { fn drop(&mut self) {
// Gadget cleanup is handled by OtgGadgetManager's Drop
debug!("OtgService dropping"); debug!("OtgService dropping");
} }
} }
impl From<&OtgDescriptorConfig> for GadgetDescriptor {
fn from(config: &OtgDescriptorConfig) -> Self {
Self {
vendor_id: config.vendor_id,
product_id: config.product_id,
device_version: super::configfs::DEFAULT_USB_BCD_DEVICE,
manufacturer: config.manufacturer.clone(),
product: config.product.clone(),
serial_number: config
.serial_number
.clone()
.unwrap_or_else(|| "0123456789".to_string()),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -657,8 +454,7 @@ mod tests {
#[test] #[test]
fn test_service_creation() { fn test_service_creation() {
let _service = OtgService::new(); let _service = OtgService::new();
// Just test that creation doesn't panic let _ = OtgService::is_available();
let _ = OtgService::is_available(); // Depends on environment
} }
#[tokio::test] #[tokio::test]

View File

@@ -22,7 +22,7 @@ use tokio::sync::{broadcast, mpsc, Mutex};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use crate::audio::AudioController; use crate::audio::AudioController;
use crate::hid::{HidController, KeyEventType, KeyboardEvent, KeyboardModifiers}; use crate::hid::{CanonicalKey, HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
use crate::video::codec_constraints::{ use crate::video::codec_constraints::{
encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec, encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec,
}; };
@@ -652,22 +652,22 @@ impl Connection {
// H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.) // H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.)
// and most RustDesk clients support H264 hardware decoding // and most RustDesk clients support H264 hardware decoding
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264) if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264)
&& registry.is_format_available(VideoEncoderType::H264, false) && registry.is_codec_available(VideoEncoderType::H264)
{ {
return VideoEncoderType::H264; return VideoEncoderType::H264;
} }
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265) if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265)
&& registry.is_format_available(VideoEncoderType::H265, false) && registry.is_codec_available(VideoEncoderType::H265)
{ {
return VideoEncoderType::H265; return VideoEncoderType::H265;
} }
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8) if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8)
&& registry.is_format_available(VideoEncoderType::VP8, false) && registry.is_codec_available(VideoEncoderType::VP8)
{ {
return VideoEncoderType::VP8; return VideoEncoderType::VP8;
} }
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9) if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9)
&& registry.is_format_available(VideoEncoderType::VP9, false) && registry.is_codec_available(VideoEncoderType::VP9)
{ {
return VideoEncoderType::VP9; return VideoEncoderType::VP9;
} }
@@ -784,7 +784,7 @@ impl Connection {
} }
let registry = EncoderRegistry::global(); let registry = EncoderRegistry::global();
if registry.is_format_available(new_codec, false) { if registry.is_codec_available(new_codec) {
info!( info!(
"Client requested codec switch: {:?} -> {:?}", "Client requested codec switch: {:?} -> {:?}",
self.negotiated_codec, new_codec self.negotiated_codec, new_codec
@@ -1121,16 +1121,16 @@ impl Connection {
// Check which encoders are available (include software fallback) // Check which encoders are available (include software fallback)
let h264_available = constraints let h264_available = constraints
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264) .is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264)
&& registry.is_format_available(VideoEncoderType::H264, false); && registry.is_codec_available(VideoEncoderType::H264);
let h265_available = constraints let h265_available = constraints
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265) .is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265)
&& registry.is_format_available(VideoEncoderType::H265, false); && registry.is_codec_available(VideoEncoderType::H265);
let vp8_available = constraints let vp8_available = constraints
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8) .is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8)
&& registry.is_format_available(VideoEncoderType::VP8, false); && registry.is_codec_available(VideoEncoderType::VP8);
let vp9_available = constraints let vp9_available = constraints
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9) .is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9)
&& registry.is_format_available(VideoEncoderType::VP9, false); && registry.is_codec_available(VideoEncoderType::VP9);
info!( info!(
"Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}", "Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}",
@@ -1328,15 +1328,13 @@ impl Connection {
); );
let caps_down = KeyboardEvent { let caps_down = KeyboardEvent {
event_type: KeyEventType::Down, event_type: KeyEventType::Down,
key: 0x39, // USB HID CapsLock key: CanonicalKey::CapsLock,
modifiers: KeyboardModifiers::default(), modifiers: KeyboardModifiers::default(),
is_usb_hid: true,
}; };
let caps_up = KeyboardEvent { let caps_up = KeyboardEvent {
event_type: KeyEventType::Up, event_type: KeyEventType::Up,
key: 0x39, key: CanonicalKey::CapsLock,
modifiers: KeyboardModifiers::default(), modifiers: KeyboardModifiers::default(),
is_usb_hid: true,
}; };
if let Err(e) = hid.send_keyboard(caps_down).await { if let Err(e) = hid.send_keyboard(caps_down).await {
warn!("Failed to send CapsLock down: {}", e); warn!("Failed to send CapsLock down: {}", e);
@@ -1351,7 +1349,7 @@ impl Connection {
if let Some(kb_event) = convert_key_event(ke) { if let Some(kb_event) = convert_key_event(ke) {
debug!( debug!(
"Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}", "Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}",
kb_event.key, kb_event.key.to_hid_usage(),
kb_event.event_type, kb_event.event_type,
kb_event.modifiers.to_hid_byte() kb_event.modifiers.to_hid_byte()
); );

View File

@@ -5,8 +5,8 @@
use super::protocol::hbb::message::key_event as ke_union; use super::protocol::hbb::message::key_event as ke_union;
use super::protocol::{ControlKey, KeyEvent, MouseEvent}; use super::protocol::{ControlKey, KeyEvent, MouseEvent};
use crate::hid::{ use crate::hid::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent as OneKvmMouseEvent, CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
MouseEventType, MouseEvent as OneKvmMouseEvent, MouseEventType,
}; };
use protobuf::Enum; use protobuf::Enum;
@@ -217,11 +217,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
// Handle control keys // Handle control keys
if let Some(ke_union::Union::ControlKey(ck)) = &event.union { if let Some(ke_union::Union::ControlKey(ck)) = &event.union {
if let Some(key) = control_key_to_hid(ck.value()) { if let Some(key) = control_key_to_hid(ck.value()) {
let key = CanonicalKey::from_hid_usage(key)?;
return Some(KeyboardEvent { return Some(KeyboardEvent {
event_type, event_type,
key, key,
modifiers, modifiers,
is_usb_hid: true, // Already converted to USB HID code
}); });
} }
} }
@@ -230,11 +230,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
if let Some(ke_union::Union::Chr(chr)) = &event.union { if let Some(ke_union::Union::Chr(chr)) = &event.union {
// chr contains USB HID scancode on Windows, X11 keycode on Linux // chr contains USB HID scancode on Windows, X11 keycode on Linux
if let Some(key) = keycode_to_hid(*chr) { if let Some(key) = keycode_to_hid(*chr) {
let key = CanonicalKey::from_hid_usage(key)?;
return Some(KeyboardEvent { return Some(KeyboardEvent {
event_type, event_type,
key, key,
modifiers, modifiers,
is_usb_hid: true, // Already converted to USB HID code
}); });
} }
} }
@@ -608,6 +608,6 @@ mod tests {
let kb_event = result.unwrap(); let kb_event = result.unwrap();
assert_eq!(kb_event.event_type, KeyEventType::Down); assert_eq!(kb_event.event_type, KeyEventType::Down);
assert_eq!(kb_event.key, 0x28); // Return key USB HID code assert_eq!(kb_event.key, CanonicalKey::Enter);
} }
} }

View File

@@ -1,5 +1,5 @@
use std::{collections::VecDeque, sync::Arc}; use std::{collections::VecDeque, sync::Arc};
use tokio::sync::{broadcast, RwLock}; use tokio::sync::{broadcast, watch, RwLock};
use crate::atx::AtxController; use crate::atx::AtxController;
use crate::audio::AudioController; use crate::audio::AudioController;
@@ -7,9 +7,9 @@ use crate::auth::{SessionStore, UserStore};
use crate::config::ConfigStore; use crate::config::ConfigStore;
use crate::events::{ use crate::events::{
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, MsdDeviceInfo, SystemEvent, AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
VideoDeviceInfo, TtydDeviceInfo, VideoDeviceInfo,
}; };
use crate::extensions::ExtensionManager; use crate::extensions::{ExtensionId, ExtensionManager};
use crate::hid::HidController; use crate::hid::HidController;
use crate::msd::MsdController; use crate::msd::MsdController;
use crate::otg::OtgService; use crate::otg::OtgService;
@@ -58,6 +58,8 @@ pub struct AppState {
pub extensions: Arc<ExtensionManager>, pub extensions: Arc<ExtensionManager>,
/// Event bus for real-time notifications /// Event bus for real-time notifications
pub events: Arc<EventBus>, pub events: Arc<EventBus>,
/// Latest device info snapshot for WebSocket clients
device_info_tx: watch::Sender<Option<SystemEvent>>,
/// Online update service /// Online update service
pub update: Arc<UpdateService>, pub update: Arc<UpdateService>,
/// Shutdown signal sender /// Shutdown signal sender
@@ -89,6 +91,8 @@ impl AppState {
shutdown_tx: broadcast::Sender<()>, shutdown_tx: broadcast::Sender<()>,
data_dir: std::path::PathBuf, data_dir: std::path::PathBuf,
) -> Arc<Self> { ) -> Arc<Self> {
let (device_info_tx, _device_info_rx) = watch::channel(None);
Arc::new(Self { Arc::new(Self {
config, config,
sessions, sessions,
@@ -103,6 +107,7 @@ impl AppState {
rtsp: Arc::new(RwLock::new(rtsp)), rtsp: Arc::new(RwLock::new(rtsp)),
extensions, extensions,
events, events,
device_info_tx,
update, update,
shutdown_tx, shutdown_tx,
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())), revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
@@ -120,6 +125,11 @@ impl AppState {
self.shutdown_tx.subscribe() self.shutdown_tx.subscribe()
} }
/// Subscribe to the latest device info snapshot.
pub fn subscribe_device_info(&self) -> watch::Receiver<Option<SystemEvent>> {
self.device_info_tx.subscribe()
}
/// Record revoked session IDs (bounded queue) /// Record revoked session IDs (bounded queue)
pub async fn remember_revoked_sessions(&self, session_ids: Vec<String>) { pub async fn remember_revoked_sessions(&self, session_ids: Vec<String>) {
if session_ids.is_empty() { if session_ids.is_empty() {
@@ -147,12 +157,13 @@ impl AppState {
/// Uses tokio::join! to collect all device info in parallel for better performance. /// Uses tokio::join! to collect all device info in parallel for better performance.
pub async fn get_device_info(&self) -> SystemEvent { pub async fn get_device_info(&self) -> SystemEvent {
// Collect all device info in parallel // Collect all device info in parallel
let (video, hid, msd, atx, audio) = tokio::join!( let (video, hid, msd, atx, audio, ttyd) = tokio::join!(
self.collect_video_info(), self.collect_video_info(),
self.collect_hid_info(), self.collect_hid_info(),
self.collect_msd_info(), self.collect_msd_info(),
self.collect_atx_info(), self.collect_atx_info(),
self.collect_audio_info(), self.collect_audio_info(),
self.collect_ttyd_info(),
); );
SystemEvent::DeviceInfo { SystemEvent::DeviceInfo {
@@ -161,13 +172,14 @@ impl AppState {
msd, msd,
atx, atx,
audio, audio,
ttyd,
} }
} }
/// Publish DeviceInfo event to all connected WebSocket clients /// Publish DeviceInfo event to all connected WebSocket clients
pub async fn publish_device_info(&self) { pub async fn publish_device_info(&self) {
let device_info = self.get_device_info().await; let device_info = self.get_device_info().await;
self.events.publish(device_info); let _ = self.device_info_tx.send(Some(device_info));
} }
/// Collect video device information /// Collect video device information
@@ -178,32 +190,19 @@ impl AppState {
/// Collect HID device information /// Collect HID device information
async fn collect_hid_info(&self) -> HidDeviceInfo { async fn collect_hid_info(&self) -> HidDeviceInfo {
let info = self.hid.info().await; let state = self.hid.snapshot().await;
let backend_type = self.hid.backend_type().await;
match info { HidDeviceInfo {
Some(hid_info) => HidDeviceInfo { available: state.available,
available: true, backend: state.backend,
backend: hid_info.name.to_string(), initialized: state.initialized,
initialized: hid_info.initialized, online: state.online,
supports_absolute_mouse: hid_info.supports_absolute_mouse, supports_absolute_mouse: state.supports_absolute_mouse,
device: match backend_type { keyboard_leds_enabled: state.keyboard_leds_enabled,
crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()), led_state: state.led_state,
_ => None, device: state.device,
}, error: state.error,
error: None, error_code: state.error_code,
},
None => HidDeviceInfo {
available: false,
backend: backend_type.name_str().to_string(),
initialized: false,
supports_absolute_mouse: false,
device: match backend_type {
crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()),
_ => None,
},
error: Some("HID backend not available".to_string()),
},
} }
} }
@@ -213,6 +212,7 @@ impl AppState {
let msd = msd_guard.as_ref()?; let msd = msd_guard.as_ref()?;
let state = msd.state().await; let state = msd.state().await;
let error = msd.monitor().error_message().await;
Some(MsdDeviceInfo { Some(MsdDeviceInfo {
available: state.available, available: state.available,
mode: match state.mode { mode: match state.mode {
@@ -223,7 +223,7 @@ impl AppState {
.to_string(), .to_string(),
connected: state.connected, connected: state.connected,
image_id: state.current_image.map(|img| img.id), image_id: state.current_image.map(|img| img.id),
error: None, error,
}) })
} }
@@ -266,4 +266,14 @@ impl AppState {
error: status.error, error: status.error,
}) })
} }
/// Collect ttyd status information
async fn collect_ttyd_info(&self) -> TtydDeviceInfo {
let status = self.extensions.status(ExtensionId::Ttyd).await;
TtydDeviceInfo {
available: self.extensions.check_available(ExtensionId::Ttyd),
running: status.is_running(),
}
}
} }

View File

@@ -1,700 +0,0 @@
//! MJPEG Streamer - High-level MJPEG/HTTP streaming manager
//!
//! This module provides a unified interface for MJPEG streaming mode,
//! integrating video capture, MJPEG distribution, and WebSocket HID.
//!
//! # Architecture
//!
//! ```text
//! MjpegStreamer
//! |
//! +-- VideoCapturer (V4L2 video capture)
//! +-- MjpegStreamHandler (HTTP multipart video)
//! +-- WsHidHandler (WebSocket HID)
//! ```
//!
//! Note: Audio WebSocket is handled separately by audio_ws.rs (/api/ws/audio)
use crate::utils::LogThrottler;
use crate::video::v4l2r_capture::V4l2rCaptureStream;
use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, RwLock};
use tracing::{error, info, warn};
use crate::audio::AudioController;
use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent};
use crate::hid::HidController;
use crate::video::capture::{CaptureConfig, VideoCapturer};
use crate::video::device::{enumerate_devices, find_best_device, VideoDeviceInfo};
use crate::video::format::{PixelFormat, Resolution};
use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use super::mjpeg::MjpegStreamHandler;
use super::ws_hid::WsHidHandler;
/// Minimum valid frame size for capture
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
/// Validate JPEG header every N frames to reduce overhead
const JPEG_VALIDATE_INTERVAL: u64 = 30;
/// MJPEG streamer configuration
#[derive(Debug, Clone)]
pub struct MjpegStreamerConfig {
/// Device path (None = auto-detect)
pub device_path: Option<PathBuf>,
/// Desired resolution
pub resolution: Resolution,
/// Desired format
pub format: PixelFormat,
/// Desired FPS
pub fps: u32,
/// JPEG quality (1-100)
pub jpeg_quality: u8,
}
impl Default for MjpegStreamerConfig {
fn default() -> Self {
Self {
device_path: None,
resolution: Resolution::HD1080,
format: PixelFormat::Mjpeg,
fps: 30,
jpeg_quality: 80,
}
}
}
/// MJPEG streamer state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MjpegStreamerState {
/// Not initialized
Uninitialized,
/// Ready but not streaming
Ready,
/// Actively streaming
Streaming,
/// No video signal
NoSignal,
/// Error occurred
Error,
}
impl std::fmt::Display for MjpegStreamerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MjpegStreamerState::Uninitialized => write!(f, "uninitialized"),
MjpegStreamerState::Ready => write!(f, "ready"),
MjpegStreamerState::Streaming => write!(f, "streaming"),
MjpegStreamerState::NoSignal => write!(f, "no_signal"),
MjpegStreamerState::Error => write!(f, "error"),
}
}
}
/// MJPEG streamer statistics
#[derive(Debug, Clone, Default)]
pub struct MjpegStreamerStats {
/// Current state
pub state: String,
/// Current device path
pub device: Option<String>,
/// Video resolution
pub resolution: Option<(u32, u32)>,
/// Video format
pub format: Option<String>,
/// Current FPS
pub fps: u32,
/// MJPEG client count
pub mjpeg_clients: u64,
/// WebSocket HID client count
pub ws_hid_clients: usize,
}
/// MJPEG Streamer
///
/// High-level manager for MJPEG/HTTP streaming mode.
/// Integrates video capture, MJPEG distribution, and WebSocket HID.
pub struct MjpegStreamer {
// === Video ===
config: RwLock<MjpegStreamerConfig>,
capturer: RwLock<Option<Arc<VideoCapturer>>>,
mjpeg_handler: Arc<MjpegStreamHandler>,
current_device: RwLock<Option<VideoDeviceInfo>>,
state: RwLock<MjpegStreamerState>,
// === Audio (controller reference only, WS handled by audio_ws.rs) ===
audio_controller: RwLock<Option<Arc<AudioController>>>,
audio_enabled: AtomicBool,
// === HID ===
ws_hid_handler: Arc<WsHidHandler>,
hid_controller: RwLock<Option<Arc<HidController>>>,
// === Control ===
start_lock: tokio::sync::Mutex<()>,
direct_stop: AtomicBool,
direct_active: AtomicBool,
direct_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
events: RwLock<Option<Arc<EventBus>>>,
config_changing: AtomicBool,
}
impl MjpegStreamer {
/// Create a new MJPEG streamer
pub fn new() -> Arc<Self> {
Arc::new(Self {
config: RwLock::new(MjpegStreamerConfig::default()),
capturer: RwLock::new(None),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
current_device: RwLock::new(None),
state: RwLock::new(MjpegStreamerState::Uninitialized),
audio_controller: RwLock::new(None),
audio_enabled: AtomicBool::new(false),
ws_hid_handler: WsHidHandler::new(),
hid_controller: RwLock::new(None),
start_lock: tokio::sync::Mutex::new(()),
direct_stop: AtomicBool::new(false),
direct_active: AtomicBool::new(false),
direct_handle: Mutex::new(None),
events: RwLock::new(None),
config_changing: AtomicBool::new(false),
})
}
/// Create with specific config
pub fn with_config(config: MjpegStreamerConfig) -> Arc<Self> {
Arc::new(Self {
config: RwLock::new(config),
capturer: RwLock::new(None),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
current_device: RwLock::new(None),
state: RwLock::new(MjpegStreamerState::Uninitialized),
audio_controller: RwLock::new(None),
audio_enabled: AtomicBool::new(false),
ws_hid_handler: WsHidHandler::new(),
hid_controller: RwLock::new(None),
start_lock: tokio::sync::Mutex::new(()),
direct_stop: AtomicBool::new(false),
direct_active: AtomicBool::new(false),
direct_handle: Mutex::new(None),
events: RwLock::new(None),
config_changing: AtomicBool::new(false),
})
}
// ========================================================================
// Configuration and Setup
// ========================================================================
/// Set event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
/// Set audio controller (for reference, WebSocket handled by audio_ws.rs)
pub async fn set_audio_controller(&self, audio: Arc<AudioController>) {
*self.audio_controller.write().await = Some(audio);
info!("MjpegStreamer: Audio controller set");
}
/// Set HID controller
pub async fn set_hid_controller(&self, hid: Arc<HidController>) {
*self.hid_controller.write().await = Some(hid.clone());
self.ws_hid_handler.set_hid_controller(hid);
info!("MjpegStreamer: HID controller set");
}
/// Enable or disable audio
pub fn set_audio_enabled(&self, enabled: bool) {
self.audio_enabled.store(enabled, Ordering::SeqCst);
}
/// Check if audio is enabled
pub fn is_audio_enabled(&self) -> bool {
self.audio_enabled.load(Ordering::SeqCst)
}
// ========================================================================
// State and Status
// ========================================================================
/// Get current state
pub async fn state(&self) -> MjpegStreamerState {
*self.state.read().await
}
/// Check if config is currently being changed
pub fn is_config_changing(&self) -> bool {
self.config_changing.load(Ordering::SeqCst)
}
/// Get current device info
pub async fn current_device(&self) -> Option<VideoDeviceInfo> {
self.current_device.read().await.clone()
}
/// Get statistics
pub async fn stats(&self) -> MjpegStreamerStats {
let state = *self.state.read().await;
let device = self.current_device.read().await;
let config = self.config.read().await;
let (resolution, format) = {
if self.direct_active.load(Ordering::Relaxed) {
(
Some((config.resolution.width, config.resolution.height)),
Some(config.format.to_string()),
)
} else if let Some(ref cap) = *self.capturer.read().await {
let _ = cap;
(
Some((config.resolution.width, config.resolution.height)),
Some(config.format.to_string()),
)
} else {
(None, None)
}
};
MjpegStreamerStats {
state: state.to_string(),
device: device.as_ref().map(|d| d.path.display().to_string()),
resolution,
format,
fps: config.fps,
mjpeg_clients: self.mjpeg_handler.client_count(),
ws_hid_clients: self.ws_hid_handler.client_count(),
}
}
// ========================================================================
// Handler Access
// ========================================================================
/// Get MJPEG handler for HTTP streaming
pub fn mjpeg_handler(&self) -> Arc<MjpegStreamHandler> {
self.mjpeg_handler.clone()
}
/// Get WebSocket HID handler
pub fn ws_hid_handler(&self) -> Arc<WsHidHandler> {
self.ws_hid_handler.clone()
}
// ========================================================================
// Initialization
// ========================================================================
/// Initialize with auto-detected device
pub async fn init_auto(self: &Arc<Self>) -> Result<()> {
let best = find_best_device()?;
self.init_with_device(best).await
}
/// Initialize with specific device
pub async fn init_with_device(self: &Arc<Self>, device: VideoDeviceInfo) -> Result<()> {
info!(
"MjpegStreamer: Initializing with device: {}",
device.path.display()
);
let config = self.config.read().await.clone();
self.mjpeg_handler.set_jpeg_quality(config.jpeg_quality);
// Create capture config
let capture_config = CaptureConfig {
device_path: device.path.clone(),
resolution: config.resolution,
format: config.format,
fps: config.fps,
buffer_count: 4,
timeout: std::time::Duration::from_secs(5),
jpeg_quality: config.jpeg_quality,
};
// Create capturer
let capturer = Arc::new(VideoCapturer::new(capture_config));
// Store device and capturer
*self.current_device.write().await = Some(device);
*self.capturer.write().await = Some(capturer);
*self.state.write().await = MjpegStreamerState::Ready;
self.publish_state_change().await;
Ok(())
}
// ========================================================================
// Streaming Control
// ========================================================================
/// Start streaming
pub async fn start(self: &Arc<Self>) -> Result<()> {
let _lock = self.start_lock.lock().await;
if self.config_changing.load(Ordering::SeqCst) {
return Err(AppError::VideoError(
"Config change in progress".to_string(),
));
}
let state = *self.state.read().await;
if state == MjpegStreamerState::Streaming {
return Ok(());
}
let device = self
.current_device
.read()
.await
.clone()
.ok_or_else(|| AppError::VideoError("Not initialized".to_string()))?;
let config = self.config.read().await.clone();
self.direct_stop.store(false, Ordering::SeqCst);
self.direct_active.store(true, Ordering::SeqCst);
let streamer = self.clone();
let handle = tokio::task::spawn_blocking(move || {
streamer.run_direct_capture(device.path, config);
});
*self.direct_handle.lock().await = Some(handle);
// Note: Audio WebSocket is handled separately by audio_ws.rs (/api/ws/audio)
*self.state.write().await = MjpegStreamerState::Streaming;
self.mjpeg_handler.set_online();
self.publish_state_change().await;
info!("MjpegStreamer: Streaming started");
Ok(())
}
/// Stop streaming
pub async fn stop(&self) -> Result<()> {
let state = *self.state.read().await;
if state != MjpegStreamerState::Streaming {
return Ok(());
}
self.direct_stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.direct_handle.lock().await.take() {
let _ = handle.await;
}
self.direct_active.store(false, Ordering::SeqCst);
// Stop capturer (legacy path)
if let Some(ref cap) = *self.capturer.read().await {
let _ = cap.stop().await;
}
// Set offline
self.mjpeg_handler.set_offline();
*self.state.write().await = MjpegStreamerState::Ready;
self.publish_state_change().await;
info!("MjpegStreamer: Streaming stopped");
Ok(())
}
/// Check if streaming
pub async fn is_streaming(&self) -> bool {
*self.state.read().await == MjpegStreamerState::Streaming
}
// ========================================================================
// Configuration Updates
// ========================================================================
/// Apply video configuration
///
/// This stops the current stream, reconfigures the capturer, and restarts.
pub async fn apply_config(self: &Arc<Self>, config: MjpegStreamerConfig) -> Result<()> {
info!("MjpegStreamer: Applying config: {:?}", config);
self.config_changing.store(true, Ordering::SeqCst);
// Stop current stream
self.stop().await?;
// Disconnect all MJPEG clients
self.mjpeg_handler.disconnect_all_clients();
// Release capturer
*self.capturer.write().await = None;
// Update config
*self.config.write().await = config.clone();
self.mjpeg_handler.set_jpeg_quality(config.jpeg_quality);
// Re-initialize if device path is set
if let Some(ref path) = config.device_path {
let devices = enumerate_devices()?;
let device = devices
.into_iter()
.find(|d| d.path == *path)
.ok_or_else(|| {
AppError::VideoError(format!("Device not found: {}", path.display()))
})?;
self.init_with_device(device).await?;
}
self.config_changing.store(false, Ordering::SeqCst);
self.publish_state_change().await;
Ok(())
}
// ========================================================================
// Internal
// ========================================================================
/// Publish state change event
async fn publish_state_change(&self) {
if let Some(ref events) = *self.events.read().await {
let state = *self.state.read().await;
let device = self.current_device.read().await;
events.publish(SystemEvent::StreamStateChanged {
state: state.to_string(),
device: device.as_ref().map(|d| d.path.display().to_string()),
});
}
}
/// Direct capture loop for MJPEG mode (single loop, no broadcast)
fn run_direct_capture(self: Arc<Self>, device_path: PathBuf, config: MjpegStreamerConfig) {
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 200;
let handle = tokio::runtime::Handle::current();
let mut last_state = MjpegStreamerState::Streaming;
let mut set_state = |new_state: MjpegStreamerState| {
if new_state != last_state {
handle.block_on(async {
*self.state.write().await = new_state;
self.publish_state_change().await;
});
last_state = new_state;
}
};
let mut stream_opt: Option<V4l2rCaptureStream> = None;
let mut last_error: Option<String> = None;
for attempt in 0..MAX_RETRIES {
if self.direct_stop.load(Ordering::Relaxed) {
self.direct_active.store(false, Ordering::SeqCst);
return;
}
match V4l2rCaptureStream::open(
&device_path,
config.resolution,
config.format,
config.fps,
4,
Duration::from_secs(2),
) {
Ok(stream) => {
stream_opt = Some(stream);
break;
}
Err(e) => {
let err_str = e.to_string();
if err_str.contains("busy") || err_str.contains("resource") {
warn!(
"Device busy on attempt {}/{}, retrying in {}ms...",
attempt + 1,
MAX_RETRIES,
RETRY_DELAY_MS
);
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
last_error = Some(err_str);
continue;
}
last_error = Some(err_str);
break;
}
}
}
let mut stream = match stream_opt {
Some(stream) => stream,
None => {
error!(
"Failed to open device {:?}: {}",
device_path,
last_error.unwrap_or_else(|| "unknown error".to_string())
);
set_state(MjpegStreamerState::Error);
self.mjpeg_handler.set_offline();
self.direct_active.store(false, Ordering::SeqCst);
return;
}
};
let resolution = stream.resolution();
let pixel_format = stream.format();
let stride = stream.stride();
info!(
"Capture format: {}x{} {:?} stride={}",
resolution.width, resolution.height, pixel_format, stride
);
let buffer_pool = Arc::new(FrameBufferPool::new(8));
let mut signal_present = true;
let mut validate_counter: u64 = 0;
let capture_error_throttler = LogThrottler::with_secs(5);
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
let classify_capture_error = |err: &std::io::Error| -> String {
let message = err.to_string();
if message.contains("dqbuf failed") && message.contains("EINVAL") {
"capture_dqbuf_einval".to_string()
} else if message.contains("dqbuf failed") {
"capture_dqbuf".to_string()
} else {
format!("capture_{:?}", err.kind())
}
};
while !self.direct_stop.load(Ordering::Relaxed) {
let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE);
let meta = match stream.next_into(&mut owned) {
Ok(meta) => meta,
Err(e) => {
if e.kind() == io::ErrorKind::TimedOut {
if signal_present {
signal_present = false;
set_state(MjpegStreamerState::NoSignal);
}
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
let is_device_lost = match e.raw_os_error() {
Some(6) => true, // ENXIO
Some(19) => true, // ENODEV
Some(5) => true, // EIO
Some(32) => true, // EPIPE
Some(108) => true, // ESHUTDOWN
_ => false,
};
if is_device_lost {
error!("Video device lost: {} - {}", device_path.display(), e);
set_state(MjpegStreamerState::Error);
self.mjpeg_handler.set_offline();
self.direct_active.store(false, Ordering::SeqCst);
return;
}
let key = classify_capture_error(&e);
if capture_error_throttler.should_log(&key) {
let suppressed = suppressed_capture_errors.remove(&key).unwrap_or(0);
if suppressed > 0 {
error!("Capture error: {} (suppressed {} repeats)", e, suppressed);
} else {
error!("Capture error: {}", e);
}
} else {
let counter = suppressed_capture_errors.entry(key).or_insert(0);
*counter = counter.saturating_add(1);
}
continue;
}
};
let frame_size = meta.bytes_used;
if frame_size < MIN_CAPTURE_FRAME_SIZE {
continue;
}
validate_counter = validate_counter.wrapping_add(1);
if pixel_format.is_compressed()
&& validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL)
&& !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size])
{
continue;
}
owned.truncate(frame_size);
let frame = VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))),
resolution,
pixel_format,
stride,
meta.sequence,
);
if !signal_present {
signal_present = true;
set_state(MjpegStreamerState::Streaming);
}
self.mjpeg_handler.update_frame(frame);
}
self.direct_active.store(false, Ordering::SeqCst);
}
}
impl Default for MjpegStreamer {
fn default() -> Self {
Self {
config: RwLock::new(MjpegStreamerConfig::default()),
capturer: RwLock::new(None),
mjpeg_handler: Arc::new(MjpegStreamHandler::new()),
current_device: RwLock::new(None),
state: RwLock::new(MjpegStreamerState::Uninitialized),
audio_controller: RwLock::new(None),
audio_enabled: AtomicBool::new(false),
ws_hid_handler: WsHidHandler::new(),
hid_controller: RwLock::new(None),
start_lock: tokio::sync::Mutex::new(()),
direct_stop: AtomicBool::new(false),
direct_active: AtomicBool::new(false),
direct_handle: Mutex::new(None),
events: RwLock::new(None),
config_changing: AtomicBool::new(false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mjpeg_streamer_creation() {
let streamer = MjpegStreamer::new();
assert!(!streamer.is_config_changing());
assert!(!streamer.is_audio_enabled());
}
#[test]
fn test_mjpeg_streamer_config_default() {
let config = MjpegStreamerConfig::default();
assert_eq!(config.resolution, Resolution::HD1080);
assert_eq!(config.format, PixelFormat::Mjpeg);
assert_eq!(config.fps, 30);
}
#[test]
fn test_mjpeg_streamer_state_display() {
assert_eq!(MjpegStreamerState::Streaming.to_string(), "streaming");
assert_eq!(MjpegStreamerState::Ready.to_string(), "ready");
}
}

View File

@@ -4,16 +4,11 @@
//! //!
//! # Components //! # Components
//! //!
//! - `MjpegStreamer` - High-level MJPEG streaming manager
//! - `MjpegStreamHandler` - HTTP multipart MJPEG video streaming //! - `MjpegStreamHandler` - HTTP multipart MJPEG video streaming
//! - `WsHidHandler` - WebSocket HID input handler //! - `WsHidHandler` - WebSocket HID input handler
pub mod mjpeg; pub mod mjpeg;
pub mod mjpeg_streamer;
pub mod ws_hid; pub mod ws_hid;
pub use mjpeg::{ClientGuard, MjpegStreamHandler}; pub use mjpeg::{ClientGuard, MjpegStreamHandler};
pub use mjpeg_streamer::{
MjpegStreamer, MjpegStreamerConfig, MjpegStreamerState, MjpegStreamerStats,
};
pub use ws_hid::WsHidHandler; pub use ws_hid::WsHidHandler;

View File

@@ -1,560 +0,0 @@
//! V4L2 video capture implementation
//!
//! Provides async video capture using memory-mapped buffers.
use bytes::Bytes;
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{watch, Mutex};
use tracing::{debug, error, info, warn};
use super::format::{PixelFormat, Resolution};
use super::frame::VideoFrame;
use crate::error::{AppError, Result};
use crate::utils::LogThrottler;
use crate::video::v4l2r_capture::V4l2rCaptureStream;
/// Default number of capture buffers (reduced from 4 to 2 for lower latency)
const DEFAULT_BUFFER_COUNT: u32 = 2;
/// Default capture timeout in seconds
const DEFAULT_TIMEOUT: u64 = 2;
/// Minimum valid frame size (bytes)
const MIN_FRAME_SIZE: usize = 128;
/// Video capturer configuration
#[derive(Debug, Clone)]
pub struct CaptureConfig {
/// Device path
pub device_path: PathBuf,
/// Desired resolution
pub resolution: Resolution,
/// Desired pixel format
pub format: PixelFormat,
/// Desired frame rate (0 = max available)
pub fps: u32,
/// Number of capture buffers
pub buffer_count: u32,
/// Capture timeout
pub timeout: Duration,
/// JPEG quality (1-100, for MJPEG sources with hardware quality control)
pub jpeg_quality: u8,
}
impl Default for CaptureConfig {
fn default() -> Self {
Self {
device_path: PathBuf::from("/dev/video0"),
resolution: Resolution::HD1080,
format: PixelFormat::Mjpeg,
fps: 30,
buffer_count: DEFAULT_BUFFER_COUNT,
timeout: Duration::from_secs(DEFAULT_TIMEOUT),
jpeg_quality: 80,
}
}
}
impl CaptureConfig {
/// Create config for a specific device
pub fn for_device(path: impl AsRef<Path>) -> Self {
Self {
device_path: path.as_ref().to_path_buf(),
..Default::default()
}
}
/// Set resolution
pub fn with_resolution(mut self, width: u32, height: u32) -> Self {
self.resolution = Resolution::new(width, height);
self
}
/// Set format
pub fn with_format(mut self, format: PixelFormat) -> Self {
self.format = format;
self
}
/// Set frame rate
pub fn with_fps(mut self, fps: u32) -> Self {
self.fps = fps;
self
}
}
/// Capture statistics
#[derive(Debug, Clone, Default)]
pub struct CaptureStats {
/// Current FPS (calculated)
pub current_fps: f32,
}
/// Video capturer state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
/// Not started
Stopped,
/// Starting (initializing device)
Starting,
/// Running and capturing
Running,
/// No signal from source
NoSignal,
/// Error occurred
Error,
/// Device was lost (disconnected)
DeviceLost,
}
/// Async video capturer
pub struct VideoCapturer {
config: CaptureConfig,
state: Arc<watch::Sender<CaptureState>>,
state_rx: watch::Receiver<CaptureState>,
stats: Arc<Mutex<CaptureStats>>,
stop_flag: Arc<AtomicBool>,
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
/// Last error that occurred (device path, reason)
last_error: Arc<parking_lot::RwLock<Option<(String, String)>>>,
}
impl VideoCapturer {
/// Create a new video capturer
pub fn new(config: CaptureConfig) -> Self {
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
Self {
config,
state: Arc::new(state_tx),
state_rx,
stats: Arc::new(Mutex::new(CaptureStats::default())),
stop_flag: Arc::new(AtomicBool::new(false)),
capture_handle: Mutex::new(None),
last_error: Arc::new(parking_lot::RwLock::new(None)),
}
}
/// Get current capture state
pub fn state(&self) -> CaptureState {
*self.state_rx.borrow()
}
/// Subscribe to state changes
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
self.state_rx.clone()
}
/// Get last error (device path, reason)
pub fn last_error(&self) -> Option<(String, String)> {
self.last_error.read().clone()
}
/// Clear last error
pub fn clear_error(&self) {
*self.last_error.write() = None;
}
/// Get capture statistics
pub async fn stats(&self) -> CaptureStats {
self.stats.lock().await.clone()
}
/// Get config
pub fn config(&self) -> &CaptureConfig {
&self.config
}
/// Start capturing in background
pub async fn start(&self) -> Result<()> {
let current_state = self.state();
// Already running or starting - nothing to do
if current_state == CaptureState::Running || current_state == CaptureState::Starting {
return Ok(());
}
info!(
"Starting capture on {:?} at {}x{} {}",
self.config.device_path,
self.config.resolution.width,
self.config.resolution.height,
self.config.format
);
// Set Starting state immediately to prevent concurrent start attempts
let _ = self.state.send(CaptureState::Starting);
// Clear any previous error
*self.last_error.write() = None;
self.stop_flag.store(false, Ordering::SeqCst);
let config = self.config.clone();
let state = self.state.clone();
let stats = self.stats.clone();
let stop_flag = self.stop_flag.clone();
let last_error = self.last_error.clone();
let handle = tokio::task::spawn_blocking(move || {
capture_loop(config, state, stats, stop_flag, last_error);
});
*self.capture_handle.lock().await = Some(handle);
Ok(())
}
/// Stop capturing
pub async fn stop(&self) -> Result<()> {
info!("Stopping capture");
self.stop_flag.store(true, Ordering::SeqCst);
if let Some(handle) = self.capture_handle.lock().await.take() {
let _ = handle.await;
}
let _ = self.state.send(CaptureState::Stopped);
Ok(())
}
/// Check if capturing
pub fn is_running(&self) -> bool {
self.state() == CaptureState::Running
}
/// Get the latest frame (if any receivers would get it)
pub fn latest_frame(&self) -> Option<VideoFrame> {
// This is a bit tricky with broadcast - we'd need to track internally
// For now, callers should use subscribe()
None
}
}
/// Main capture loop (runs in blocking thread)
fn capture_loop(
config: CaptureConfig,
state: Arc<watch::Sender<CaptureState>>,
stats: Arc<Mutex<CaptureStats>>,
stop_flag: Arc<AtomicBool>,
error_holder: Arc<parking_lot::RwLock<Option<(String, String)>>>,
) {
let result = run_capture(&config, &state, &stats, &stop_flag);
match result {
Ok(_) => {
let _ = state.send(CaptureState::Stopped);
}
Err(AppError::VideoDeviceLost { device, reason }) => {
error!("Video device lost: {} - {}", device, reason);
// Store the error for recovery handling
*error_holder.write() = Some((device, reason));
let _ = state.send(CaptureState::DeviceLost);
}
Err(e) => {
error!("Capture error: {}", e);
let _ = state.send(CaptureState::Error);
}
}
}
fn run_capture(
config: &CaptureConfig,
state: &watch::Sender<CaptureState>,
stats: &Arc<Mutex<CaptureStats>>,
stop_flag: &AtomicBool,
) -> Result<()> {
// Retry logic for device busy errors
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 200;
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
if stop_flag.load(Ordering::Relaxed) {
return Ok(());
}
let stream = match V4l2rCaptureStream::open(
&config.device_path,
config.resolution,
config.format,
config.fps,
config.buffer_count,
config.timeout,
) {
Ok(stream) => stream,
Err(e) => {
let err_str = e.to_string();
if err_str.contains("busy") || err_str.contains("resource") {
warn!(
"Device busy on attempt {}/{}, retrying in {}ms...",
attempt + 1,
MAX_RETRIES,
RETRY_DELAY_MS
);
std::thread::sleep(Duration::from_millis(RETRY_DELAY_MS));
last_error = Some(AppError::VideoError(format!(
"Failed to open device {:?}: {}",
config.device_path, e
)));
continue;
}
return Err(AppError::VideoError(format!(
"Failed to open device {:?}: {}",
config.device_path, e
)));
}
};
return run_capture_inner(config, state, stats, stop_flag, stream);
}
// All retries exhausted
Err(last_error.unwrap_or_else(|| {
AppError::VideoError("Failed to open device after all retries".to_string())
}))
}
/// Inner capture function after device is successfully opened
fn run_capture_inner(
config: &CaptureConfig,
state: &watch::Sender<CaptureState>,
stats: &Arc<Mutex<CaptureStats>>,
stop_flag: &AtomicBool,
mut stream: V4l2rCaptureStream,
) -> Result<()> {
let resolution = stream.resolution();
let pixel_format = stream.format();
let stride = stream.stride();
info!(
"Capture format: {}x{} {:?} stride={}",
resolution.width, resolution.height, pixel_format, stride
);
let _ = state.send(CaptureState::Running);
info!("Capture started");
// FPS calculation variables
let mut fps_frame_count = 0u64;
let mut fps_window_start = Instant::now();
let fps_window_duration = Duration::from_secs(1);
let mut scratch = Vec::new();
let capture_error_throttler = LogThrottler::with_secs(5);
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
let classify_capture_error = |err: &std::io::Error| -> String {
let message = err.to_string();
if message.contains("dqbuf failed") && message.contains("EINVAL") {
"capture_dqbuf_einval".to_string()
} else if message.contains("dqbuf failed") {
"capture_dqbuf".to_string()
} else {
format!("capture_{:?}", err.kind())
}
};
// Main capture loop
while !stop_flag.load(Ordering::Relaxed) {
let meta = match stream.next_into(&mut scratch) {
Ok(meta) => meta,
Err(e) => {
if e.kind() == io::ErrorKind::TimedOut {
warn!("Capture timeout - no signal?");
let _ = state.send(CaptureState::NoSignal);
// Wait a bit before retrying
std::thread::sleep(Duration::from_millis(100));
continue;
}
// Check for device loss errors
let is_device_lost = match e.raw_os_error() {
Some(6) => true, // ENXIO - No such device or address
Some(19) => true, // ENODEV - No such device
Some(5) => true, // EIO - I/O error (device removed)
Some(32) => true, // EPIPE - Broken pipe
Some(108) => true, // ESHUTDOWN - Transport endpoint shutdown
_ => false,
};
if is_device_lost {
let device_path = config.device_path.display().to_string();
error!("Video device lost: {} - {}", device_path, e);
return Err(AppError::VideoDeviceLost {
device: device_path,
reason: e.to_string(),
});
}
let key = classify_capture_error(&e);
if capture_error_throttler.should_log(&key) {
let suppressed = suppressed_capture_errors.remove(&key).unwrap_or(0);
if suppressed > 0 {
error!("Capture error: {} (suppressed {} repeats)", e, suppressed);
} else {
error!("Capture error: {}", e);
}
} else {
let counter = suppressed_capture_errors.entry(key).or_insert(0);
*counter = counter.saturating_add(1);
}
continue;
}
};
// Use actual bytes used, not buffer size
let frame_size = meta.bytes_used;
// Validate frame
if frame_size < MIN_FRAME_SIZE {
debug!(
"Dropping small frame: {} bytes (bytesused={})",
frame_size, meta.bytes_used
);
continue;
}
// Update state if was no signal
if *state.borrow() == CaptureState::NoSignal {
let _ = state.send(CaptureState::Running);
}
// Update FPS calculation
if let Ok(mut s) = stats.try_lock() {
fps_frame_count += 1;
let elapsed = fps_window_start.elapsed();
if elapsed >= fps_window_duration {
// Calculate FPS from the completed window
s.current_fps = (fps_frame_count as f32 / elapsed.as_secs_f32()).max(0.0);
// Reset for next window
fps_frame_count = 0;
fps_window_start = Instant::now();
} else if elapsed.as_millis() > 100 && fps_frame_count > 0 {
// Provide partial estimate if we have at least 100ms of data
s.current_fps = (fps_frame_count as f32 / elapsed.as_secs_f32()).max(0.0);
}
}
if *state.borrow() == CaptureState::NoSignal {
let _ = state.send(CaptureState::Running);
}
}
info!("Capture stopped");
Ok(())
}
/// Validate JPEG frame data
#[cfg(test)]
fn is_valid_jpeg(data: &[u8]) -> bool {
if data.len() < 125 {
return false;
}
// Check start marker (0xFFD8)
let start_marker = ((data[0] as u16) << 8) | data[1] as u16;
if start_marker != 0xFFD8 {
return false;
}
// Check end marker
let end = data.len();
let end_marker = ((data[end - 2] as u16) << 8) | data[end - 1] as u16;
// Valid end markers: 0xFFD9, 0xD900, 0x0000 (padded)
matches!(end_marker, 0xFFD9 | 0xD900 | 0x0000)
}
/// Frame grabber for one-shot capture
pub struct FrameGrabber {
device_path: PathBuf,
}
impl FrameGrabber {
/// Create a new frame grabber
pub fn new(device_path: impl AsRef<Path>) -> Self {
Self {
device_path: device_path.as_ref().to_path_buf(),
}
}
/// Capture a single frame
pub async fn grab(&self, resolution: Resolution, format: PixelFormat) -> Result<VideoFrame> {
let device_path = self.device_path.clone();
tokio::task::spawn_blocking(move || grab_single_frame(&device_path, resolution, format))
.await
.map_err(|e| AppError::VideoError(format!("Grab task failed: {}", e)))?
}
}
fn grab_single_frame(
device_path: &Path,
resolution: Resolution,
format: PixelFormat,
) -> Result<VideoFrame> {
let mut stream = V4l2rCaptureStream::open(
device_path,
resolution,
format,
0,
2,
Duration::from_secs(DEFAULT_TIMEOUT),
)?;
let actual_resolution = stream.resolution();
let actual_format = stream.format();
let actual_stride = stream.stride();
let mut scratch = Vec::new();
// Try to get a valid frame (skip first few which might be bad)
for attempt in 0..5 {
match stream.next_into(&mut scratch) {
Ok(meta) => {
if meta.bytes_used >= MIN_FRAME_SIZE {
return Ok(VideoFrame::new(
Bytes::copy_from_slice(&scratch[..meta.bytes_used]),
actual_resolution,
actual_format,
actual_stride,
0,
));
}
}
Err(e) if attempt == 4 => {
return Err(AppError::VideoError(format!("Failed to grab frame: {}", e)));
}
Err(_) => {}
}
}
Err(AppError::VideoError(
"Failed to capture valid frame".to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_jpeg() {
// Valid JPEG header and footer
let mut data = vec![0xFF, 0xD8]; // SOI
data.extend(vec![0u8; 200]); // Content
data.extend([0xFF, 0xD9]); // EOI
assert!(is_valid_jpeg(&data));
// Invalid - too small
assert!(!is_valid_jpeg(&[0xFF, 0xD8, 0xFF, 0xD9]));
// Invalid - wrong header
let mut bad = vec![0x00, 0x00];
bad.extend(vec![0u8; 200]);
assert!(!is_valid_jpeg(&bad));
}
}

View File

@@ -17,6 +17,8 @@ use hwcodec::ffmpeg::AVPixelFormat;
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder}; use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
use hwcodec::ffmpeg_ram::CodecInfo; use hwcodec::ffmpeg_ram::CodecInfo;
use super::detect_best_codec_for_format;
use super::registry::EncoderBackend;
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig}; use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::video::format::{PixelFormat, Resolution}; use crate::video::format::{PixelFormat, Resolution};
@@ -69,21 +71,17 @@ impl std::fmt::Display for H264EncoderType {
} }
/// Map codec name to encoder type /// Map codec name to encoder type
fn codec_name_to_type(name: &str) -> H264EncoderType { impl From<EncoderBackend> for H264EncoderType {
if name.contains("nvenc") { fn from(backend: EncoderBackend) -> Self {
H264EncoderType::Nvenc match backend {
} else if name.contains("qsv") { EncoderBackend::Nvenc => H264EncoderType::Nvenc,
H264EncoderType::Qsv EncoderBackend::Qsv => H264EncoderType::Qsv,
} else if name.contains("amf") { EncoderBackend::Amf => H264EncoderType::Amf,
H264EncoderType::Amf EncoderBackend::Vaapi => H264EncoderType::Vaapi,
} else if name.contains("vaapi") { EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
H264EncoderType::Vaapi EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
} else if name.contains("rkmpp") { EncoderBackend::Software => H264EncoderType::Software,
H264EncoderType::Rkmpp }
} else if name.contains("v4l2m2m") {
H264EncoderType::V4l2M2m
} else {
H264EncoderType::Software
} }
} }
@@ -215,22 +213,16 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
pub fn detect_best_encoder(width: u32, height: u32) -> (H264EncoderType, Option<String>) { pub fn detect_best_encoder(width: u32, height: u32) -> (H264EncoderType, Option<String>) {
let encoders = get_available_encoders(width, height); let encoders = get_available_encoders(width, height);
if encoders.is_empty() { if let Some((encoder_type, codec_name)) =
detect_best_codec_for_format(&encoders, hwcodec::common::DataFormat::H264, |_| true)
{
info!("Best H.264 encoder: {} ({})", codec_name, encoder_type);
(encoder_type, Some(codec_name))
} else {
warn!("No H.264 encoders available from hwcodec"); warn!("No H.264 encoders available from hwcodec");
return (H264EncoderType::None, None);
}
// Find H264 encoder (not H265)
for codec in &encoders {
if codec.format == hwcodec::common::DataFormat::H264 {
let encoder_type = codec_name_to_type(&codec.name);
info!("Best H.264 encoder: {} ({})", codec.name, encoder_type);
return (encoder_type, Some(codec.name.clone()));
}
}
(H264EncoderType::None, None) (H264EncoderType::None, None)
} }
}
/// Encoded frame from hwcodec (cloned for ownership) /// Encoded frame from hwcodec (cloned for ownership)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -252,9 +244,6 @@ pub struct H264Encoder {
codec_name: String, codec_name: String,
/// Frame counter /// Frame counter
frame_count: u64, frame_count: u64,
/// YUV420P buffer for input (reserved for future use)
#[allow(dead_code)]
yuv_buffer: Vec<u8>,
/// Required YUV buffer length from hwcodec /// Required YUV buffer length from hwcodec
yuv_length: i32, yuv_length: i32,
} }
@@ -321,7 +310,7 @@ impl H264Encoder {
})?; })?;
let yuv_length = inner.length; let yuv_length = inner.length;
let encoder_type = codec_name_to_type(codec_name); let encoder_type = H264EncoderType::from(EncoderBackend::from_codec_name(codec_name));
info!( info!(
"H.264 encoder created: {} (type: {}, buffer_length: {}, input_format: {:?})", "H.264 encoder created: {} (type: {}, buffer_length: {}, input_format: {:?})",
@@ -334,7 +323,6 @@ impl H264Encoder {
encoder_type, encoder_type,
codec_name: codec_name.to_string(), codec_name: codec_name.to_string(),
frame_count: 0, frame_count: 0,
yuv_buffer: vec![0u8; yuv_length as usize],
yuv_length, yuv_length,
}) })
} }

View File

@@ -15,6 +15,7 @@ use hwcodec::ffmpeg::AVPixelFormat;
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder}; use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
use hwcodec::ffmpeg_ram::CodecInfo; use hwcodec::ffmpeg_ram::CodecInfo;
use super::detect_best_codec_for_format;
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType}; use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig}; use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
@@ -221,43 +222,25 @@ pub fn get_available_h265_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
pub fn detect_best_h265_encoder(width: u32, height: u32) -> (H265EncoderType, Option<String>) { pub fn detect_best_h265_encoder(width: u32, height: u32) -> (H265EncoderType, Option<String>) {
let encoders = get_available_h265_encoders(width, height); let encoders = get_available_h265_encoders(width, height);
if encoders.is_empty() {
warn!("No H.265 encoders available");
return (H265EncoderType::None, None);
}
// Prefer hardware encoders over software (libx265) // Prefer hardware encoders over software (libx265)
// Hardware priority: NVENC > QSV > AMF > VAAPI > RKMPP > V4L2 M2M > Software // Hardware priority: NVENC > QSV > AMF > VAAPI > RKMPP > V4L2 M2M > Software
let codec = encoders if let Some((encoder_type, codec_name)) =
.iter() detect_best_codec_for_format(&encoders, DataFormat::H265, |codec| {
.find(|e| !e.name.contains("libx265")) !codec.name.contains("libx265")
.or_else(|| encoders.first()) })
.unwrap(); {
info!("Selected H.265 encoder: {} ({})", codec_name, encoder_type);
let encoder_type = if codec.name.contains("nvenc") { (encoder_type, Some(codec_name))
H265EncoderType::Nvenc
} else if codec.name.contains("qsv") {
H265EncoderType::Qsv
} else if codec.name.contains("amf") {
H265EncoderType::Amf
} else if codec.name.contains("vaapi") {
H265EncoderType::Vaapi
} else if codec.name.contains("rkmpp") {
H265EncoderType::Rkmpp
} else if codec.name.contains("v4l2m2m") {
H265EncoderType::V4l2M2m
} else { } else {
H265EncoderType::Software // Default to software for unknown warn!("No H.265 encoders available");
}; (H265EncoderType::None, None)
}
info!("Selected H.265 encoder: {} ({})", codec.name, encoder_type);
(encoder_type, Some(codec.name.clone()))
} }
/// Check if H265 hardware encoding is available /// Check if H265 hardware encoding is available
pub fn is_h265_available() -> bool { pub fn is_h265_available() -> bool {
let registry = EncoderRegistry::global(); let registry = EncoderRegistry::global();
registry.is_format_available(VideoEncoderType::H265, true) registry.is_codec_available(VideoEncoderType::H265)
} }
/// Encoded frame from hwcodec (cloned for ownership) /// Encoded frame from hwcodec (cloned for ownership)
@@ -268,7 +251,7 @@ pub struct HwEncodeFrame {
pub key: i32, pub key: i32,
} }
/// H.265 encoder using hwcodec (hardware only) /// H.265 encoder using hwcodec
pub struct H265Encoder { pub struct H265Encoder {
/// hwcodec encoder instance /// hwcodec encoder instance
inner: HwEncoder, inner: HwEncoder,

View File

@@ -3,17 +3,21 @@
//! This module provides video encoding capabilities including: //! This module provides video encoding capabilities including:
//! - JPEG encoding for raw frames (YUYV, NV12, etc.) //! - JPEG encoding for raw frames (YUYV, NV12, etc.)
//! - H264 encoding (hardware + software) //! - H264 encoding (hardware + software)
//! - H265 encoding (hardware only) //! - H265 encoding (hardware + software)
//! - VP8 encoding (hardware only - VAAPI) //! - VP8 encoding (hardware + software)
//! - VP9 encoding (hardware only - VAAPI) //! - VP9 encoding (hardware + software)
//! - WebRTC video codec abstraction //! - WebRTC video codec abstraction
//! - Encoder registry for automatic detection //! - Encoder registry for automatic detection
use hwcodec::common::DataFormat;
use hwcodec::ffmpeg_ram::CodecInfo;
pub mod codec; pub mod codec;
pub mod h264; pub mod h264;
pub mod h265; pub mod h265;
pub mod jpeg; pub mod jpeg;
pub mod registry; pub mod registry;
pub mod self_check;
pub mod traits; pub mod traits;
pub mod vp8; pub mod vp8;
pub mod vp9; pub mod vp9;
@@ -28,18 +32,53 @@ pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, Vid
// Encoder registry // Encoder registry
pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType}; pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType};
pub use self_check::{
build_hardware_self_check_runtime_error, run_hardware_self_check, VideoEncoderSelfCheckCell,
VideoEncoderSelfCheckCodec, VideoEncoderSelfCheckResponse, VideoEncoderSelfCheckRow,
};
// H264 encoder // H264 encoder
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat}; pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
// H265 encoder (hardware only) // H265 encoder
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat}; pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
// VP8 encoder (hardware only) // VP8 encoder
pub use vp8::{VP8Config, VP8Encoder, VP8EncoderType, VP8InputFormat}; pub use vp8::{VP8Config, VP8Encoder, VP8EncoderType, VP8InputFormat};
// VP9 encoder (hardware only) // VP9 encoder
pub use vp9::{VP9Config, VP9Encoder, VP9EncoderType, VP9InputFormat}; pub use vp9::{VP9Config, VP9Encoder, VP9EncoderType, VP9InputFormat};
// JPEG encoder // JPEG encoder
pub use jpeg::JpegEncoder; pub use jpeg::JpegEncoder;
pub(crate) fn select_codec_for_format<F>(
encoders: &[CodecInfo],
format: DataFormat,
preferred: F,
) -> Option<&CodecInfo>
where
F: Fn(&CodecInfo) -> bool,
{
encoders
.iter()
.find(|codec| codec.format == format && preferred(codec))
.or_else(|| encoders.iter().find(|codec| codec.format == format))
}
pub(crate) fn detect_best_codec_for_format<T, F>(
encoders: &[CodecInfo],
format: DataFormat,
preferred: F,
) -> Option<(T, String)>
where
T: From<EncoderBackend>,
F: Fn(&CodecInfo) -> bool,
{
select_codec_for_format(encoders, format, preferred).map(|codec| {
(
T::from(EncoderBackend::from_codec_name(&codec.name)),
codec.name.clone(),
)
})
}

View File

@@ -7,6 +7,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::Duration;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use hwcodec::common::{DataFormat, Quality, RateControl}; use hwcodec::common::{DataFormat, Quality, RateControl};
@@ -28,6 +29,10 @@ pub enum VideoEncoderType {
} }
impl VideoEncoderType { impl VideoEncoderType {
pub const fn ordered() -> [Self; 4] {
[Self::H264, Self::H265, Self::VP8, Self::VP9]
}
/// Convert to hwcodec DataFormat /// Convert to hwcodec DataFormat
pub fn to_data_format(&self) -> DataFormat { pub fn to_data_format(&self) -> DataFormat {
match self { match self {
@@ -68,17 +73,6 @@ impl VideoEncoderType {
VideoEncoderType::VP9 => "VP9", VideoEncoderType::VP9 => "VP9",
} }
} }
/// Check if this format requires hardware-only encoding
/// H264 supports software fallback, others require hardware
pub fn hardware_only(&self) -> bool {
match self {
VideoEncoderType::H264 => false,
VideoEncoderType::H265 => true,
VideoEncoderType::VP8 => true,
VideoEncoderType::VP9 => true,
}
}
} }
impl std::fmt::Display for VideoEncoderType { impl std::fmt::Display for VideoEncoderType {
@@ -210,14 +204,84 @@ pub struct EncoderRegistry {
} }
impl EncoderRegistry { impl EncoderRegistry {
fn detect_encoders_with_timeout(ctx: EncodeContext, timeout: Duration) -> Vec<CodecInfo> {
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
let handle = std::thread::Builder::new()
.name("ffmpeg-encoder-detect".to_string())
.spawn(move || {
let result = HwEncoder::available_encoders(ctx, None);
let _ = tx.send(result);
});
let Ok(handle) = handle else {
warn!("Failed to spawn encoder detection thread");
return Vec::new();
};
match rx.recv_timeout(timeout) {
Ok(encoders) => {
let _ = handle.join();
encoders
}
Err(mpsc::RecvTimeoutError::Timeout) => {
warn!(
"Encoder detection timed out after {}ms, skipping hardware detection",
timeout.as_millis()
);
std::thread::spawn(move || {
let _ = handle.join();
});
Vec::new()
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
let _ = handle.join();
warn!("Encoder detection thread exited unexpectedly");
Vec::new()
}
}
}
fn register_software_fallbacks(&mut self) {
info!("Registering software encoders...");
for format in VideoEncoderType::ordered() {
let encoders = self.encoders.entry(format).or_default();
if encoders.iter().any(|encoder| !encoder.is_hardware) {
continue;
}
let codec_name = match format {
VideoEncoderType::H264 => "libx264",
VideoEncoderType::H265 => "libx265",
VideoEncoderType::VP8 => "libvpx",
VideoEncoderType::VP9 => "libvpx-vp9",
};
encoders.push(AvailableEncoder {
format,
codec_name: codec_name.to_string(),
backend: EncoderBackend::Software,
priority: 100,
is_hardware: false,
});
debug!(
"Registered software encoder: {} for {} (priority: {})",
codec_name, format, 100
);
}
}
/// Get the global registry instance /// Get the global registry instance
/// ///
/// The registry is initialized lazily on first access with 1920x1080 detection. /// The registry is initialized lazily on first access with 1280x720 detection.
pub fn global() -> &'static Self { pub fn global() -> &'static Self {
static INSTANCE: OnceLock<EncoderRegistry> = OnceLock::new(); static INSTANCE: OnceLock<EncoderRegistry> = OnceLock::new();
INSTANCE.get_or_init(|| { INSTANCE.get_or_init(|| {
let mut registry = EncoderRegistry::new(); let mut registry = EncoderRegistry::new();
registry.detect_encoders(1920, 1080); registry.detect_encoders(1280, 720);
registry registry
}) })
} }
@@ -257,32 +321,11 @@ impl EncoderRegistry {
}; };
const DETECT_TIMEOUT_MS: u64 = 5000; const DETECT_TIMEOUT_MS: u64 = 5000;
// Get all available encoders from hwcodec with a hard timeout
let all_encoders = {
use std::sync::mpsc;
use std::time::Duration;
info!("Encoder detection timeout: {}ms", DETECT_TIMEOUT_MS); info!("Encoder detection timeout: {}ms", DETECT_TIMEOUT_MS);
let all_encoders = Self::detect_encoders_with_timeout(
let (tx, rx) = mpsc::channel(); ctx.clone(),
let ctx_clone = ctx.clone(); Duration::from_millis(DETECT_TIMEOUT_MS),
std::thread::spawn(move || {
let result = HwEncoder::available_encoders(ctx_clone, None);
let _ = tx.send(result);
});
match rx.recv_timeout(Duration::from_millis(DETECT_TIMEOUT_MS)) {
Ok(encoders) => encoders,
Err(_) => {
warn!(
"Encoder detection timed out after {}ms, skipping hardware detection",
DETECT_TIMEOUT_MS
); );
Vec::new()
}
}
};
info!("Found {} encoders from hwcodec", all_encoders.len()); info!("Found {} encoders from hwcodec", all_encoders.len());
@@ -305,32 +348,7 @@ impl EncoderRegistry {
encoders.sort_by_key(|e| e.priority); encoders.sort_by_key(|e| e.priority);
} }
// Register software encoders as fallback self.register_software_fallbacks();
info!("Registering software encoders...");
let software_encoders = [
(VideoEncoderType::H264, "libx264", 100),
(VideoEncoderType::H265, "libx265", 100),
(VideoEncoderType::VP8, "libvpx", 100),
(VideoEncoderType::VP9, "libvpx-vp9", 100),
];
for (format, codec_name, priority) in software_encoders {
self.encoders
.entry(format)
.or_default()
.push(AvailableEncoder {
format,
codec_name: codec_name.to_string(),
backend: EncoderBackend::Software,
priority,
is_hardware: false,
});
debug!(
"Registered software encoder: {} for {} (priority: {})",
codec_name, format, priority
);
}
// Log summary // Log summary
for (format, encoders) in &self.encoders { for (format, encoders) in &self.encoders {
@@ -370,6 +388,10 @@ impl EncoderRegistry {
) )
} }
pub fn best_available_encoder(&self, format: VideoEncoderType) -> Option<&AvailableEncoder> {
self.best_encoder(format, false)
}
/// Get all encoders for a format /// Get all encoders for a format
pub fn encoders_for_format(&self, format: VideoEncoderType) -> &[AvailableEncoder] { pub fn encoders_for_format(&self, format: VideoEncoderType) -> &[AvailableEncoder] {
self.encoders self.encoders
@@ -405,31 +427,17 @@ impl EncoderRegistry {
self.best_encoder(format, hardware_only).is_some() self.best_encoder(format, hardware_only).is_some()
} }
pub fn is_codec_available(&self, format: VideoEncoderType) -> bool {
self.best_available_encoder(format).is_some()
}
/// Get available formats for user selection /// Get available formats for user selection
/// ///
/// Returns formats that are actually usable based on their requirements:
/// - H264: Available if any encoder exists (hardware or software)
/// - H265/VP8/VP9: Available only if hardware encoder exists
pub fn selectable_formats(&self) -> Vec<VideoEncoderType> { pub fn selectable_formats(&self) -> Vec<VideoEncoderType> {
let mut formats = Vec::new(); VideoEncoderType::ordered()
.into_iter()
// H264 - supports software fallback .filter(|format| self.is_codec_available(*format))
if self.is_format_available(VideoEncoderType::H264, false) { .collect()
formats.push(VideoEncoderType::H264);
}
// H265/VP8/VP9 - hardware only
for format in [
VideoEncoderType::H265,
VideoEncoderType::VP8,
VideoEncoderType::VP9,
] {
if self.is_format_available(format, true) {
formats.push(format);
}
}
formats
} }
/// Get detection resolution /// Get detection resolution
@@ -534,11 +542,16 @@ mod tests {
} }
#[test] #[test]
fn test_hardware_only_requirement() { fn test_codec_ordering() {
assert!(!VideoEncoderType::H264.hardware_only()); assert_eq!(
assert!(VideoEncoderType::H265.hardware_only()); VideoEncoderType::ordered(),
assert!(VideoEncoderType::VP8.hardware_only()); [
assert!(VideoEncoderType::VP9.hardware_only()); VideoEncoderType::H264,
VideoEncoderType::H265,
VideoEncoderType::VP8,
VideoEncoderType::VP9,
]
);
} }
#[test] #[test]

View File

@@ -0,0 +1,335 @@
use serde::Serialize;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use super::{
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
VP9Config, VP9Encoder, VideoEncoderType,
};
use crate::error::{AppError, Result};
use crate::video::format::{PixelFormat, Resolution};
const SELF_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
const SELF_CHECK_FRAME_ATTEMPTS: u64 = 3;
#[derive(Serialize)]
pub struct VideoEncoderSelfCheckCodec {
pub id: &'static str,
pub name: &'static str,
}
#[derive(Serialize)]
pub struct VideoEncoderSelfCheckCell {
pub codec_id: &'static str,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub elapsed_ms: Option<u64>,
}
#[derive(Serialize)]
pub struct VideoEncoderSelfCheckRow {
pub resolution_id: &'static str,
pub resolution_label: &'static str,
pub width: u32,
pub height: u32,
pub cells: Vec<VideoEncoderSelfCheckCell>,
}
#[derive(Serialize)]
pub struct VideoEncoderSelfCheckResponse {
pub current_hardware_encoder: String,
pub codecs: Vec<VideoEncoderSelfCheckCodec>,
pub rows: Vec<VideoEncoderSelfCheckRow>,
}
pub fn run_hardware_self_check() -> VideoEncoderSelfCheckResponse {
let registry = EncoderRegistry::global();
let codecs = codec_columns();
let mut rows = Vec::new();
for (resolution_id, resolution_label, resolution) in test_resolutions() {
let mut cells = Vec::new();
for codec in test_codecs() {
let cell = match registry.best_encoder(codec, true) {
Some(encoder) => run_single_check(codec, resolution, encoder.codec_name.clone()),
None => unsupported_cell(codec),
};
cells.push(cell);
}
rows.push(VideoEncoderSelfCheckRow {
resolution_id,
resolution_label,
width: resolution.width,
height: resolution.height,
cells,
});
}
VideoEncoderSelfCheckResponse {
current_hardware_encoder: current_hardware_encoder(registry),
codecs,
rows,
}
}
pub fn build_hardware_self_check_runtime_error() -> VideoEncoderSelfCheckResponse {
let codecs = codec_columns();
let mut rows = Vec::new();
for (resolution_id, resolution_label, resolution) in test_resolutions() {
let cells = test_codecs()
.into_iter()
.map(|codec| VideoEncoderSelfCheckCell {
codec_id: codec_id(codec),
ok: false,
elapsed_ms: None,
})
.collect();
rows.push(VideoEncoderSelfCheckRow {
resolution_id,
resolution_label,
width: resolution.width,
height: resolution.height,
cells,
});
}
VideoEncoderSelfCheckResponse {
current_hardware_encoder: "None".to_string(),
codecs,
rows,
}
}
fn codec_columns() -> Vec<VideoEncoderSelfCheckCodec> {
test_codecs()
.into_iter()
.map(|codec| VideoEncoderSelfCheckCodec {
id: codec_id(codec),
name: match codec {
VideoEncoderType::H265 => "H.265",
_ => codec.display_name(),
},
})
.collect()
}
fn test_codecs() -> [VideoEncoderType; 4] {
[
VideoEncoderType::H264,
VideoEncoderType::H265,
VideoEncoderType::VP8,
VideoEncoderType::VP9,
]
}
fn test_resolutions() -> [(&'static str, &'static str, Resolution); 4] {
[
("720p", "720p", Resolution::HD720),
("1080p", "1080p", Resolution::HD1080),
("2k", "2K", Resolution::new(2560, 1440)),
("4k", "4K", Resolution::UHD4K),
]
}
fn codec_id(codec: VideoEncoderType) -> &'static str {
match codec {
VideoEncoderType::H264 => "h264",
VideoEncoderType::H265 => "h265",
VideoEncoderType::VP8 => "vp8",
VideoEncoderType::VP9 => "vp9",
}
}
fn unsupported_cell(codec: VideoEncoderType) -> VideoEncoderSelfCheckCell {
VideoEncoderSelfCheckCell {
codec_id: codec_id(codec),
ok: false,
elapsed_ms: None,
}
}
fn run_single_check(
codec: VideoEncoderType,
resolution: Resolution,
codec_name_ffmpeg: String,
) -> VideoEncoderSelfCheckCell {
let started = Instant::now();
let (tx, rx) = mpsc::channel();
let thread_codec_name = codec_name_ffmpeg.clone();
let spawn_result = std::thread::Builder::new()
.name(format!(
"encoder-self-check-{}-{}x{}",
codec_id(codec),
resolution.width,
resolution.height
))
.spawn(move || {
let _ = tx.send(run_smoke_test(codec, resolution, &thread_codec_name));
});
if let Err(e) = spawn_result {
let _ = e;
return VideoEncoderSelfCheckCell {
codec_id: codec_id(codec),
ok: false,
elapsed_ms: Some(started.elapsed().as_millis() as u64),
};
}
match rx.recv_timeout(SELF_CHECK_TIMEOUT) {
Ok(Ok(())) => VideoEncoderSelfCheckCell {
codec_id: codec_id(codec),
ok: true,
elapsed_ms: Some(started.elapsed().as_millis() as u64),
},
Ok(Err(_)) => VideoEncoderSelfCheckCell {
codec_id: codec_id(codec),
ok: false,
elapsed_ms: Some(started.elapsed().as_millis() as u64),
},
Err(mpsc::RecvTimeoutError::Timeout) => VideoEncoderSelfCheckCell {
codec_id: codec_id(codec),
ok: false,
elapsed_ms: Some(started.elapsed().as_millis() as u64),
},
Err(mpsc::RecvTimeoutError::Disconnected) => VideoEncoderSelfCheckCell {
codec_id: codec_id(codec),
ok: false,
elapsed_ms: Some(started.elapsed().as_millis() as u64),
},
}
}
fn current_hardware_encoder(registry: &EncoderRegistry) -> String {
let backends = registry
.available_backends()
.into_iter()
.filter(|backend| backend.is_hardware())
.map(|backend| backend.display_name().to_string())
.collect::<Vec<_>>();
if backends.is_empty() {
"None".to_string()
} else {
backends.join("/")
}
}
fn run_smoke_test(
codec: VideoEncoderType,
resolution: Resolution,
codec_name_ffmpeg: &str,
) -> Result<()> {
match codec {
VideoEncoderType::H264 => run_h264_smoke_test(resolution, codec_name_ffmpeg),
VideoEncoderType::H265 => run_h265_smoke_test(resolution, codec_name_ffmpeg),
VideoEncoderType::VP8 => run_vp8_smoke_test(resolution, codec_name_ffmpeg),
VideoEncoderType::VP9 => run_vp9_smoke_test(resolution, codec_name_ffmpeg),
}
}
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
let mut encoder = H264Encoder::with_codec(
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
codec_name_ffmpeg,
)?;
encoder.request_keyframe();
let frame = build_nv12_test_frame(resolution, encoder.yuv_info().2 as usize);
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
if frames.iter().any(|frame| !frame.data.is_empty()) {
return Ok(());
}
}
Err(AppError::VideoError(
"Encoder produced no output after multiple frames".to_string(),
))
}
fn run_h265_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
let mut encoder = H265Encoder::with_codec(
H265Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
codec_name_ffmpeg,
)?;
encoder.request_keyframe();
let frame = build_nv12_test_frame(resolution, encoder.buffer_info().2 as usize);
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
if frames.iter().any(|frame| !frame.data.is_empty()) {
return Ok(());
}
}
Err(AppError::VideoError(
"Encoder produced no output after multiple frames".to_string(),
))
}
fn run_vp8_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
let mut encoder = VP8Encoder::with_codec(
VP8Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
codec_name_ffmpeg,
)?;
let frame = build_nv12_test_frame(resolution, encoder.buffer_info().2 as usize);
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
if frames.iter().any(|frame| !frame.data.is_empty()) {
return Ok(());
}
}
Err(AppError::VideoError(
"Encoder produced no output after multiple frames".to_string(),
))
}
fn run_vp9_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
let mut encoder = VP9Encoder::with_codec(
VP9Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
codec_name_ffmpeg,
)?;
let frame = build_nv12_test_frame(resolution, encoder.buffer_info().2 as usize);
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
if frames.iter().any(|frame| !frame.data.is_empty()) {
return Ok(());
}
}
Err(AppError::VideoError(
"Encoder produced no output after multiple frames".to_string(),
))
}
fn build_nv12_test_frame(resolution: Resolution, buffer_length: usize) -> Vec<u8> {
let minimum_length = PixelFormat::Nv12.frame_size(resolution).unwrap_or(0);
let mut frame = vec![0x80; buffer_length.max(minimum_length)];
let y_plane_len = (resolution.width * resolution.height) as usize;
let fill_len = y_plane_len.min(frame.len());
frame[..fill_len].fill(0x10);
frame
}
fn bitrate_kbps_for_resolution(resolution: Resolution) -> u32 {
match resolution.width {
0..=1280 => 4_000,
1281..=1920 => 8_000,
1921..=2560 => 12_000,
_ => 20_000,
}
}
fn pts_ms(sequence: u64) -> i64 {
((sequence * 1000) / 30) as i64
}

View File

@@ -15,6 +15,7 @@ use hwcodec::ffmpeg::AVPixelFormat;
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder}; use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
use hwcodec::ffmpeg_ram::CodecInfo; use hwcodec::ffmpeg_ram::CodecInfo;
use super::detect_best_codec_for_format;
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType}; use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig}; use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
@@ -156,32 +157,24 @@ pub fn get_available_vp8_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
pub fn detect_best_vp8_encoder(width: u32, height: u32) -> (VP8EncoderType, Option<String>) { pub fn detect_best_vp8_encoder(width: u32, height: u32) -> (VP8EncoderType, Option<String>) {
let encoders = get_available_vp8_encoders(width, height); let encoders = get_available_vp8_encoders(width, height);
if encoders.is_empty() {
warn!("No VP8 encoders available");
return (VP8EncoderType::None, None);
}
// Prefer hardware encoders (VAAPI) over software (libvpx) // Prefer hardware encoders (VAAPI) over software (libvpx)
let codec = encoders if let Some((encoder_type, codec_name)) =
.iter() detect_best_codec_for_format(&encoders, DataFormat::VP8, |codec| {
.find(|e| e.name.contains("vaapi")) codec.name.contains("vaapi")
.or_else(|| encoders.first()) })
.unwrap(); {
info!("Selected VP8 encoder: {} ({})", codec_name, encoder_type);
let encoder_type = if codec.name.contains("vaapi") { (encoder_type, Some(codec_name))
VP8EncoderType::Vaapi
} else { } else {
VP8EncoderType::Software // Default to software for unknown warn!("No VP8 encoders available");
}; (VP8EncoderType::None, None)
}
info!("Selected VP8 encoder: {} ({})", codec.name, encoder_type);
(encoder_type, Some(codec.name.clone()))
} }
/// Check if VP8 hardware encoding is available /// Check if VP8 hardware encoding is available
pub fn is_vp8_available() -> bool { pub fn is_vp8_available() -> bool {
let registry = EncoderRegistry::global(); let registry = EncoderRegistry::global();
registry.is_format_available(VideoEncoderType::VP8, true) registry.is_codec_available(VideoEncoderType::VP8)
} }
/// Encoded frame from hwcodec (cloned for ownership) /// Encoded frame from hwcodec (cloned for ownership)
@@ -192,7 +185,7 @@ pub struct HwEncodeFrame {
pub key: i32, pub key: i32,
} }
/// VP8 encoder using hwcodec (hardware only - VAAPI) /// VP8 encoder using hwcodec
pub struct VP8Encoder { pub struct VP8Encoder {
/// hwcodec encoder instance /// hwcodec encoder instance
inner: HwEncoder, inner: HwEncoder,

View File

@@ -15,6 +15,7 @@ use hwcodec::ffmpeg::AVPixelFormat;
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder}; use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
use hwcodec::ffmpeg_ram::CodecInfo; use hwcodec::ffmpeg_ram::CodecInfo;
use super::detect_best_codec_for_format;
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType}; use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig}; use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
@@ -156,32 +157,24 @@ pub fn get_available_vp9_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
pub fn detect_best_vp9_encoder(width: u32, height: u32) -> (VP9EncoderType, Option<String>) { pub fn detect_best_vp9_encoder(width: u32, height: u32) -> (VP9EncoderType, Option<String>) {
let encoders = get_available_vp9_encoders(width, height); let encoders = get_available_vp9_encoders(width, height);
if encoders.is_empty() {
warn!("No VP9 encoders available");
return (VP9EncoderType::None, None);
}
// Prefer hardware encoders (VAAPI) over software (libvpx-vp9) // Prefer hardware encoders (VAAPI) over software (libvpx-vp9)
let codec = encoders if let Some((encoder_type, codec_name)) =
.iter() detect_best_codec_for_format(&encoders, DataFormat::VP9, |codec| {
.find(|e| e.name.contains("vaapi")) codec.name.contains("vaapi")
.or_else(|| encoders.first()) })
.unwrap(); {
info!("Selected VP9 encoder: {} ({})", codec_name, encoder_type);
let encoder_type = if codec.name.contains("vaapi") { (encoder_type, Some(codec_name))
VP9EncoderType::Vaapi
} else { } else {
VP9EncoderType::Software // Default to software for unknown warn!("No VP9 encoders available");
}; (VP9EncoderType::None, None)
}
info!("Selected VP9 encoder: {} ({})", codec.name, encoder_type);
(encoder_type, Some(codec.name.clone()))
} }
/// Check if VP9 hardware encoding is available /// Check if VP9 hardware encoding is available
pub fn is_vp9_available() -> bool { pub fn is_vp9_available() -> bool {
let registry = EncoderRegistry::global(); let registry = EncoderRegistry::global();
registry.is_format_available(VideoEncoderType::VP9, true) registry.is_codec_available(VideoEncoderType::VP9)
} }
/// Encoded frame from hwcodec (cloned for ownership) /// Encoded frame from hwcodec (cloned for ownership)
@@ -192,7 +185,7 @@ pub struct HwEncodeFrame {
pub key: i32, pub key: i32,
} }
/// VP9 encoder using hwcodec (hardware only - VAAPI) /// VP9 encoder using hwcodec
pub struct VP9Encoder { pub struct VP9Encoder {
/// hwcodec encoder instance /// hwcodec encoder instance
inner: HwEncoder, inner: HwEncoder,

View File

@@ -1,444 +0,0 @@
//! H264 video encoding pipeline for WebRTC streaming
//!
//! This module provides a complete H264 encoding pipeline that connects:
//! 1. Video capture (YUYV/MJPEG from V4L2)
//! 2. Pixel conversion (YUYV → YUV420P) or JPEG decode
//! 3. H264 encoding (via hwcodec)
//! 4. RTP packetization and WebRTC track output
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{broadcast, watch, Mutex};
use tracing::{debug, error, info, warn};
use crate::error::{AppError, Result};
use crate::video::convert::Nv12Converter;
use crate::video::encoder::h264::{H264Config, H264Encoder};
use crate::video::format::{PixelFormat, Resolution};
use crate::webrtc::rtp::{H264VideoTrack, H264VideoTrackConfig};
/// H264 pipeline configuration
#[derive(Debug, Clone)]
pub struct H264PipelineConfig {
/// Input resolution
pub resolution: Resolution,
/// Input pixel format (YUYV, NV12, etc.)
pub input_format: PixelFormat,
/// Target bitrate in kbps
pub bitrate_kbps: u32,
/// Target FPS
pub fps: u32,
/// GOP size (keyframe interval in frames)
pub gop_size: u32,
/// Track ID for WebRTC
pub track_id: String,
/// Stream ID for WebRTC
pub stream_id: String,
}
impl Default for H264PipelineConfig {
fn default() -> Self {
Self {
resolution: Resolution::HD720,
input_format: PixelFormat::Yuyv,
bitrate_kbps: 8000,
fps: 30,
gop_size: 30,
track_id: "video0".to_string(),
stream_id: "one-kvm-stream".to_string(),
}
}
}
/// H264 pipeline statistics
#[derive(Debug, Clone, Default)]
pub struct H264PipelineStats {
/// Current encoding FPS
pub current_fps: f32,
}
/// H264 video encoding pipeline
pub struct H264Pipeline {
config: H264PipelineConfig,
/// H264 encoder instance
encoder: Arc<Mutex<Option<H264Encoder>>>,
/// NV12 converter (for BGR24/RGB24/YUYV → NV12)
nv12_converter: Arc<Mutex<Option<Nv12Converter>>>,
/// WebRTC video track
video_track: Arc<H264VideoTrack>,
/// Pipeline statistics
stats: Arc<Mutex<H264PipelineStats>>,
/// Running state
running: watch::Sender<bool>,
}
impl H264Pipeline {
/// Create a new H264 pipeline
pub fn new(config: H264PipelineConfig) -> Result<Self> {
info!(
"Creating H264 pipeline: {}x{} @ {} kbps, {} fps",
config.resolution.width, config.resolution.height, config.bitrate_kbps, config.fps
);
// Determine encoder input format based on pipeline input
// NV12 is optimal for VAAPI, use it for all formats
// VAAPI encoders typically only support NV12 input
let encoder_input_format = crate::video::encoder::h264::H264InputFormat::Nv12;
// Create H264 encoder with appropriate input format
let encoder_config = H264Config {
base: crate::video::encoder::traits::EncoderConfig::h264(
config.resolution,
config.bitrate_kbps,
),
bitrate_kbps: config.bitrate_kbps,
gop_size: config.gop_size,
fps: config.fps,
input_format: encoder_input_format,
};
let encoder = H264Encoder::new(encoder_config)?;
info!(
"H264 encoder created: {} ({}) with {:?} input",
encoder.codec_name(),
encoder.encoder_type(),
encoder_input_format
);
// Create NV12 converter based on input format
// All formats are converted to NV12 for VAAPI encoder
let nv12_converter = match config.input_format {
// NV12 input - direct passthrough
PixelFormat::Nv12 => {
info!("NV12 input: direct passthrough to encoder");
None
}
// YUYV (4:2:2 packed) → NV12
PixelFormat::Yuyv => {
info!("YUYV input: converting to NV12");
Some(Nv12Converter::yuyv_to_nv12(config.resolution))
}
// RGB24 → NV12
PixelFormat::Rgb24 => {
info!("RGB24 input: converting to NV12");
Some(Nv12Converter::rgb24_to_nv12(config.resolution))
}
// BGR24 → NV12
PixelFormat::Bgr24 => {
info!("BGR24 input: converting to NV12");
Some(Nv12Converter::bgr24_to_nv12(config.resolution))
}
// MJPEG/JPEG input - not supported (requires libjpeg for decoding)
PixelFormat::Mjpeg | PixelFormat::Jpeg => {
return Err(AppError::VideoError(
"MJPEG input format not supported in this build".to_string(),
));
}
_ => {
return Err(AppError::VideoError(format!(
"Unsupported input format for H264 pipeline: {}",
config.input_format
)));
}
};
// Create WebRTC video track
let track_config = H264VideoTrackConfig {
track_id: config.track_id.clone(),
stream_id: config.stream_id.clone(),
resolution: config.resolution,
bitrate_kbps: config.bitrate_kbps,
fps: config.fps,
profile_level_id: None, // Let browser negotiate the best profile
};
let video_track = Arc::new(H264VideoTrack::new(track_config));
let (running_tx, _) = watch::channel(false);
Ok(Self {
config,
encoder: Arc::new(Mutex::new(Some(encoder))),
nv12_converter: Arc::new(Mutex::new(nv12_converter)),
video_track,
stats: Arc::new(Mutex::new(H264PipelineStats::default())),
running: running_tx,
})
}
/// Get the WebRTC video track
pub fn video_track(&self) -> Arc<H264VideoTrack> {
self.video_track.clone()
}
/// Get current statistics
pub async fn stats(&self) -> H264PipelineStats {
self.stats.lock().await.clone()
}
/// Check if pipeline is running
pub fn is_running(&self) -> bool {
*self.running.borrow()
}
/// Start the encoding pipeline
///
/// This starts a background task that receives raw frames from the receiver,
/// encodes them to H264, and sends them to the WebRTC track.
pub async fn start(&self, mut frame_rx: broadcast::Receiver<Vec<u8>>) {
if *self.running.borrow() {
warn!("H264 pipeline already running");
return;
}
let _ = self.running.send(true);
info!(
"Starting H264 pipeline (input format: {})",
self.config.input_format
);
let encoder = self.encoder.lock().await.take();
let nv12_converter = self.nv12_converter.lock().await.take();
let video_track = self.video_track.clone();
let stats = self.stats.clone();
let config = self.config.clone();
let mut running_rx = self.running.subscribe();
// Spawn encoding task
tokio::spawn(async move {
let mut encoder = match encoder {
Some(e) => e,
None => {
error!("No encoder available");
return;
}
};
let mut nv12_converter = nv12_converter;
let mut frame_count: u64 = 0;
let mut last_fps_time = Instant::now();
let mut fps_frame_count: u64 = 0;
// Flag for one-time warnings
let mut size_mismatch_warned = false;
loop {
tokio::select! {
biased;
_ = running_rx.changed() => {
if !*running_rx.borrow() {
info!("H264 pipeline stopping");
break;
}
}
result = frame_rx.recv() => {
match result {
Ok(raw_frame) => {
let start = Instant::now();
// Validate frame size for uncompressed formats
if let Some(expected_size) = config.input_format.frame_size(config.resolution) {
if raw_frame.len() != expected_size && !size_mismatch_warned {
warn!(
"Frame size mismatch: got {} bytes, expected {} for {} {}x{}",
raw_frame.len(),
expected_size,
config.input_format,
config.resolution.width,
config.resolution.height
);
size_mismatch_warned = true;
}
}
// Convert to NV12 for VAAPI encoder
// BGR24/RGB24/YUYV -> NV12 (via NV12 converter)
// NV12 -> pass through
//
// Optimized: avoid unnecessary allocations and copies
frame_count += 1;
fps_frame_count += 1;
let pts_ms = (frame_count * 1000 / config.fps as u64) as i64;
let encode_result = if let Some(ref mut conv) = nv12_converter {
// BGR24/RGB24/YUYV input - convert to NV12
// Optimized: pass reference directly without copy
match conv.convert(&raw_frame) {
Ok(nv12_data) => encoder.encode_raw(nv12_data, pts_ms),
Err(e) => {
error!("NV12 conversion failed: {}", e);
continue;
}
}
} else {
// NV12 input - pass reference directly
encoder.encode_raw(&raw_frame, pts_ms)
};
match encode_result {
Ok(frames) => {
if !frames.is_empty() {
let frame = &frames[0];
let is_keyframe = frame.key == 1;
// Send to WebRTC track
let duration = Duration::from_millis(
1000 / config.fps as u64
);
if let Err(e) = video_track
.write_frame(&frame.data, duration, is_keyframe)
.await
{
error!("Failed to write frame to track: {}", e);
} else {
let _ = start;
}
}
}
Err(e) => {
error!("Encoding failed: {}", e);
}
}
// Update FPS every second
if last_fps_time.elapsed() >= Duration::from_secs(1) {
let mut s = stats.lock().await;
s.current_fps = fps_frame_count as f32
/ last_fps_time.elapsed().as_secs_f32();
fps_frame_count = 0;
last_fps_time = Instant::now();
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
let _ = n;
}
Err(broadcast::error::RecvError::Closed) => {
info!("Frame channel closed, stopping H264 pipeline");
break;
}
}
}
}
}
info!("H264 pipeline task exited");
});
}
/// Stop the encoding pipeline
pub fn stop(&self) {
if *self.running.borrow() {
let _ = self.running.send(false);
info!("Stopping H264 pipeline");
}
}
/// Request a keyframe (force IDR)
pub async fn request_keyframe(&self) {
// Note: hwcodec doesn't support on-demand keyframe requests
// The encoder will produce keyframes based on GOP size
debug!("Keyframe requested (will occur at next GOP boundary)");
}
/// Update bitrate dynamically
pub async fn set_bitrate(&self, bitrate_kbps: u32) -> Result<()> {
if let Some(ref mut encoder) = *self.encoder.lock().await {
encoder.set_bitrate(bitrate_kbps)?;
info!("H264 pipeline bitrate updated to {} kbps", bitrate_kbps);
}
Ok(())
}
}
/// Builder for H264 pipeline configuration
pub struct H264PipelineBuilder {
config: H264PipelineConfig,
}
impl H264PipelineBuilder {
pub fn new() -> Self {
Self {
config: H264PipelineConfig::default(),
}
}
pub fn resolution(mut self, resolution: Resolution) -> Self {
self.config.resolution = resolution;
self
}
pub fn input_format(mut self, format: PixelFormat) -> Self {
self.config.input_format = format;
self
}
pub fn bitrate_kbps(mut self, bitrate: u32) -> Self {
self.config.bitrate_kbps = bitrate;
self
}
pub fn fps(mut self, fps: u32) -> Self {
self.config.fps = fps;
self
}
pub fn gop_size(mut self, gop: u32) -> Self {
self.config.gop_size = gop;
self
}
pub fn track_id(mut self, id: &str) -> Self {
self.config.track_id = id.to_string();
self
}
pub fn stream_id(mut self, id: &str) -> Self {
self.config.stream_id = id.to_string();
self
}
pub fn build(self) -> Result<H264Pipeline> {
H264Pipeline::new(self.config)
}
}
impl Default for H264PipelineBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pipeline_config_default() {
let config = H264PipelineConfig::default();
assert_eq!(config.resolution, Resolution::HD720);
assert_eq!(config.bitrate_kbps, 8000);
assert_eq!(config.fps, 30);
assert_eq!(config.gop_size, 30);
}
#[test]
fn test_pipeline_builder() {
let builder = H264PipelineBuilder::new()
.resolution(Resolution::HD1080)
.bitrate_kbps(4000)
.fps(60)
.input_format(PixelFormat::Yuyv);
assert_eq!(builder.config.resolution, Resolution::HD1080);
assert_eq!(builder.config.bitrate_kbps, 4000);
assert_eq!(builder.config.fps, 60);
assert_eq!(builder.config.input_format, PixelFormat::Yuyv);
}
}

View File

@@ -2,7 +2,6 @@
//! //!
//! This module provides V4L2 video capture, encoding, and streaming functionality. //! This module provides V4L2 video capture, encoding, and streaming functionality.
pub mod capture;
pub mod codec_constraints; pub mod codec_constraints;
pub mod convert; pub mod convert;
pub mod decoder; pub mod decoder;
@@ -10,25 +9,18 @@ pub mod device;
pub mod encoder; pub mod encoder;
pub mod format; pub mod format;
pub mod frame; pub mod frame;
pub mod h264_pipeline;
pub mod shared_video_pipeline; pub mod shared_video_pipeline;
pub mod stream_manager; pub mod stream_manager;
pub mod streamer; pub mod streamer;
pub mod v4l2r_capture; pub mod v4l2r_capture;
pub mod video_session;
pub use capture::VideoCapturer;
pub use convert::{PixelConverter, Yuv420pBuffer}; pub use convert::{PixelConverter, Yuv420pBuffer};
pub use device::{VideoDevice, VideoDeviceInfo}; pub use device::{VideoDevice, VideoDeviceInfo};
pub use encoder::{H264Encoder, H264EncoderType, JpegEncoder}; pub use encoder::{H264Encoder, H264EncoderType, JpegEncoder};
pub use format::PixelFormat; pub use format::PixelFormat;
pub use frame::VideoFrame; pub use frame::VideoFrame;
pub use h264_pipeline::{H264Pipeline, H264PipelineBuilder, H264PipelineConfig};
pub use shared_video_pipeline::{ pub use shared_video_pipeline::{
EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats, EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
}; };
pub use stream_manager::VideoStreamManager; pub use stream_manager::VideoStreamManager;
pub use streamer::{Streamer, StreamerState}; pub use streamer::{Streamer, StreamerState};
pub use video_session::{
CodecInfo, VideoSessionInfo, VideoSessionManager, VideoSessionManagerConfig, VideoSessionState,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,651 @@
use crate::error::{AppError, Result};
use crate::video::convert::{Nv12Converter, PixelConverter};
use crate::video::decoder::MjpegTurboDecoder;
use crate::video::encoder::h264::{H264Config, H264Encoder, H264InputFormat};
use crate::video::encoder::h265::{H265Config, H265Encoder, H265InputFormat};
use crate::video::encoder::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
use crate::video::encoder::traits::EncoderConfig;
use crate::video::encoder::vp8::{VP8Config, VP8Encoder};
use crate::video::encoder::vp9::{VP9Config, VP9Encoder};
use crate::video::format::{PixelFormat, Resolution};
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
use hwcodec::ffmpeg_hw::{
last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline,
};
use tracing::info;
use super::SharedVideoPipelineConfig;
pub(super) struct EncoderThreadState {
pub(super) encoder: Option<Box<dyn VideoEncoderTrait + Send>>,
pub(super) mjpeg_decoder: Option<MjpegDecoderKind>,
pub(super) nv12_converter: Option<Nv12Converter>,
pub(super) yuv420p_converter: Option<PixelConverter>,
pub(super) encoder_needs_yuv420p: bool,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
pub(super) ffmpeg_hw_enabled: bool,
pub(super) fps: u32,
pub(super) codec: VideoEncoderType,
pub(super) input_format: PixelFormat,
}
pub(super) trait VideoEncoderTrait: Send {
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>>;
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()>;
fn codec_name(&self) -> &str;
fn request_keyframe(&mut self);
}
pub(super) struct EncodedFrame {
pub(super) data: Vec<u8>,
pub(super) key: i32,
}
struct H264EncoderWrapper(H264Encoder);
impl VideoEncoderTrait for H264EncoderWrapper {
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
let frames = self.0.encode_raw(data, pts_ms)?;
Ok(frames
.into_iter()
.map(|f| EncodedFrame {
data: f.data,
key: f.key,
})
.collect())
}
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
self.0.set_bitrate(bitrate_kbps)
}
fn codec_name(&self) -> &str {
self.0.codec_name()
}
fn request_keyframe(&mut self) {
self.0.request_keyframe()
}
}
struct H265EncoderWrapper(H265Encoder);
impl VideoEncoderTrait for H265EncoderWrapper {
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
let frames = self.0.encode_raw(data, pts_ms)?;
Ok(frames
.into_iter()
.map(|f| EncodedFrame {
data: f.data,
key: f.key,
})
.collect())
}
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
self.0.set_bitrate(bitrate_kbps)
}
fn codec_name(&self) -> &str {
self.0.codec_name()
}
fn request_keyframe(&mut self) {
self.0.request_keyframe()
}
}
struct VP8EncoderWrapper(VP8Encoder);
impl VideoEncoderTrait for VP8EncoderWrapper {
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
let frames = self.0.encode_raw(data, pts_ms)?;
Ok(frames
.into_iter()
.map(|f| EncodedFrame {
data: f.data,
key: f.key,
})
.collect())
}
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
self.0.set_bitrate(bitrate_kbps)
}
fn codec_name(&self) -> &str {
self.0.codec_name()
}
fn request_keyframe(&mut self) {}
}
struct VP9EncoderWrapper(VP9Encoder);
impl VideoEncoderTrait for VP9EncoderWrapper {
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
let frames = self.0.encode_raw(data, pts_ms)?;
Ok(frames
.into_iter()
.map(|f| EncodedFrame {
data: f.data,
key: f.key,
})
.collect())
}
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
self.0.set_bitrate(bitrate_kbps)
}
fn codec_name(&self) -> &str {
self.0.codec_name()
}
fn request_keyframe(&mut self) {}
}
pub(super) enum MjpegDecoderKind {
Turbo(MjpegTurboDecoder),
}
impl MjpegDecoderKind {
pub(super) fn decode(&mut self, data: &[u8]) -> Result<Vec<u8>> {
match self {
MjpegDecoderKind::Turbo(decoder) => decoder.decode_to_rgb(data),
}
}
}
pub(super) fn build_encoder_state(
config: &SharedVideoPipelineConfig,
) -> Result<EncoderThreadState> {
let registry = EncoderRegistry::global();
let get_codec_name =
|format: VideoEncoderType, backend: Option<EncoderBackend>| -> Option<String> {
match backend {
Some(b) => registry
.encoder_with_backend(format, b)
.map(|e| e.codec_name.clone()),
None => registry
.best_available_encoder(format)
.map(|e| e.codec_name.clone()),
}
};
let needs_mjpeg_decode = config.input_format.is_compressed();
let is_rkmpp_available = registry
.encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Rkmpp)
.is_some();
let use_yuyv_direct =
is_rkmpp_available && !needs_mjpeg_decode && config.input_format == PixelFormat::Yuyv;
let use_rkmpp_direct = is_rkmpp_available
&& !needs_mjpeg_decode
&& matches!(
config.input_format,
PixelFormat::Yuyv
| PixelFormat::Yuv420
| PixelFormat::Rgb24
| PixelFormat::Bgr24
| PixelFormat::Nv12
| PixelFormat::Nv16
| PixelFormat::Nv21
| PixelFormat::Nv24
);
if use_yuyv_direct {
info!("RKMPP backend detected with YUYV input, enabling YUYV direct input optimization");
} else if use_rkmpp_direct {
info!(
"RKMPP backend detected with {} input, enabling direct input optimization",
config.input_format
);
}
let selected_codec_name = match config.output_codec {
VideoEncoderType::H264 => {
if use_rkmpp_direct {
get_codec_name(VideoEncoderType::H264, Some(EncoderBackend::Rkmpp)).ok_or_else(
|| AppError::VideoError("RKMPP backend not available for H.264".to_string()),
)?
} else if let Some(ref backend) = config.encoder_backend {
get_codec_name(VideoEncoderType::H264, Some(*backend)).ok_or_else(|| {
AppError::VideoError(format!("Backend {:?} does not support H.264", backend))
})?
} else {
get_codec_name(VideoEncoderType::H264, None)
.ok_or_else(|| AppError::VideoError("No H.264 encoder available".to_string()))?
}
}
VideoEncoderType::H265 => {
if use_rkmpp_direct {
get_codec_name(VideoEncoderType::H265, Some(EncoderBackend::Rkmpp)).ok_or_else(
|| AppError::VideoError("RKMPP backend not available for H.265".to_string()),
)?
} else if let Some(ref backend) = config.encoder_backend {
get_codec_name(VideoEncoderType::H265, Some(*backend)).ok_or_else(|| {
AppError::VideoError(format!("Backend {:?} does not support H.265", backend))
})?
} else {
get_codec_name(VideoEncoderType::H265, None)
.ok_or_else(|| AppError::VideoError("No H.265 encoder available".to_string()))?
}
}
VideoEncoderType::VP8 => {
if let Some(ref backend) = config.encoder_backend {
get_codec_name(VideoEncoderType::VP8, Some(*backend)).ok_or_else(|| {
AppError::VideoError(format!("Backend {:?} does not support VP8", backend))
})?
} else {
get_codec_name(VideoEncoderType::VP8, None)
.ok_or_else(|| AppError::VideoError("No VP8 encoder available".to_string()))?
}
}
VideoEncoderType::VP9 => {
if let Some(ref backend) = config.encoder_backend {
get_codec_name(VideoEncoderType::VP9, Some(*backend)).ok_or_else(|| {
AppError::VideoError(format!("Backend {:?} does not support VP9", backend))
})?
} else {
get_codec_name(VideoEncoderType::VP9, None)
.ok_or_else(|| AppError::VideoError("No VP9 encoder available".to_string()))?
}
}
};
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
if needs_mjpeg_decode
&& is_rkmpp_encoder
&& matches!(
config.output_codec,
VideoEncoderType::H264 | VideoEncoderType::H265
)
{
info!(
"Initializing FFmpeg HW MJPEG->{} pipeline (no fallback)",
config.output_codec
);
let pipeline = HwMjpegH26xPipeline::new(HwMjpegH26xConfig {
decoder: "mjpeg_rkmpp".to_string(),
encoder: selected_codec_name.clone(),
width: config.resolution.width as i32,
height: config.resolution.height as i32,
fps: config.fps as i32,
bitrate_kbps: config.bitrate_kbps() as i32,
gop: config.gop_size() as i32,
thread_count: 1,
})
.map_err(|e| {
let detail = if e.is_empty() {
ffmpeg_hw_last_error()
} else {
e
};
AppError::VideoError(format!(
"FFmpeg HW MJPEG->{} init failed: {}",
config.output_codec, detail
))
})?;
info!("Using FFmpeg HW MJPEG->{} pipeline", config.output_codec);
return Ok(EncoderThreadState {
encoder: None,
mjpeg_decoder: None,
nv12_converter: None,
yuv420p_converter: None,
encoder_needs_yuv420p: false,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_pipeline: Some(pipeline),
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_enabled: true,
fps: config.fps,
codec: config.output_codec,
input_format: config.input_format,
});
}
let (mjpeg_decoder, pipeline_input_format) = if needs_mjpeg_decode {
info!(
"MJPEG input detected, using TurboJPEG decoder ({} -> RGB24)",
config.input_format
);
(
Some(MjpegDecoderKind::Turbo(MjpegTurboDecoder::new(
config.resolution,
)?)),
PixelFormat::Rgb24,
)
} else {
(None, config.input_format)
};
let encoder: Box<dyn VideoEncoderTrait + Send> = match config.output_codec {
VideoEncoderType::H264 => {
let codec_name = selected_codec_name.clone();
let direct_input_format = h264_direct_input_format(&codec_name, pipeline_input_format);
let input_format = direct_input_format.unwrap_or_else(|| {
if codec_name.contains("libx264") {
H264InputFormat::Yuv420p
} else {
H264InputFormat::Nv12
}
});
if use_rkmpp_direct {
info!(
"Creating H264 encoder with RKMPP backend for {} direct input (codec: {})",
config.input_format, codec_name
);
} else if let Some(ref backend) = config.encoder_backend {
info!(
"Creating H264 encoder with backend {:?} (codec: {})",
backend, codec_name
);
}
let encoder = H264Encoder::with_codec(
H264Config {
base: EncoderConfig::h264(config.resolution, config.bitrate_kbps()),
bitrate_kbps: config.bitrate_kbps(),
gop_size: config.gop_size(),
fps: config.fps,
input_format,
},
&codec_name,
)?;
info!("Created H264 encoder: {}", encoder.codec_name());
Box::new(H264EncoderWrapper(encoder))
}
VideoEncoderType::H265 => {
let codec_name = selected_codec_name.clone();
let direct_input_format = h265_direct_input_format(&codec_name, pipeline_input_format);
let input_format = direct_input_format.unwrap_or_else(|| {
if codec_name.contains("libx265") {
H265InputFormat::Yuv420p
} else {
H265InputFormat::Nv12
}
});
if use_rkmpp_direct {
info!(
"Creating H265 encoder with RKMPP backend for {} direct input (codec: {})",
config.input_format, codec_name
);
} else if let Some(ref backend) = config.encoder_backend {
info!(
"Creating H265 encoder with backend {:?} (codec: {})",
backend, codec_name
);
}
let encoder = H265Encoder::with_codec(
H265Config {
base: EncoderConfig {
resolution: config.resolution,
input_format: config.input_format,
quality: config.bitrate_kbps(),
fps: config.fps,
gop_size: config.gop_size(),
},
bitrate_kbps: config.bitrate_kbps(),
gop_size: config.gop_size(),
fps: config.fps,
input_format,
},
&codec_name,
)?;
info!("Created H265 encoder: {}", encoder.codec_name());
Box::new(H265EncoderWrapper(encoder))
}
VideoEncoderType::VP8 => {
let codec_name = selected_codec_name.clone();
if let Some(ref backend) = config.encoder_backend {
info!(
"Creating VP8 encoder with backend {:?} (codec: {})",
backend, codec_name
);
}
let encoder = VP8Encoder::with_codec(
VP8Config::low_latency(config.resolution, config.bitrate_kbps()),
&codec_name,
)?;
info!("Created VP8 encoder: {}", encoder.codec_name());
Box::new(VP8EncoderWrapper(encoder))
}
VideoEncoderType::VP9 => {
let codec_name = selected_codec_name.clone();
if let Some(ref backend) = config.encoder_backend {
info!(
"Creating VP9 encoder with backend {:?} (codec: {})",
backend, codec_name
);
}
let encoder = VP9Encoder::with_codec(
VP9Config::low_latency(config.resolution, config.bitrate_kbps()),
&codec_name,
)?;
info!("Created VP9 encoder: {}", encoder.codec_name());
Box::new(VP9EncoderWrapper(encoder))
}
};
let codec_name = encoder.codec_name();
let use_direct_input = if codec_name.contains("rkmpp") {
matches!(
pipeline_input_format,
PixelFormat::Yuyv
| PixelFormat::Yuv420
| PixelFormat::Rgb24
| PixelFormat::Bgr24
| PixelFormat::Nv12
| PixelFormat::Nv16
| PixelFormat::Nv21
| PixelFormat::Nv24
)
} else if codec_name.contains("libx264") {
matches!(
pipeline_input_format,
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
)
} else {
false
};
let needs_yuv420p = if codec_name.contains("libx264") {
!matches!(
pipeline_input_format,
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
)
} else {
codec_name.contains("libvpx") || codec_name.contains("libx265")
};
info!(
"Encoder {} needs {} format",
codec_name,
if use_direct_input {
"direct"
} else if needs_yuv420p {
"YUV420P"
} else {
"NV12"
}
);
info!(
"Initializing input format handler for: {} -> {}",
pipeline_input_format,
if use_direct_input {
"direct"
} else if needs_yuv420p {
"YUV420P"
} else {
"NV12"
}
);
let (nv12_converter, yuv420p_converter) = converters_for_pipeline(
config.resolution,
pipeline_input_format,
use_yuyv_direct,
use_direct_input,
needs_yuv420p,
)?;
Ok(EncoderThreadState {
encoder: Some(encoder),
mjpeg_decoder,
nv12_converter,
yuv420p_converter,
encoder_needs_yuv420p: needs_yuv420p,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_pipeline: None,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_enabled: false,
fps: config.fps,
codec: config.output_codec,
input_format: config.input_format,
})
}
fn h264_direct_input_format(
codec_name: &str,
input_format: PixelFormat,
) -> Option<H264InputFormat> {
if codec_name.contains("rkmpp") {
match input_format {
PixelFormat::Yuyv => Some(H264InputFormat::Yuyv422),
PixelFormat::Yuv420 => Some(H264InputFormat::Yuv420p),
PixelFormat::Rgb24 => Some(H264InputFormat::Rgb24),
PixelFormat::Bgr24 => Some(H264InputFormat::Bgr24),
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
PixelFormat::Nv16 => Some(H264InputFormat::Nv16),
PixelFormat::Nv21 => Some(H264InputFormat::Nv21),
PixelFormat::Nv24 => Some(H264InputFormat::Nv24),
_ => None,
}
} else if codec_name.contains("libx264") {
match input_format {
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
PixelFormat::Nv16 => Some(H264InputFormat::Nv16),
PixelFormat::Nv21 => Some(H264InputFormat::Nv21),
PixelFormat::Yuv420 => Some(H264InputFormat::Yuv420p),
_ => None,
}
} else {
None
}
}
fn h265_direct_input_format(
codec_name: &str,
input_format: PixelFormat,
) -> Option<H265InputFormat> {
if codec_name.contains("rkmpp") {
match input_format {
PixelFormat::Yuyv => Some(H265InputFormat::Yuyv422),
PixelFormat::Yuv420 => Some(H265InputFormat::Yuv420p),
PixelFormat::Rgb24 => Some(H265InputFormat::Rgb24),
PixelFormat::Bgr24 => Some(H265InputFormat::Bgr24),
PixelFormat::Nv12 => Some(H265InputFormat::Nv12),
PixelFormat::Nv16 => Some(H265InputFormat::Nv16),
PixelFormat::Nv21 => Some(H265InputFormat::Nv21),
PixelFormat::Nv24 => Some(H265InputFormat::Nv24),
_ => None,
}
} else if codec_name.contains("libx265") {
match input_format {
PixelFormat::Yuv420 => Some(H265InputFormat::Yuv420p),
_ => None,
}
} else {
None
}
}
fn converters_for_pipeline(
resolution: Resolution,
input_format: PixelFormat,
use_yuyv_direct: bool,
use_direct_input: bool,
needs_yuv420p: bool,
) -> Result<(Option<Nv12Converter>, Option<PixelConverter>)> {
if use_yuyv_direct {
info!("YUYV direct input enabled for RKMPP, skipping format conversion");
return Ok((None, None));
}
if use_direct_input {
info!("Direct input enabled, skipping format conversion");
return Ok((None, None));
}
if needs_yuv420p {
return match input_format {
PixelFormat::Yuv420 => {
info!("Using direct YUV420P input (no conversion)");
Ok((None, None))
}
PixelFormat::Yuyv => {
info!("Using YUYV->YUV420P converter");
Ok((None, Some(PixelConverter::yuyv_to_yuv420p(resolution))))
}
PixelFormat::Nv12 => {
info!("Using NV12->YUV420P converter");
Ok((None, Some(PixelConverter::nv12_to_yuv420p(resolution))))
}
PixelFormat::Nv21 => {
info!("Using NV21->YUV420P converter");
Ok((None, Some(PixelConverter::nv21_to_yuv420p(resolution))))
}
PixelFormat::Rgb24 => {
info!("Using RGB24->YUV420P converter");
Ok((None, Some(PixelConverter::rgb24_to_yuv420p(resolution))))
}
PixelFormat::Bgr24 => {
info!("Using BGR24->YUV420P converter");
Ok((None, Some(PixelConverter::bgr24_to_yuv420p(resolution))))
}
_ => Err(AppError::VideoError(format!(
"Unsupported input format for software encoding: {}",
input_format
))),
};
}
match input_format {
PixelFormat::Nv12 => {
info!("Using direct NV12 input (no conversion)");
Ok((None, None))
}
PixelFormat::Yuyv => {
info!("Using YUYV->NV12 converter");
Ok((Some(Nv12Converter::yuyv_to_nv12(resolution)), None))
}
PixelFormat::Nv21 => {
info!("Using NV21->NV12 converter");
Ok((Some(Nv12Converter::nv21_to_nv12(resolution)), None))
}
PixelFormat::Nv16 => {
info!("Using NV16->NV12 converter");
Ok((Some(Nv12Converter::nv16_to_nv12(resolution)), None))
}
PixelFormat::Yuv420 => {
info!("Using YUV420P->NV12 converter");
Ok((Some(Nv12Converter::yuv420_to_nv12(resolution)), None))
}
PixelFormat::Rgb24 => {
info!("Using RGB24->NV12 converter");
Ok((Some(Nv12Converter::rgb24_to_nv12(resolution)), None))
}
PixelFormat::Bgr24 => {
info!("Using BGR24->NV12 converter");
Ok((Some(Nv12Converter::bgr24_to_nv12(resolution)), None))
}
_ => Err(AppError::VideoError(format!(
"Unsupported input format for hardware encoding: {}",
input_format
))),
}
}

View File

@@ -12,7 +12,6 @@
//! │ //! │
//! ├── MJPEG Mode //! ├── MJPEG Mode
//! │ └── Streamer ──► MjpegStreamHandler //! │ └── Streamer ──► MjpegStreamHandler
//! │ (Future: MjpegStreamer with WsAudio/WsHid)
//! │ //! │
//! └── WebRTC Mode //! └── WebRTC Mode
//! └── WebRtcStreamer ──► H264SessionManager //! └── WebRtcStreamer ──► H264SessionManager
@@ -211,21 +210,7 @@ impl VideoStreamManager {
} }
} }
// Configure WebRTC capture source after initialization self.sync_webrtc_capture_source("after init").await;
let (device_path, resolution, format, fps, jpeg_quality) =
self.streamer.current_capture_config().await;
info!(
"WebRTC capture config after init: {}x{} {:?} @ {}fps",
resolution.width, resolution.height, format, fps
);
self.webrtc_streamer
.update_video_config(resolution, format, fps)
.await;
if let Some(device_path) = device_path {
self.webrtc_streamer
.set_capture_device(device_path, jpeg_quality)
.await;
}
Ok(()) Ok(())
} }
@@ -351,11 +336,17 @@ impl VideoStreamManager {
} }
} }
self.sync_webrtc_capture_source("for WebRTC ensure").await;
Ok(())
}
async fn sync_webrtc_capture_source(&self, reason: &str) {
let (device_path, resolution, format, fps, jpeg_quality) = let (device_path, resolution, format, fps, jpeg_quality) =
self.streamer.current_capture_config().await; self.streamer.current_capture_config().await;
info!( info!(
"Configuring WebRTC capture: {}x{} {:?} @ {}fps", "Syncing WebRTC capture source {}: {}x{} {:?} @ {}fps",
resolution.width, resolution.height, format, fps reason, resolution.width, resolution.height, format, fps
); );
self.webrtc_streamer self.webrtc_streamer
.update_video_config(resolution, format, fps) .update_video_config(resolution, format, fps)
@@ -364,9 +355,9 @@ impl VideoStreamManager {
self.webrtc_streamer self.webrtc_streamer
.set_capture_device(device_path, jpeg_quality) .set_capture_device(device_path, jpeg_quality)
.await; .await;
} else {
warn!("No capture device configured while syncing WebRTC capture source");
} }
Ok(())
} }
/// Internal implementation of mode switching (called with lock held) /// Internal implementation of mode switching (called with lock held)
@@ -471,22 +462,7 @@ impl VideoStreamManager {
} }
} }
let (device_path, resolution, format, fps, jpeg_quality) = self.sync_webrtc_capture_source("for WebRTC mode").await;
self.streamer.current_capture_config().await;
info!(
"Configuring WebRTC capture pipeline: {}x{} {:?} @ {}fps",
resolution.width, resolution.height, format, fps
);
self.webrtc_streamer
.update_video_config(resolution, format, fps)
.await;
if let Some(device_path) = device_path {
self.webrtc_streamer
.set_capture_device(device_path, jpeg_quality)
.await;
} else {
warn!("No capture device configured for WebRTC");
}
let codec = self.webrtc_streamer.current_video_codec().await; let codec = self.webrtc_streamer.current_video_codec().await;
let is_hardware = self.webrtc_streamer.is_hardware_encoding().await; let is_hardware = self.webrtc_streamer.is_hardware_encoding().await;
@@ -532,17 +508,30 @@ impl VideoStreamManager {
device_path, format, resolution.width, resolution.height, fps, mode device_path, format, resolution.width, resolution.height, fps, mode
); );
if mode == StreamMode::WebRTC {
// Stop the shared pipeline before replacing the capture source so WebRTC
// sessions do not stay attached to a stale frame source.
self.webrtc_streamer
.update_video_config(resolution, format, fps)
.await;
info!("WebRTC streamer config updated (pipeline stopped, sessions closed)");
}
// Apply to streamer (handles video capture) // Apply to streamer (handles video capture)
self.streamer self.streamer
.apply_video_config(device_path, format, resolution, fps) .apply_video_config(device_path, format, resolution, fps)
.await?; .await?;
if mode != StreamMode::WebRTC {
if let Err(e) = self.start().await {
error!("Failed to start streamer after config change: {}", e);
} else {
info!("Streamer started after config change");
}
}
// Update WebRTC config if in WebRTC mode // Update WebRTC config if in WebRTC mode
if mode == StreamMode::WebRTC { if mode == StreamMode::WebRTC {
self.webrtc_streamer
.update_video_config(resolution, format, fps)
.await;
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) = let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
self.streamer.current_capture_config().await; self.streamer.current_capture_config().await;
if actual_format != format || actual_resolution != resolution || actual_fps != fps { if actual_format != format || actual_resolution != resolution || actual_fps != fps {
@@ -590,19 +579,7 @@ impl VideoStreamManager {
self.streamer.init_auto().await?; self.streamer.init_auto().await?;
} }
// Synchronize WebRTC config with current capture config self.sync_webrtc_capture_source("before start").await;
let (device_path, resolution, format, fps, jpeg_quality) =
self.streamer.current_capture_config().await;
self.webrtc_streamer
.update_video_config(resolution, format, fps)
.await;
if let Some(device_path) = device_path {
self.webrtc_streamer
.set_capture_device(device_path, jpeg_quality)
.await;
} else {
warn!("No capture device configured for WebRTC");
}
} }
} }
@@ -747,24 +724,10 @@ impl VideoStreamManager {
} }
// 2. Synchronize WebRTC config with capture config // 2. Synchronize WebRTC config with capture config
let (device_path, resolution, format, fps, jpeg_quality) = let (device_path, _, _, _, _) = self.streamer.current_capture_config().await;
self.streamer.current_capture_config().await; self.sync_webrtc_capture_source("for encoded frame subscription")
tracing::info!(
"Connecting encoded frame subscription: {}x{} {:?} @ {}fps",
resolution.width,
resolution.height,
format,
fps
);
self.webrtc_streamer
.update_video_config(resolution, format, fps)
.await; .await;
if let Some(device_path) = device_path { if device_path.is_none() {
self.webrtc_streamer
.set_capture_device(device_path, jpeg_quality)
.await;
} else {
tracing::warn!("No capture device configured for encoded frames");
return None; return None;
} }

View File

@@ -1,591 +0,0 @@
//! Video session management with multi-codec support
//!
//! This module provides session management for video streaming with:
//! - Multi-codec support (H264, H265, VP8, VP9)
//! - Session lifecycle management
//! - Dynamic codec switching
//! - Statistics and monitoring
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, RwLock};
use tracing::{debug, info, warn};
use super::encoder::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
use super::encoder::BitratePreset;
use super::format::Resolution;
use super::frame::VideoFrame;
use super::shared_video_pipeline::{
EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
};
use crate::error::{AppError, Result};
/// Maximum concurrent video sessions
const MAX_VIDEO_SESSIONS: usize = 8;
/// Video session state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoSessionState {
/// Session created but not started
Created,
/// Session is active and streaming
Active,
/// Session is paused
Paused,
/// Session is closing
Closing,
/// Session is closed
Closed,
}
impl std::fmt::Display for VideoSessionState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VideoSessionState::Created => write!(f, "Created"),
VideoSessionState::Active => write!(f, "Active"),
VideoSessionState::Paused => write!(f, "Paused"),
VideoSessionState::Closing => write!(f, "Closing"),
VideoSessionState::Closed => write!(f, "Closed"),
}
}
}
/// Video session information
#[derive(Debug, Clone)]
pub struct VideoSessionInfo {
/// Session ID
pub session_id: String,
/// Current codec
pub codec: VideoEncoderType,
/// Session state
pub state: VideoSessionState,
/// Creation time
pub created_at: Instant,
/// Last activity time
pub last_activity: Instant,
/// Frames received
pub frames_received: u64,
/// Bytes received
pub bytes_received: u64,
}
/// Individual video session
struct VideoSession {
/// Session ID
session_id: String,
/// Codec for this session
codec: VideoEncoderType,
/// Session state
state: VideoSessionState,
/// Creation time
created_at: Instant,
/// Last activity time
last_activity: Instant,
/// Frame receiver
frame_rx: Option<tokio::sync::mpsc::Receiver<std::sync::Arc<EncodedVideoFrame>>>,
/// Stats
frames_received: u64,
bytes_received: u64,
}
impl VideoSession {
fn new(session_id: String, codec: VideoEncoderType) -> Self {
let now = Instant::now();
Self {
session_id,
codec,
state: VideoSessionState::Created,
created_at: now,
last_activity: now,
frame_rx: None,
frames_received: 0,
bytes_received: 0,
}
}
fn info(&self) -> VideoSessionInfo {
VideoSessionInfo {
session_id: self.session_id.clone(),
codec: self.codec,
state: self.state,
created_at: self.created_at,
last_activity: self.last_activity,
frames_received: self.frames_received,
bytes_received: self.bytes_received,
}
}
}
/// Video session manager configuration
#[derive(Debug, Clone)]
pub struct VideoSessionManagerConfig {
/// Default codec
pub default_codec: VideoEncoderType,
/// Default resolution
pub resolution: Resolution,
/// Bitrate preset
pub bitrate_preset: BitratePreset,
/// Default FPS
pub fps: u32,
/// Session timeout (seconds)
pub session_timeout_secs: u64,
/// Encoder backend (None = auto select best available)
pub encoder_backend: Option<EncoderBackend>,
}
impl Default for VideoSessionManagerConfig {
fn default() -> Self {
Self {
default_codec: VideoEncoderType::H264,
resolution: Resolution::HD720,
bitrate_preset: BitratePreset::Balanced,
fps: 30,
session_timeout_secs: 300,
encoder_backend: None,
}
}
}
/// Video session manager
///
/// Manages video encoding sessions with multi-codec support.
/// A single encoder is shared across all sessions with the same codec.
pub struct VideoSessionManager {
/// Configuration
config: VideoSessionManagerConfig,
/// Active sessions
sessions: RwLock<HashMap<String, VideoSession>>,
/// Current pipeline (shared across sessions with same codec)
pipeline: RwLock<Option<Arc<SharedVideoPipeline>>>,
/// Current codec (active pipeline codec)
current_codec: RwLock<Option<VideoEncoderType>>,
/// Video frame source
frame_source: RwLock<Option<broadcast::Receiver<VideoFrame>>>,
}
impl VideoSessionManager {
/// Create a new video session manager
pub fn new(config: VideoSessionManagerConfig) -> Self {
info!(
"Creating video session manager with default codec: {}",
config.default_codec
);
Self {
config,
sessions: RwLock::new(HashMap::new()),
pipeline: RwLock::new(None),
current_codec: RwLock::new(None),
frame_source: RwLock::new(None),
}
}
/// Create with default configuration
pub fn with_defaults() -> Self {
Self::new(VideoSessionManagerConfig::default())
}
/// Set the video frame source
pub async fn set_frame_source(&self, rx: broadcast::Receiver<VideoFrame>) {
*self.frame_source.write().await = Some(rx);
}
/// Get available codecs based on hardware capabilities
pub fn available_codecs(&self) -> Vec<VideoEncoderType> {
EncoderRegistry::global().selectable_formats()
}
/// Check if a codec is available
pub fn is_codec_available(&self, codec: VideoEncoderType) -> bool {
let hardware_only = codec.hardware_only();
EncoderRegistry::global().is_format_available(codec, hardware_only)
}
/// Create a new video session
pub async fn create_session(&self, codec: Option<VideoEncoderType>) -> Result<String> {
let sessions = self.sessions.read().await;
if sessions.len() >= MAX_VIDEO_SESSIONS {
return Err(AppError::VideoError(format!(
"Maximum video sessions ({}) reached",
MAX_VIDEO_SESSIONS
)));
}
drop(sessions);
// Use specified codec or default
let codec = codec.unwrap_or(self.config.default_codec);
// Verify codec is available
if !self.is_codec_available(codec) {
return Err(AppError::VideoError(format!(
"Codec {} is not available on this system",
codec
)));
}
// Generate session ID
let session_id = uuid::Uuid::new_v4().to_string();
// Create session
let session = VideoSession::new(session_id.clone(), codec);
// Store session
let mut sessions = self.sessions.write().await;
sessions.insert(session_id.clone(), session);
info!("Video session created: {} (codec: {})", session_id, codec);
Ok(session_id)
}
/// Start a video session (subscribe to encoded frames)
pub async fn start_session(
&self,
session_id: &str,
) -> Result<tokio::sync::mpsc::Receiver<std::sync::Arc<EncodedVideoFrame>>> {
// Ensure pipeline is running with correct codec
self.ensure_pipeline_for_session(session_id).await?;
let mut sessions = self.sessions.write().await;
let session = sessions
.get_mut(session_id)
.ok_or_else(|| AppError::NotFound(format!("Session not found: {}", session_id)))?;
// Get pipeline and subscribe
let pipeline = self.pipeline.read().await;
let pipeline = pipeline
.as_ref()
.ok_or_else(|| AppError::VideoError("Pipeline not initialized".to_string()))?;
let rx = pipeline.subscribe();
session.frame_rx = Some(pipeline.subscribe());
session.state = VideoSessionState::Active;
session.last_activity = Instant::now();
info!("Video session started: {}", session_id);
Ok(rx)
}
/// Ensure pipeline is running with correct codec for session
async fn ensure_pipeline_for_session(&self, session_id: &str) -> Result<()> {
let sessions = self.sessions.read().await;
let session = sessions
.get(session_id)
.ok_or_else(|| AppError::NotFound(format!("Session not found: {}", session_id)))?;
let required_codec = session.codec;
drop(sessions);
let current_codec = *self.current_codec.read().await;
// Check if we need to create or switch pipeline
if current_codec != Some(required_codec) {
self.switch_pipeline_codec(required_codec).await?;
}
// Ensure pipeline is started
let pipeline = self.pipeline.read().await;
if let Some(ref pipe) = *pipeline {
if !pipe.is_running() {
// Need frame source to start
let frame_rx = {
let source = self.frame_source.read().await;
source.as_ref().map(|rx| rx.resubscribe())
};
if let Some(rx) = frame_rx {
drop(pipeline);
let pipeline = self.pipeline.read().await;
if let Some(ref pipe) = *pipeline {
pipe.start(rx).await?;
}
}
}
}
Ok(())
}
/// Switch pipeline to different codec
async fn switch_pipeline_codec(&self, codec: VideoEncoderType) -> Result<()> {
info!("Switching pipeline to codec: {}", codec);
// Stop existing pipeline
{
let pipeline = self.pipeline.read().await;
if let Some(ref pipe) = *pipeline {
pipe.stop();
}
}
// Create new pipeline config
let pipeline_config = SharedVideoPipelineConfig {
resolution: self.config.resolution,
input_format: crate::video::format::PixelFormat::Mjpeg, // Common input
output_codec: codec,
bitrate_preset: self.config.bitrate_preset,
fps: self.config.fps,
encoder_backend: self.config.encoder_backend,
};
// Create new pipeline
let new_pipeline = SharedVideoPipeline::new(pipeline_config)?;
// Update state
*self.pipeline.write().await = Some(new_pipeline);
*self.current_codec.write().await = Some(codec);
info!("Pipeline switched to codec: {}", codec);
Ok(())
}
/// Get session info
pub async fn get_session(&self, session_id: &str) -> Option<VideoSessionInfo> {
let sessions = self.sessions.read().await;
sessions.get(session_id).map(|s| s.info())
}
/// List all sessions
pub async fn list_sessions(&self) -> Vec<VideoSessionInfo> {
let sessions = self.sessions.read().await;
sessions.values().map(|s| s.info()).collect()
}
/// Pause a session
pub async fn pause_session(&self, session_id: &str) -> Result<()> {
let mut sessions = self.sessions.write().await;
let session = sessions
.get_mut(session_id)
.ok_or_else(|| AppError::NotFound(format!("Session not found: {}", session_id)))?;
session.state = VideoSessionState::Paused;
session.last_activity = Instant::now();
debug!("Video session paused: {}", session_id);
Ok(())
}
/// Resume a session
pub async fn resume_session(&self, session_id: &str) -> Result<()> {
let mut sessions = self.sessions.write().await;
let session = sessions
.get_mut(session_id)
.ok_or_else(|| AppError::NotFound(format!("Session not found: {}", session_id)))?;
session.state = VideoSessionState::Active;
session.last_activity = Instant::now();
debug!("Video session resumed: {}", session_id);
Ok(())
}
/// Close a session
pub async fn close_session(&self, session_id: &str) -> Result<()> {
let mut sessions = self.sessions.write().await;
if let Some(mut session) = sessions.remove(session_id) {
session.state = VideoSessionState::Closed;
session.frame_rx = None;
info!("Video session closed: {}", session_id);
}
// If no more sessions, consider stopping pipeline
if sessions.is_empty() {
drop(sessions);
self.maybe_stop_pipeline().await;
}
Ok(())
}
/// Stop pipeline if no active sessions
async fn maybe_stop_pipeline(&self) {
let sessions = self.sessions.read().await;
let has_active = sessions
.values()
.any(|s| s.state == VideoSessionState::Active);
drop(sessions);
if !has_active {
let pipeline = self.pipeline.read().await;
if let Some(ref pipe) = *pipeline {
pipe.stop();
debug!("Pipeline stopped - no active sessions");
}
}
}
/// Cleanup stale/timed out sessions
pub async fn cleanup_stale_sessions(&self) {
let timeout = std::time::Duration::from_secs(self.config.session_timeout_secs);
let now = Instant::now();
let stale_ids: Vec<String> = {
let sessions = self.sessions.read().await;
sessions
.iter()
.filter(|(_, s)| {
(s.state == VideoSessionState::Paused || s.state == VideoSessionState::Created)
&& now.duration_since(s.last_activity) > timeout
})
.map(|(id, _)| id.clone())
.collect()
};
if !stale_ids.is_empty() {
let mut sessions = self.sessions.write().await;
for id in stale_ids {
info!("Removing stale video session: {}", id);
sessions.remove(&id);
}
}
}
/// Get session count
pub async fn session_count(&self) -> usize {
self.sessions.read().await.len()
}
/// Get active session count
pub async fn active_session_count(&self) -> usize {
self.sessions
.read()
.await
.values()
.filter(|s| s.state == VideoSessionState::Active)
.count()
}
/// Get pipeline statistics
pub async fn pipeline_stats(&self) -> Option<SharedVideoPipelineStats> {
let pipeline = self.pipeline.read().await;
if let Some(ref pipe) = *pipeline {
Some(pipe.stats().await)
} else {
None
}
}
/// Get current active codec
pub async fn current_codec(&self) -> Option<VideoEncoderType> {
*self.current_codec.read().await
}
/// Set bitrate for current pipeline
pub async fn set_bitrate(&self, bitrate_kbps: u32) -> Result<()> {
let pipeline = self.pipeline.read().await;
if let Some(ref pipe) = *pipeline {
pipe.set_bitrate(bitrate_kbps).await?;
}
Ok(())
}
/// Request keyframe for all sessions
pub async fn request_keyframe(&self) {
// This would be implemented if encoders support forced keyframes
warn!("Keyframe request not yet implemented");
}
/// Change codec for a session (requires restart)
pub async fn change_session_codec(
&self,
session_id: &str,
new_codec: VideoEncoderType,
) -> Result<()> {
if !self.is_codec_available(new_codec) {
return Err(AppError::VideoError(format!(
"Codec {} is not available",
new_codec
)));
}
let mut sessions = self.sessions.write().await;
let session = sessions
.get_mut(session_id)
.ok_or_else(|| AppError::NotFound(format!("Session not found: {}", session_id)))?;
let old_codec = session.codec;
session.codec = new_codec;
session.state = VideoSessionState::Created; // Require restart
session.frame_rx = None;
session.last_activity = Instant::now();
info!(
"Session {} codec changed: {} -> {}",
session_id, old_codec, new_codec
);
Ok(())
}
/// Get codec info
pub fn get_codec_info(&self, codec: VideoEncoderType) -> Option<CodecInfo> {
let registry = EncoderRegistry::global();
let encoder = registry.best_encoder(codec, codec.hardware_only())?;
Some(CodecInfo {
codec_type: codec,
codec_name: encoder.codec_name.clone(),
backend: encoder.backend.to_string(),
is_hardware: encoder.is_hardware,
})
}
/// List all available codecs with their info
pub fn list_codec_info(&self) -> Vec<CodecInfo> {
self.available_codecs()
.iter()
.filter_map(|c| self.get_codec_info(*c))
.collect()
}
}
/// Codec information
#[derive(Debug, Clone)]
pub struct CodecInfo {
/// Codec type
pub codec_type: VideoEncoderType,
/// FFmpeg codec name
pub codec_name: String,
/// Backend (VAAPI, NVENC, etc.)
pub backend: String,
/// Whether this is hardware accelerated
pub is_hardware: bool,
}
impl Default for VideoSessionManager {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_state_display() {
assert_eq!(VideoSessionState::Active.to_string(), "Active");
assert_eq!(VideoSessionState::Closed.to_string(), "Closed");
}
#[test]
fn test_available_codecs() {
let manager = VideoSessionManager::with_defaults();
let codecs = manager.available_codecs();
println!("Available codecs: {:?}", codecs);
// H264 should always be available (software fallback)
assert!(codecs.contains(&VideoEncoderType::H264));
}
#[test]
fn test_codec_info() {
let manager = VideoSessionManager::with_defaults();
let info = manager.get_codec_info(VideoEncoderType::H264);
if let Some(info) = info {
println!(
"H264: {} ({}, hardware={})",
info.codec_name, info.backend, info.is_hardware
);
}
}
}

View File

@@ -6,13 +6,32 @@ use std::sync::Arc;
use crate::config::*; use crate::config::*;
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::events::SystemEvent;
use crate::rtsp::RtspService; use crate::rtsp::RtspService;
use crate::state::AppState; use crate::state::AppState;
use crate::video::codec_constraints::{ use crate::video::codec_constraints::{
enforce_constraints_with_stream_manager, StreamCodecConstraints, enforce_constraints_with_stream_manager, StreamCodecConstraints,
}; };
fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType {
match config.backend {
HidBackend::Otg => crate::hid::HidBackendType::Otg,
HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
port: config.ch9329_port.clone(),
baud_rate: config.ch9329_baudrate,
},
HidBackend::None => crate::hid::HidBackendType::None,
}
}
async fn reconcile_otg_from_store(state: &Arc<AppState>) -> Result<()> {
let config = state.config.get();
state
.otg_service
.apply_config(&config.hid, &config.msd)
.await
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
}
/// 应用 Video 配置变更 /// 应用 Video 配置变更
pub async fn apply_video_config( pub async fn apply_video_config(
state: &Arc<AppState>, state: &Arc<AppState>,
@@ -45,73 +64,11 @@ pub async fn apply_video_config(
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height); let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
// Step 1: 更新 WebRTC streamer 配置(停止现有 pipeline 和 sessions
state state
.stream_manager .stream_manager
.webrtc_streamer()
.update_video_config(resolution, format, new_config.fps)
.await;
tracing::info!("WebRTC streamer config updated");
// Step 2: 应用视频配置到 streamer重新创建 capturer
state
.stream_manager
.streamer()
.apply_video_config(&device, format, resolution, new_config.fps) .apply_video_config(&device, format, resolution, new_config.fps)
.await .await
.map_err(|e| AppError::VideoError(format!("Failed to apply video config: {}", e)))?; .map_err(|e| AppError::VideoError(format!("Failed to apply video config: {}", e)))?;
tracing::info!("Video config applied to streamer");
// Step 3: 重启 streamer仅 MJPEG 模式)
if !state.stream_manager.is_webrtc_enabled().await {
if let Err(e) = state.stream_manager.start().await {
tracing::error!("Failed to start streamer after config change: {}", e);
} else {
tracing::info!("Streamer started after config change");
}
}
// 配置 WebRTC direct capture所有模式统一配置
let (device_path, _resolution, _format, _fps, jpeg_quality) = state
.stream_manager
.streamer()
.current_capture_config()
.await;
if let Some(device_path) = device_path {
state
.stream_manager
.webrtc_streamer()
.set_capture_device(device_path, jpeg_quality)
.await;
} else {
tracing::warn!("No capture device configured for WebRTC");
}
if state.stream_manager.is_webrtc_enabled().await {
use crate::video::encoder::VideoCodecType;
let codec = state
.stream_manager
.webrtc_streamer()
.current_video_codec()
.await;
let codec_str = match codec {
VideoCodecType::H264 => "h264",
VideoCodecType::H265 => "h265",
VideoCodecType::VP8 => "vp8",
VideoCodecType::VP9 => "vp9",
}
.to_string();
let is_hardware = state
.stream_manager
.webrtc_streamer()
.is_hardware_encoding()
.await;
state.events.publish(SystemEvent::WebRTCReady {
transition_id: None,
codec: codec_str,
hardware: is_hardware,
});
}
tracing::info!("Video config applied successfully"); tracing::info!("Video config applied successfully");
Ok(()) Ok(())
@@ -188,56 +145,26 @@ pub async fn apply_hid_config(
old_config: &HidConfig, old_config: &HidConfig,
new_config: &HidConfig, new_config: &HidConfig,
) -> Result<()> { ) -> Result<()> {
// 检查 OTG 描述符是否变更 let current_msd_enabled = state.config.get().msd.enabled;
new_config.validate_otg_endpoint_budget(current_msd_enabled)?;
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor; let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
let old_hid_functions = old_config.effective_otg_functions(); let old_hid_functions = old_config.constrained_otg_functions();
let mut new_hid_functions = new_config.effective_otg_functions(); let new_hid_functions = new_config.constrained_otg_functions();
// Low-endpoint UDCs (e.g., musb) cannot handle consumer control endpoints reliably
if new_config.backend == HidBackend::Otg {
if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.otg_udc.as_deref()) {
if crate::otg::configfs::is_low_endpoint_udc(&udc) && new_hid_functions.consumer {
tracing::warn!(
"UDC {} has low endpoint resources, disabling consumer control",
udc
);
new_hid_functions.consumer = false;
}
}
}
let hid_functions_changed = old_hid_functions != new_hid_functions; let hid_functions_changed = old_hid_functions != new_hid_functions;
let keyboard_leds_changed =
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
let endpoint_budget_changed =
old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit();
if new_config.backend == HidBackend::Otg && new_hid_functions.is_empty() {
return Err(AppError::BadRequest(
"OTG HID functions cannot be empty".to_string(),
));
}
// 如果描述符变更且当前使用 OTG 后端,需要重建 Gadget
if descriptor_changed && new_config.backend == HidBackend::Otg {
tracing::info!("OTG descriptor changed, updating gadget...");
if let Err(e) = state
.otg_service
.update_descriptor(&new_config.otg_descriptor)
.await
{
tracing::error!("Failed to update OTG descriptor: {}", e);
return Err(AppError::Config(format!(
"OTG descriptor update failed: {}",
e
)));
}
tracing::info!("OTG descriptor updated successfully");
}
// 检查是否需要重载 HID 后端
if old_config.backend == new_config.backend if old_config.backend == new_config.backend
&& old_config.ch9329_port == new_config.ch9329_port && old_config.ch9329_port == new_config.ch9329_port
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate && old_config.ch9329_baudrate == new_config.ch9329_baudrate
&& old_config.otg_udc == new_config.otg_udc && old_config.otg_udc == new_config.otg_udc
&& !descriptor_changed && !descriptor_changed
&& !hid_functions_changed && !hid_functions_changed
&& !keyboard_leds_changed
&& !endpoint_budget_changed
{ {
tracing::info!("HID config unchanged, skipping reload"); tracing::info!("HID config unchanged, skipping reload");
return Ok(()); return Ok(());
@@ -245,30 +172,27 @@ pub async fn apply_hid_config(
tracing::info!("Applying HID config changes..."); tracing::info!("Applying HID config changes...");
if new_config.backend == HidBackend::Otg let new_hid_backend = hid_backend_type(new_config);
&& (hid_functions_changed || old_config.backend != HidBackend::Otg) let transitioning_away_from_otg =
{ old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg;
if transitioning_away_from_otg {
state state
.otg_service .hid
.update_hid_functions(new_hid_functions.clone()) .reload(new_hid_backend.clone())
.await .await
.map_err(|e| AppError::Config(format!("OTG HID function update failed: {}", e)))?; .map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
} }
let new_hid_backend = match new_config.backend { reconcile_otg_from_store(state).await?;
HidBackend::Otg => crate::hid::HidBackendType::Otg,
HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
port: new_config.ch9329_port.clone(),
baud_rate: new_config.ch9329_baudrate,
},
HidBackend::None => crate::hid::HidBackendType::None,
};
if !transitioning_away_from_otg {
state state
.hid .hid
.reload(new_hid_backend) .reload(new_hid_backend)
.await .await
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?; .map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
}
tracing::info!( tracing::info!(
"HID backend reloaded successfully: {:?}", "HID backend reloaded successfully: {:?}",
@@ -284,6 +208,12 @@ pub async fn apply_msd_config(
old_config: &MsdConfig, old_config: &MsdConfig,
new_config: &MsdConfig, new_config: &MsdConfig,
) -> Result<()> { ) -> Result<()> {
state
.config
.get()
.hid
.validate_otg_endpoint_budget(new_config.enabled)?;
tracing::info!("MSD config sent, checking if reload needed..."); tracing::info!("MSD config sent, checking if reload needed...");
tracing::debug!("Old MSD config: {:?}", old_config); tracing::debug!("Old MSD config: {:?}", old_config);
tracing::debug!("New MSD config: {:?}", new_config); tracing::debug!("New MSD config: {:?}", new_config);
@@ -323,6 +253,8 @@ pub async fn apply_msd_config(
if new_msd_enabled { if new_msd_enabled {
tracing::info!("(Re)initializing MSD..."); tracing::info!("(Re)initializing MSD...");
reconcile_otg_from_store(state).await?;
// Shutdown existing controller if present // Shutdown existing controller if present
let mut msd_guard = state.msd.write().await; let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() { if let Some(msd) = msd_guard.as_mut() {
@@ -358,6 +290,17 @@ pub async fn apply_msd_config(
} }
*msd_guard = None; *msd_guard = None;
tracing::info!("MSD shutdown complete"); tracing::info!("MSD shutdown complete");
reconcile_otg_from_store(state).await?;
}
let current_config = state.config.get();
if current_config.hid.backend == HidBackend::Otg && old_msd_enabled != new_msd_enabled {
state
.hid
.reload(crate::hid::HidBackendType::Otg)
.await
.map_err(|e| AppError::Config(format!("OTG HID reload failed: {}", e)))?;
} }
Ok(()) Ok(())

View File

@@ -307,7 +307,9 @@ pub struct HidConfigUpdate {
pub otg_udc: Option<String>, pub otg_udc: Option<String>,
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>, pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
pub otg_profile: Option<OtgHidProfile>, pub otg_profile: Option<OtgHidProfile>,
pub otg_endpoint_budget: Option<OtgEndpointBudget>,
pub otg_functions: Option<OtgHidFunctionsUpdate>, pub otg_functions: Option<OtgHidFunctionsUpdate>,
pub otg_keyboard_leds: Option<bool>,
pub mouse_absolute: Option<bool>, pub mouse_absolute: Option<bool>,
} }
@@ -346,9 +348,15 @@ impl HidConfigUpdate {
if let Some(profile) = self.otg_profile.clone() { if let Some(profile) = self.otg_profile.clone() {
config.otg_profile = profile; config.otg_profile = profile;
} }
if let Some(budget) = self.otg_endpoint_budget {
config.otg_endpoint_budget = budget;
}
if let Some(ref functions) = self.otg_functions { if let Some(ref functions) = self.otg_functions {
functions.apply_to(&mut config.otg_functions); functions.apply_to(&mut config.otg_functions);
} }
if let Some(enabled) = self.otg_keyboard_leds {
config.otg_keyboard_leds = enabled;
}
if let Some(absolute) = self.mouse_absolute { if let Some(absolute) = self.mouse_absolute {
config.mouse_absolute = absolute; config.mouse_absolute = absolute;
} }

View File

@@ -4,7 +4,7 @@ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
Json, Json,
}; };
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
use typeshare::typeshare; use typeshare::typeshare;
@@ -324,27 +324,3 @@ pub async fn update_easytier_config(
Ok(Json(new_config.extensions.easytier.clone())) Ok(Json(new_config.extensions.easytier.clone()))
} }
// ============================================================================
// Ttyd status for console (simplified)
// ============================================================================
/// Simple ttyd status for console view
#[typeshare]
#[derive(Debug, Serialize)]
pub struct TtydStatus {
pub available: bool,
pub running: bool,
}
/// Get ttyd status for console view
/// GET /api/extensions/ttyd/status
pub async fn get_ttyd_status(State(state): State<Arc<AppState>>) -> Json<TtydStatus> {
let mgr = &state.extensions;
let status = mgr.status(ExtensionId::Ttyd).await;
Json(TtydStatus {
available: mgr.check_available(ExtensionId::Ttyd),
running: status.is_running(),
})
}

View File

@@ -12,11 +12,13 @@ use tracing::{info, warn};
use crate::auth::{Session, SESSION_COOKIE}; use crate::auth::{Session, SESSION_COOKIE};
use crate::config::{AppConfig, StreamMode}; use crate::config::{AppConfig, StreamMode};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::events::SystemEvent;
use crate::state::AppState; use crate::state::AppState;
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest}; use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
use crate::video::codec_constraints::codec_to_id; use crate::video::codec_constraints::codec_to_id;
use crate::video::encoder::BitratePreset; use crate::video::encoder::{
build_hardware_self_check_runtime_error, run_hardware_self_check, BitratePreset,
VideoEncoderSelfCheckResponse,
};
// ============================================================================ // ============================================================================
// Health & Info // Health & Info
@@ -596,38 +598,14 @@ pub struct SetupRequest {
pub hid_ch9329_baudrate: Option<u32>, pub hid_ch9329_baudrate: Option<u32>,
pub hid_otg_udc: Option<String>, pub hid_otg_udc: Option<String>,
pub hid_otg_profile: Option<String>, pub hid_otg_profile: Option<String>,
pub hid_otg_endpoint_budget: Option<crate::config::OtgEndpointBudget>,
pub hid_otg_keyboard_leds: Option<bool>,
pub msd_enabled: Option<bool>,
// Extension settings // Extension settings
pub ttyd_enabled: Option<bool>, pub ttyd_enabled: Option<bool>,
pub rustdesk_enabled: Option<bool>, pub rustdesk_enabled: Option<bool>,
} }
fn normalize_otg_profile_for_low_endpoint(config: &mut AppConfig) {
if !matches!(config.hid.backend, crate::config::HidBackend::Otg) {
return;
}
let udc = crate::otg::configfs::resolve_udc_name(config.hid.otg_udc.as_deref());
let Some(udc) = udc else {
return;
};
if !crate::otg::configfs::is_low_endpoint_udc(&udc) {
return;
}
match config.hid.otg_profile {
crate::config::OtgHidProfile::Full => {
config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumer;
}
crate::config::OtgHidProfile::FullNoMsd => {
config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumerNoMsd;
}
crate::config::OtgHidProfile::Custom => {
if config.hid.otg_functions.consumer {
config.hid.otg_functions.consumer = false;
}
}
_ => {}
}
}
pub async fn setup_init( pub async fn setup_init(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<SetupRequest>, Json(req): Json<SetupRequest>,
@@ -701,31 +679,18 @@ pub async fn setup_init(
config.hid.otg_udc = Some(udc); config.hid.otg_udc = Some(udc);
} }
if let Some(profile) = req.hid_otg_profile.clone() { if let Some(profile) = req.hid_otg_profile.clone() {
config.hid.otg_profile = match profile.as_str() { if let Some(parsed) = crate::config::OtgHidProfile::from_legacy_str(&profile) {
"full" => crate::config::OtgHidProfile::Full, config.hid.otg_profile = parsed;
"full_no_msd" => crate::config::OtgHidProfile::FullNoMsd,
"full_no_consumer" => crate::config::OtgHidProfile::FullNoConsumer,
"full_no_consumer_no_msd" => crate::config::OtgHidProfile::FullNoConsumerNoMsd,
"legacy_keyboard" => crate::config::OtgHidProfile::LegacyKeyboard,
"legacy_mouse_relative" => crate::config::OtgHidProfile::LegacyMouseRelative,
"custom" => crate::config::OtgHidProfile::Custom,
_ => config.hid.otg_profile.clone(),
};
if matches!(config.hid.backend, crate::config::HidBackend::Otg) {
match config.hid.otg_profile {
crate::config::OtgHidProfile::Full
| crate::config::OtgHidProfile::FullNoConsumer => {
config.msd.enabled = true;
}
crate::config::OtgHidProfile::FullNoMsd
| crate::config::OtgHidProfile::FullNoConsumerNoMsd
| crate::config::OtgHidProfile::LegacyKeyboard
| crate::config::OtgHidProfile::LegacyMouseRelative => {
config.msd.enabled = false;
}
crate::config::OtgHidProfile::Custom => {}
} }
} }
if let Some(budget) = req.hid_otg_endpoint_budget {
config.hid.otg_endpoint_budget = budget;
}
if let Some(enabled) = req.hid_otg_keyboard_leds {
config.hid.otg_keyboard_leds = enabled;
}
if let Some(enabled) = req.msd_enabled {
config.msd.enabled = enabled;
} }
// Extension settings // Extension settings
@@ -735,29 +700,18 @@ pub async fn setup_init(
if let Some(enabled) = req.rustdesk_enabled { if let Some(enabled) = req.rustdesk_enabled {
config.rustdesk.enabled = enabled; config.rustdesk.enabled = enabled;
} }
normalize_otg_profile_for_low_endpoint(config);
}) })
.await?; .await?;
// Get updated config for HID reload // Get updated config for HID reload
let new_config = state.config.get(); let new_config = state.config.get();
if matches!(new_config.hid.backend, crate::config::HidBackend::Otg) { if let Err(e) = state
let mut hid_functions = new_config.hid.effective_otg_functions(); .otg_service
if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref()) .apply_config(&new_config.hid, &new_config.msd)
.await
{ {
if crate::otg::configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer { tracing::warn!("Failed to apply OTG config during setup: {}", e);
tracing::warn!(
"UDC {} has low endpoint resources, disabling consumer control",
udc
);
hid_functions.consumer = false;
}
}
if let Err(e) = state.otg_service.update_hid_functions(hid_functions).await {
tracing::warn!("Failed to apply HID functions during setup: {}", e);
}
} }
tracing::info!( tracing::info!(
@@ -879,8 +833,10 @@ pub async fn update_config(
let new_config: AppConfig = serde_json::from_value(merged) let new_config: AppConfig = serde_json::from_value(merged)
.map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?; .map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?;
let mut new_config = new_config; let new_config = new_config;
normalize_otg_profile_for_low_endpoint(&mut new_config); new_config
.hid
.validate_otg_endpoint_budget(new_config.msd.enabled)?;
// Apply the validated config // Apply the validated config
state.config.set(new_config.clone()).await?; state.config.set(new_config.clone()).await?;
@@ -908,297 +864,76 @@ pub async fn update_config(
// Get new config for device reloading // Get new config for device reloading
let new_config = state.config.get(); let new_config = state.config.get();
// Video config processing - always reload if section was sent
if has_video { if has_video {
tracing::info!("Video config sent, applying settings..."); if let Err(e) =
config::apply::apply_video_config(&state, &old_config.video, &new_config.video).await
let device = new_config
.video
.device
.clone()
.ok_or_else(|| AppError::BadRequest("video_device is required".to_string()))?;
// Map to PixelFormat/Resolution
let format = new_config
.video
.format
.as_ref()
.and_then(|f| {
serde_json::from_value::<crate::video::format::PixelFormat>(
serde_json::Value::String(f.clone()),
)
.ok()
})
.unwrap_or(crate::video::format::PixelFormat::Mjpeg);
let resolution =
crate::video::format::Resolution::new(new_config.video.width, new_config.video.height);
// Step 1: Update WebRTC streamer config FIRST
// This stops the shared pipeline and closes existing sessions BEFORE capturer is recreated
// This ensures the pipeline won't be subscribed to a stale frame source
state
.stream_manager
.webrtc_streamer()
.update_video_config(resolution, format, new_config.video.fps)
.await;
tracing::info!("WebRTC streamer config updated (pipeline stopped, sessions closed)");
// Step 2: Apply video config to streamer (recreates capturer)
if let Err(e) = state
.stream_manager
.streamer()
.apply_video_config(&device, format, resolution, new_config.video.fps)
.await
{ {
tracing::error!("Failed to apply video config: {}", e); tracing::error!("Failed to apply video config: {}", e);
// Rollback config on failure
state.config.set((*old_config).clone()).await?; state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse { return Ok(Json(LoginResponse {
success: false, success: false,
message: Some(format!("Video configuration invalid: {}", e)), message: Some(format!("Video configuration invalid: {}", e)),
})); }));
} }
tracing::info!("Video config applied successfully");
// Step 3: Start the streamer to begin capturing frames (MJPEG mode only)
if !state.stream_manager.is_webrtc_enabled().await {
// This is necessary because apply_video_config only creates the capturer but doesn't start it
if let Err(e) = state.stream_manager.start().await {
tracing::error!("Failed to start streamer after config change: {}", e);
// Don't fail the request - the stream might start later when client connects
} else {
tracing::info!("Streamer started after config change");
}
} }
// Configure WebRTC direct capture (all modes)
let (device_path, _resolution, _format, _fps, jpeg_quality) = state
.stream_manager
.streamer()
.current_capture_config()
.await;
if let Some(device_path) = device_path {
state
.stream_manager
.webrtc_streamer()
.set_capture_device(device_path, jpeg_quality)
.await;
} else {
tracing::warn!("No capture device configured for WebRTC");
}
if state.stream_manager.is_webrtc_enabled().await {
use crate::video::encoder::VideoCodecType;
let codec = state
.stream_manager
.webrtc_streamer()
.current_video_codec()
.await;
let codec_str = match codec {
VideoCodecType::H264 => "h264",
VideoCodecType::H265 => "h265",
VideoCodecType::VP8 => "vp8",
VideoCodecType::VP9 => "vp9",
}
.to_string();
let is_hardware = state
.stream_manager
.webrtc_streamer()
.is_hardware_encoding()
.await;
state.events.publish(SystemEvent::WebRTCReady {
transition_id: None,
codec: codec_str,
hardware: is_hardware,
});
}
}
// Stream config processing (encoder backend, bitrate, etc.)
if has_stream { if has_stream {
tracing::info!("Stream config sent, applying encoder settings..."); if let Err(e) =
config::apply::apply_stream_config(&state, &old_config.stream, &new_config.stream).await
// Update WebRTC streamer encoder backend {
let encoder_backend = new_config.stream.encoder.to_backend(); tracing::error!("Failed to apply stream config: {}", e);
tracing::info!( state.config.set((*old_config).clone()).await?;
"Updating encoder backend to: {:?} (from config: {:?})", return Ok(Json(LoginResponse {
encoder_backend, success: false,
new_config.stream.encoder message: Some(format!("Stream configuration invalid: {}", e)),
); }));
}
state
.stream_manager
.webrtc_streamer()
.update_encoder_backend(encoder_backend)
.await;
// Update bitrate if changed
state
.stream_manager
.webrtc_streamer()
.set_bitrate_preset(new_config.stream.bitrate_preset)
.await
.ok(); // Ignore error if no active stream
tracing::info!(
"Stream config applied: encoder={:?}, bitrate={}",
new_config.stream.encoder,
new_config.stream.bitrate_preset
);
} }
// HID config processing - always reload if section was sent
if has_hid { if has_hid {
tracing::info!("HID config sent, reloading HID backend..."); if let Err(e) =
config::apply::apply_hid_config(&state, &old_config.hid, &new_config.hid).await
// Determine new backend type {
let new_hid_backend = match new_config.hid.backend {
crate::config::HidBackend::Otg => crate::hid::HidBackendType::Otg,
crate::config::HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
port: new_config.hid.ch9329_port.clone(),
baud_rate: new_config.hid.ch9329_baudrate,
},
crate::config::HidBackend::None => crate::hid::HidBackendType::None,
};
// Reload HID backend - return success=false on error
if let Err(e) = state.hid.reload(new_hid_backend).await {
tracing::error!("HID reload failed: {}", e); tracing::error!("HID reload failed: {}", e);
// Rollback config on failure
state.config.set((*old_config).clone()).await?; state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse { return Ok(Json(LoginResponse {
success: false, success: false,
message: Some(format!("HID configuration invalid: {}", e)), message: Some(format!("HID configuration invalid: {}", e)),
})); }));
} }
tracing::info!(
"HID backend reloaded successfully: {:?}",
new_config.hid.backend
);
} }
// Audio config processing - always reload if section was sent
if has_audio { if has_audio {
tracing::info!("Audio config sent, applying settings..."); if let Err(e) =
config::apply::apply_audio_config(&state, &old_config.audio, &new_config.audio).await
// Create audio controller config from new config
let audio_config = crate::audio::AudioControllerConfig {
enabled: new_config.audio.enabled,
device: new_config.audio.device.clone(),
quality: crate::audio::AudioQuality::from_str(&new_config.audio.quality),
};
// Update audio controller
if let Err(e) = state.audio.update_config(audio_config).await {
tracing::error!("Audio config update failed: {}", e);
// Don't rollback config for audio errors - it's not critical
// Just log the error
} else {
tracing::info!(
"Audio config applied: enabled={}, device={}",
new_config.audio.enabled,
new_config.audio.device
);
}
// Also update WebRTC audio enabled state
if let Err(e) = state
.stream_manager
.set_webrtc_audio_enabled(new_config.audio.enabled)
.await
{ {
tracing::warn!("Failed to update WebRTC audio state: {}", e); tracing::warn!("Audio config update failed: {}", e);
} else {
tracing::info!("WebRTC audio enabled: {}", new_config.audio.enabled);
}
// Reconnect audio sources for existing WebRTC sessions
// This is needed because the audio controller was restarted with new config
if new_config.audio.enabled {
state.stream_manager.reconnect_webrtc_audio_sources().await;
} }
} }
// MSD config processing - reload if enabled state or directory changed
if has_msd { if has_msd {
tracing::info!("MSD config sent, checking if reload needed..."); if let Err(e) =
tracing::debug!("Old MSD config: {:?}", old_config.msd); config::apply::apply_msd_config(&state, &old_config.msd, &new_config.msd).await
tracing::debug!("New MSD config: {:?}", new_config.msd); {
let old_msd_enabled = old_config.msd.enabled;
let new_msd_enabled = new_config.msd.enabled;
let msd_dir_changed = old_config.msd.msd_dir != new_config.msd.msd_dir;
tracing::info!(
"MSD enabled: old={}, new={}",
old_msd_enabled,
new_msd_enabled
);
if msd_dir_changed {
tracing::info!("MSD directory changed: {}", new_config.msd.msd_dir);
}
// Ensure MSD directories exist (msd/images, msd/ventoy)
let msd_dir = new_config.msd.msd_dir_path();
if let Err(e) = std::fs::create_dir_all(msd_dir.join("images")) {
tracing::warn!("Failed to create MSD images directory: {}", e);
}
if let Err(e) = std::fs::create_dir_all(msd_dir.join("ventoy")) {
tracing::warn!("Failed to create MSD ventoy directory: {}", e);
}
let needs_reload = old_msd_enabled != new_msd_enabled || msd_dir_changed;
if !needs_reload {
tracing::info!(
"MSD enabled state unchanged ({}) and directory unchanged, no reload needed",
new_msd_enabled
);
} else if new_msd_enabled {
tracing::info!("(Re)initializing MSD...");
// Shutdown existing controller if present
let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() {
if let Err(e) = msd.shutdown().await {
tracing::warn!("MSD shutdown failed: {}", e);
}
}
*msd_guard = None;
drop(msd_guard);
let msd = crate::msd::MsdController::new(
state.otg_service.clone(),
new_config.msd.msd_dir_path(),
);
if let Err(e) = msd.init().await {
tracing::error!("MSD initialization failed: {}", e); tracing::error!("MSD initialization failed: {}", e);
// Rollback config on failure
state.config.set((*old_config).clone()).await?; state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse { return Ok(Json(LoginResponse {
success: false, success: false,
message: Some(format!("MSD initialization failed: {}", e)), message: Some(format!("MSD initialization failed: {}", e)),
})); }));
} }
// Set event bus
let events = state.events.clone();
msd.set_event_bus(events).await;
// Store the initialized controller
*state.msd.write().await = Some(msd);
tracing::info!("MSD initialized successfully");
} else {
tracing::info!("MSD disabled in config, shutting down...");
let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() {
if let Err(e) = msd.shutdown().await {
tracing::warn!("MSD shutdown failed: {}", e);
} }
}
*msd_guard = None; if has_atx {
tracing::info!("MSD shutdown complete"); if let Err(e) =
config::apply::apply_atx_config(&state, &old_config.atx, &new_config.atx).await
{
tracing::error!("ATX configuration invalid: {}", e);
state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse {
success: false,
message: Some(format!("ATX configuration invalid: {}", e)),
}));
} }
} }
@@ -1798,7 +1533,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
}); });
// Check H264 availability (supports software fallback) // Check H264 availability (supports software fallback)
let h264_encoder = registry.best_encoder(VideoEncoderType::H264, false); let h264_encoder = registry.best_available_encoder(VideoEncoderType::H264);
codecs.push(VideoCodecInfo { codecs.push(VideoCodecInfo {
id: "h264".to_string(), id: "h264".to_string(),
name: "H.264 / WebRTC".to_string(), name: "H.264 / WebRTC".to_string(),
@@ -1809,7 +1544,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
}); });
// Check H265 availability (now supports software too) // Check H265 availability (now supports software too)
let h265_encoder = registry.best_encoder(VideoEncoderType::H265, false); let h265_encoder = registry.best_available_encoder(VideoEncoderType::H265);
codecs.push(VideoCodecInfo { codecs.push(VideoCodecInfo {
id: "h265".to_string(), id: "h265".to_string(),
name: "H.265 / WebRTC".to_string(), name: "H.265 / WebRTC".to_string(),
@@ -1820,7 +1555,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
}); });
// Check VP8 availability (now supports software too) // Check VP8 availability (now supports software too)
let vp8_encoder = registry.best_encoder(VideoEncoderType::VP8, false); let vp8_encoder = registry.best_available_encoder(VideoEncoderType::VP8);
codecs.push(VideoCodecInfo { codecs.push(VideoCodecInfo {
id: "vp8".to_string(), id: "vp8".to_string(),
name: "VP8 / WebRTC".to_string(), name: "VP8 / WebRTC".to_string(),
@@ -1831,7 +1566,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
}); });
// Check VP9 availability (now supports software too) // Check VP9 availability (now supports software too)
let vp9_encoder = registry.best_encoder(VideoEncoderType::VP9, false); let vp9_encoder = registry.best_available_encoder(VideoEncoderType::VP9);
codecs.push(VideoCodecInfo { codecs.push(VideoCodecInfo {
id: "vp9".to_string(), id: "vp9".to_string(),
name: "VP9 / WebRTC".to_string(), name: "VP9 / WebRTC".to_string(),
@@ -1848,6 +1583,15 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
}) })
} }
/// Run hardware encoder smoke tests across common resolutions/codecs.
pub async fn video_encoder_self_check() -> Json<VideoEncoderSelfCheckResponse> {
let response = tokio::task::spawn_blocking(run_hardware_self_check)
.await
.unwrap_or_else(|_| build_hardware_self_check_runtime_error());
Json(response)
}
/// Query parameters for MJPEG stream /// Query parameters for MJPEG stream
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
pub struct MjpegStreamQuery { pub struct MjpegStreamQuery {
@@ -2299,8 +2043,14 @@ pub struct HidStatus {
pub available: bool, pub available: bool,
pub backend: String, pub backend: String,
pub initialized: bool, pub initialized: bool,
pub online: bool,
pub supports_absolute_mouse: bool, pub supports_absolute_mouse: bool,
pub keyboard_leds_enabled: bool,
pub led_state: crate::hid::LedState,
pub screen_resolution: Option<(u32, u32)>, pub screen_resolution: Option<(u32, u32)>,
pub device: Option<String>,
pub error: Option<String>,
pub error_code: Option<String>,
} }
#[derive(Serialize, Clone, Copy, PartialEq, Eq)] #[derive(Serialize, Clone, Copy, PartialEq, Eq)]
@@ -3061,19 +2811,19 @@ pub async fn hid_otg_self_check(State(state): State<Arc<AppState>>) -> Json<OtgS
/// Get HID status /// Get HID status
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> { pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
let info = state.hid.info().await; let hid = state.hid.snapshot().await;
Json(HidStatus { Json(HidStatus {
available: info.is_some(), available: hid.available,
backend: info backend: hid.backend,
.as_ref() initialized: hid.initialized,
.map(|i| i.name.to_string()) online: hid.online,
.unwrap_or_else(|| "none".to_string()), supports_absolute_mouse: hid.supports_absolute_mouse,
initialized: info.as_ref().map(|i| i.initialized).unwrap_or(false), keyboard_leds_enabled: hid.keyboard_leds_enabled,
supports_absolute_mouse: info led_state: hid.led_state,
.as_ref() screen_resolution: hid.screen_resolution,
.map(|i| i.supports_absolute_mouse) device: hid.device,
.unwrap_or(false), error: hid.error,
screen_resolution: info.and_then(|i| i.screen_resolution), error_code: hid.error_code,
}) })
} }

View File

@@ -51,6 +51,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/stream/bitrate", post(handlers::stream_set_bitrate)) .route("/stream/bitrate", post(handlers::stream_set_bitrate))
.route("/stream/codecs", get(handlers::stream_codecs_list)) .route("/stream/codecs", get(handlers::stream_codecs_list))
.route("/stream/constraints", get(handlers::stream_constraints_get)) .route("/stream/constraints", get(handlers::stream_constraints_get))
.route(
"/video/encoder/self-check",
get(handlers::video_encoder_self_check),
)
// WebRTC endpoints // WebRTC endpoints
.route("/webrtc/session", post(handlers::webrtc_create_session)) .route("/webrtc/session", post(handlers::webrtc_create_session))
.route("/webrtc/offer", post(handlers::webrtc_offer)) .route("/webrtc/offer", post(handlers::webrtc_offer))
@@ -192,10 +196,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/extensions/ttyd/config", "/extensions/ttyd/config",
patch(handlers::extensions::update_ttyd_config), patch(handlers::extensions::update_ttyd_config),
) )
.route(
"/extensions/ttyd/status",
get(handlers::extensions::get_ttyd_status),
)
.route( .route(
"/extensions/gostc/config", "/extensions/gostc/config",
patch(handlers::extensions::update_gostc_config), patch(handlers::extensions::update_gostc_config),

View File

@@ -201,7 +201,7 @@ pub fn placeholder_html() -> &'static str {
<h1>One-KVM</h1> <h1>One-KVM</h1>
<p>Frontend not built yet.</p> <p>Frontend not built yet.</p>
<p>Please build the frontend or access the API directly.</p> <p>Please build the frontend or access the API directly.</p>
<div class="version">v0.1.0</div> <div class="version">v0.1.7</div>
</div> </div>
</body> </body>
</html>"# </html>"#

View File

@@ -16,12 +16,122 @@ use axum::{
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::broadcast; use tokio::{sync::mpsc, task::JoinHandle};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::events::SystemEvent; use crate::events::SystemEvent;
use crate::state::AppState; use crate::state::AppState;
enum BusMessage {
Event(SystemEvent),
Lagged { topic: String, count: u64 },
}
fn normalize_topics(topics: &[String]) -> Vec<String> {
let mut normalized = topics.to_vec();
normalized.sort();
normalized.dedup();
if normalized.iter().any(|topic| topic == "*") {
return vec!["*".to_string()];
}
normalized
.into_iter()
.filter(|topic| {
if topic.ends_with(".*") {
return true;
}
let Some((prefix, _)) = topic.split_once('.') else {
return true;
};
let wildcard = format!("{}.*", prefix);
!topics.iter().any(|candidate| candidate == &wildcard)
})
.collect()
}
fn is_device_info_topic(topic: &str) -> bool {
matches!(topic, "*" | "system.*" | "system.device_info")
}
fn rebuild_event_tasks(
state: &Arc<AppState>,
topics: &[String],
event_tx: &mpsc::UnboundedSender<BusMessage>,
event_tasks: &mut Vec<JoinHandle<()>>,
) {
for task in event_tasks.drain(..) {
task.abort();
}
let topics = normalize_topics(topics);
let mut device_info_task_added = false;
for topic in topics {
if is_device_info_topic(&topic) && !device_info_task_added {
let state = state.clone();
let mut rx = state.subscribe_device_info();
let event_tx = event_tx.clone();
event_tasks.push(tokio::spawn(async move {
let snapshot = state.get_device_info().await;
if event_tx.send(BusMessage::Event(snapshot)).is_err() {
return;
}
loop {
if rx.changed().await.is_err() {
break;
}
if let Some(snapshot) = rx.borrow().clone() {
if event_tx.send(BusMessage::Event(snapshot)).is_err() {
break;
}
}
}
}));
device_info_task_added = true;
}
if is_device_info_topic(&topic) && topic != "*" {
continue;
}
let Some(mut rx) = state.events.subscribe_topic(&topic) else {
warn!("Client subscribed to unknown topic: {}", topic);
continue;
};
let event_tx = event_tx.clone();
let topic_name = topic.clone();
event_tasks.push(tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => {
if event_tx.send(BusMessage::Event(event)).is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
if event_tx
.send(BusMessage::Lagged {
topic: topic_name.clone(),
count,
})
.is_err()
{
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}));
}
}
/// Client-to-server message /// Client-to-server message
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(tag = "type", content = "payload")] #[serde(tag = "type", content = "payload")]
@@ -50,16 +160,12 @@ pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>
/// Handle WebSocket connection /// Handle WebSocket connection
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) { async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
let (mut sender, mut receiver) = socket.split(); let (mut sender, mut receiver) = socket.split();
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
// Subscribe to event bus let mut event_tasks: Vec<JoinHandle<()>> = Vec::new();
let mut event_rx = state.events.subscribe();
// Track subscribed topics (default: none until client subscribes) // Track subscribed topics (default: none until client subscribes)
let mut subscribed_topics: Vec<String> = vec![]; let mut subscribed_topics: Vec<String> = vec![];
// Flag to send device info after first subscribe
let mut device_info_sent = false;
info!("WebSocket client connected"); info!("WebSocket client connected");
// Heartbeat interval (30 seconds) // Heartbeat interval (30 seconds)
@@ -73,18 +179,13 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await { if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await {
warn!("Failed to handle client message: {}", e); warn!("Failed to handle client message: {}", e);
} } else {
rebuild_event_tasks(
// Send device info after first subscribe &state,
if !device_info_sent && !subscribed_topics.is_empty() { &subscribed_topics,
let device_info = state.get_device_info().await; &event_tx,
if let Ok(json) = serialize_event(&device_info) { &mut event_tasks,
if sender.send(Message::Text(json.into())).await.is_err() { );
warn!("Failed to send device info to client");
break;
}
}
device_info_sent = true;
} }
} }
Some(Ok(Message::Ping(_))) => { Some(Ok(Message::Ping(_))) => {
@@ -109,9 +210,8 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
// Receive event from event bus // Receive event from event bus
event = event_rx.recv() => { event = event_rx.recv() => {
match event { match event {
Ok(event) => { Some(BusMessage::Event(event)) => {
// Filter event based on subscribed topics // Filter event based on subscribed topics
if should_send_event(&event, &subscribed_topics) {
if let Ok(json) = serialize_event(&event) { if let Ok(json) = serialize_event(&event) {
if sender.send(Message::Text(json.into())).await.is_err() { if sender.send(Message::Text(json.into())).await.is_err() {
warn!("Failed to send event to client, disconnecting"); warn!("Failed to send event to client, disconnecting");
@@ -119,18 +219,20 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
} }
} }
} }
} Some(BusMessage::Lagged { topic, count }) => {
Err(broadcast::error::RecvError::Lagged(n)) => { warn!(
warn!("WebSocket client lagged by {} events", n); "WebSocket client lagged by {} events on topic {}",
count, topic
);
// Send error notification to client using SystemEvent::Error // Send error notification to client using SystemEvent::Error
let error_event = SystemEvent::Error { let error_event = SystemEvent::Error {
message: format!("Lagged by {} events", n), message: format!("Lagged by {} events", count),
}; };
if let Ok(json) = serialize_event(&error_event) { if let Ok(json) = serialize_event(&error_event) {
let _ = sender.send(Message::Text(json.into())).await; let _ = sender.send(Message::Text(json.into())).await;
} }
} }
Err(_) => { None => {
warn!("Event bus closed"); warn!("Event bus closed");
break; break;
} }
@@ -147,6 +249,10 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
} }
} }
for task in event_tasks {
task.abort();
}
info!("WebSocket handler exiting"); info!("WebSocket handler exiting");
} }
@@ -176,21 +282,6 @@ async fn handle_client_message(
Ok(()) Ok(())
} }
/// Check if an event should be sent based on subscribed topics
fn should_send_event(event: &SystemEvent, topics: &[String]) -> bool {
if topics.is_empty() {
return false;
}
// Fast path: check for wildcard subscription (avoid String allocation)
if topics.iter().any(|t| t == "*") {
return true;
}
// Check if event matches any subscribed topic
topics.iter().any(|topic| event.matches_topic(topic))
}
/// Serialize event to JSON string /// Serialize event to JSON string
fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> { fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
serde_json::to_string(event) serde_json::to_string(event)
@@ -199,53 +290,49 @@ fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::events::SystemEvent;
#[test] #[test]
fn test_should_send_event_wildcard() { fn test_normalize_topics_dedupes_and_sorts() {
let event = SystemEvent::StreamStateChanged { let topics = vec![
state: "streaming".to_string(), "stream.state_changed".to_string(),
device: None, "stream.state_changed".to_string(),
}; "system.device_info".to_string(),
];
assert!(should_send_event(&event, &["*".to_string()])); assert_eq!(
normalize_topics(&topics),
vec![
"stream.state_changed".to_string(),
"system.device_info".to_string()
]
);
} }
#[test] #[test]
fn test_should_send_event_prefix() { fn test_normalize_topics_wildcard_wins() {
let event = SystemEvent::StreamStateChanged { let topics = vec!["*".to_string(), "stream.state_changed".to_string()];
state: "streaming".to_string(), assert_eq!(normalize_topics(&topics), vec!["*".to_string()]);
device: None,
};
assert!(should_send_event(&event, &["stream.*".to_string()]));
assert!(!should_send_event(&event, &["msd.*".to_string()]));
} }
#[test] #[test]
fn test_should_send_event_exact() { fn test_normalize_topics_drops_exact_when_prefix_exists() {
let event = SystemEvent::StreamStateChanged { let topics = vec![
state: "streaming".to_string(), "stream.*".to_string(),
device: None, "stream.state_changed".to_string(),
}; "system.device_info".to_string(),
];
assert!(should_send_event( assert_eq!(
&event, normalize_topics(&topics),
&["stream.state_changed".to_string()] vec!["stream.*".to_string(), "system.device_info".to_string()]
)); );
assert!(!should_send_event(
&event,
&["stream.config_changed".to_string()]
));
} }
#[test] #[test]
fn test_should_send_event_empty_topics() { fn test_is_device_info_topic_matches_expected_topics() {
let event = SystemEvent::StreamStateChanged { assert!(is_device_info_topic("system.device_info"));
state: "streaming".to_string(), assert!(is_device_info_topic("system.*"));
device: None, assert!(is_device_info_topic("*"));
}; assert!(!is_device_info_topic("stream.*"));
assert!(!should_send_event(&event, &[]));
} }
} }

View File

@@ -37,12 +37,6 @@ const H265_NAL_SPS: u8 = 33;
const H265_NAL_PPS: u8 = 34; const H265_NAL_PPS: u8 = 34;
const H265_NAL_AUD: u8 = 35; const H265_NAL_AUD: u8 = 35;
const H265_NAL_FILLER: u8 = 38; const H265_NAL_FILLER: u8 = 38;
#[allow(dead_code)]
const H265_NAL_SEI_PREFIX: u8 = 39; // PREFIX_SEI_NUT
#[allow(dead_code)]
const H265_NAL_SEI_SUFFIX: u8 = 40; // SUFFIX_SEI_NUT
#[allow(dead_code)]
const H265_NAL_AP: u8 = 48; // Aggregation Packet
const H265_NAL_FU: u8 = 49; // Fragmentation Unit const H265_NAL_FU: u8 = 49; // Fragmentation Unit
/// H.265 NAL header size /// H.265 NAL header size
@@ -51,11 +45,6 @@ const H265_NAL_HEADER_SIZE: usize = 2;
/// FU header size (1 byte after NAL header) /// FU header size (1 byte after NAL header)
const H265_FU_HEADER_SIZE: usize = 1; const H265_FU_HEADER_SIZE: usize = 1;
/// Fixed PayloadHdr for FU packets: Type=49, LayerID=0, TID=1
/// This matches the rtp crate's FRAG_PAYLOAD_HDR
#[allow(dead_code)]
const FU_PAYLOAD_HDR: [u8; 2] = [0x62, 0x01];
/// Fixed PayloadHdr for AP packets: Type=48, LayerID=0, TID=1 /// Fixed PayloadHdr for AP packets: Type=48, LayerID=0, TID=1
/// This matches the rtp crate's AGGR_PAYLOAD_HDR /// This matches the rtp crate's AGGR_PAYLOAD_HDR
const AP_PAYLOAD_HDR: [u8; 2] = [0x60, 0x01]; const AP_PAYLOAD_HDR: [u8; 2] = [0x60, 0x01];

View File

@@ -9,7 +9,7 @@
//! //!
//! Architecture: //! Architecture:
//! ```text //! ```text
//! VideoCapturer (MJPEG/YUYV) //! V4L2 capture
//! | //! |
//! v //! v
//! SharedVideoPipeline (decode -> convert -> encode) //! SharedVideoPipeline (decode -> convert -> encode)

View File

@@ -262,8 +262,6 @@ impl Default for AudioTrackConfig {
/// Audio track for WebRTC streaming /// Audio track for WebRTC streaming
pub struct AudioTrack { pub struct AudioTrack {
#[allow(dead_code)]
config: AudioTrackConfig,
/// RTP track /// RTP track
track: Arc<TrackLocalStaticRTP>, track: Arc<TrackLocalStaticRTP>,
/// Running flag /// Running flag
@@ -284,7 +282,6 @@ impl AudioTrack {
let (running_tx, _) = watch::channel(false); let (running_tx, _) = watch::channel(false);
Self { Self {
config,
track, track,
running: Arc::new(running_tx), running: Arc::new(running_tx),
} }

View File

@@ -46,6 +46,53 @@ use webrtc::ice_transport::ice_gatherer_state::RTCIceGathererState;
/// H.265/HEVC MIME type (RFC 7798) /// H.265/HEVC MIME type (RFC 7798)
const MIME_TYPE_H265: &str = "video/H265"; const MIME_TYPE_H265: &str = "video/H265";
fn h264_contains_parameter_sets(data: &[u8]) -> bool {
// Annex-B start code path
let mut i = 0usize;
while i + 4 <= data.len() {
let sc_len = if i + 4 <= data.len()
&& data[i] == 0
&& data[i + 1] == 0
&& data[i + 2] == 0
&& data[i + 3] == 1
{
4
} else if i + 3 <= data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
3
} else {
i += 1;
continue;
};
let nal_start = i + sc_len;
if nal_start < data.len() {
let nal_type = data[nal_start] & 0x1F;
if nal_type == 7 || nal_type == 8 {
return true;
}
}
i = nal_start.saturating_add(1);
}
// Length-prefixed fallback
let mut pos = 0usize;
while pos + 4 <= data.len() {
let nalu_len =
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
if nalu_len == 0 || pos + nalu_len > data.len() {
break;
}
let nal_type = data[pos] & 0x1F;
if nal_type == 7 || nal_type == 8 {
return true;
}
pos += nalu_len;
}
false
}
/// Universal WebRTC session configuration /// Universal WebRTC session configuration
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UniversalSessionConfig { pub struct UniversalSessionConfig {
@@ -649,6 +696,13 @@ impl UniversalSession {
if gap_detected { if gap_detected {
waiting_for_keyframe = true; waiting_for_keyframe = true;
} }
// Some H264 encoders output SPS/PPS in a separate non-keyframe AU
// before IDR. Keep this frame so browser can decode the next IDR.
let forward_h264_parameter_frame = waiting_for_keyframe
&& expected_codec == VideoEncoderType::H264
&& h264_contains_parameter_sets(encoded_frame.data.as_ref());
let now = Instant::now(); let now = Instant::now();
if now.duration_since(last_keyframe_request) if now.duration_since(last_keyframe_request)
>= Duration::from_millis(200) >= Duration::from_millis(200)
@@ -656,9 +710,11 @@ impl UniversalSession {
request_keyframe(); request_keyframe();
last_keyframe_request = now; last_keyframe_request = now;
} }
if !forward_h264_parameter_frame {
continue; continue;
} }
} }
}
let _ = send_in_flight; let _ = send_in_flight;

View File

@@ -221,23 +221,11 @@ impl WebRtcStreamer {
use crate::video::encoder::registry::EncoderRegistry; use crate::video::encoder::registry::EncoderRegistry;
let registry = EncoderRegistry::global(); let registry = EncoderRegistry::global();
let mut codecs = vec![]; VideoEncoderType::ordered()
.into_iter()
// H264 always available (has software fallback) .filter(|codec| registry.is_codec_available(*codec))
codecs.push(VideoCodecType::H264); .map(Self::encoder_type_to_codec_type)
.collect()
// Check hardware codecs
if registry.is_format_available(VideoEncoderType::H265, true) {
codecs.push(VideoCodecType::H265);
}
if registry.is_format_available(VideoEncoderType::VP8, true) {
codecs.push(VideoCodecType::VP8);
}
if registry.is_format_available(VideoEncoderType::VP9, true) {
codecs.push(VideoCodecType::VP9);
}
codecs
} }
/// Convert VideoCodecType to VideoEncoderType /// Convert VideoCodecType to VideoEncoderType
@@ -250,6 +238,15 @@ impl WebRtcStreamer {
} }
} }
fn encoder_type_to_codec_type(codec: VideoEncoderType) -> VideoCodecType {
match codec {
VideoEncoderType::H264 => VideoCodecType::H264,
VideoEncoderType::H265 => VideoCodecType::H265,
VideoEncoderType::VP8 => VideoCodecType::VP8,
VideoEncoderType::VP9 => VideoCodecType::VP9,
}
}
fn should_stop_pipeline(session_count: usize, subscriber_count: usize) -> bool { fn should_stop_pipeline(session_count: usize, subscriber_count: usize) -> bool {
session_count == 0 && subscriber_count == 0 session_count == 0 && subscriber_count == 0
} }
@@ -577,7 +574,7 @@ impl WebRtcStreamer {
VideoCodecType::VP9 => VideoEncoderType::VP9, VideoCodecType::VP9 => VideoEncoderType::VP9,
}; };
EncoderRegistry::global() EncoderRegistry::global()
.best_encoder(codec_type, false) .best_available_encoder(codec_type)
.map(|e| e.is_hardware) .map(|e| e.is_hardware)
.unwrap_or(false) .unwrap_or(false)
} }

View File

@@ -1,566 +0,0 @@
#!/usr/bin/env python3
"""
One-KVM benchmark script (Windows-friendly).
Measures FPS + CPU usage across:
- input pixel formats (capture card formats)
- output codecs (mjpeg/h264/h265/vp8/vp9)
- resolution/FPS matrix
- encoder backends (software/hardware)
Requirements:
pip install requests websockets playwright
playwright install
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import json
import sys
import threading
import time
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Tuple
import requests
import websockets
from playwright.async_api import async_playwright
SESSION_COOKIE = "one_kvm_session"
DEFAULT_MATRIX = [
(1920, 1080, 30),
(1920, 1080, 60),
(1280, 720, 30),
(1280, 720, 60),
]
@dataclass
class Case:
input_format: str
output_codec: str
encoder: Optional[str]
width: int
height: int
fps: int
@dataclass
class Result:
input_format: str
output_codec: str
encoder: str
width: int
height: int
fps: int
avg_fps: float
avg_cpu: float
note: str = ""
class KvmClient:
def __init__(self, base_url: str, username: str, password: str) -> None:
self.base = base_url.rstrip("/")
self.s = requests.Session()
self.login(username, password)
def login(self, username: str, password: str) -> None:
r = self.s.post(f"{self.base}/api/auth/login", json={"username": username, "password": password})
r.raise_for_status()
def get_cookie(self) -> str:
return self.s.cookies.get(SESSION_COOKIE, "")
def get_video_config(self) -> Dict:
r = self.s.get(f"{self.base}/api/config/video")
r.raise_for_status()
return r.json()
def get_stream_config(self) -> Dict:
r = self.s.get(f"{self.base}/api/config/stream")
r.raise_for_status()
return r.json()
def get_devices(self) -> Dict:
r = self.s.get(f"{self.base}/api/devices")
r.raise_for_status()
return r.json()
def get_codecs(self) -> Dict:
r = self.s.get(f"{self.base}/api/stream/codecs")
r.raise_for_status()
return r.json()
def patch_video(self, device: Optional[str], fmt: str, w: int, h: int, fps: int) -> None:
payload: Dict[str, object] = {"format": fmt, "width": w, "height": h, "fps": fps}
if device:
payload["device"] = device
r = self.s.patch(f"{self.base}/api/config/video", json=payload)
r.raise_for_status()
def patch_stream(self, encoder: Optional[str]) -> None:
if encoder is None:
return
r = self.s.patch(f"{self.base}/api/config/stream", json={"encoder": encoder})
r.raise_for_status()
def set_mode(self, mode: str) -> None:
r = self.s.post(f"{self.base}/api/stream/mode", json={"mode": mode})
r.raise_for_status()
def get_mode(self) -> Dict:
r = self.s.get(f"{self.base}/api/stream/mode")
r.raise_for_status()
return r.json()
def wait_mode_ready(self, mode: str, timeout_sec: int = 20) -> None:
deadline = time.time() + timeout_sec
while time.time() < deadline:
data = self.get_mode()
if not data.get("switching") and data.get("mode") == mode:
return
time.sleep(0.5)
raise RuntimeError(f"mode switch timeout: {mode}")
def start_stream(self) -> None:
r = self.s.post(f"{self.base}/api/stream/start")
r.raise_for_status()
def stop_stream(self) -> None:
r = self.s.post(f"{self.base}/api/stream/stop")
r.raise_for_status()
def cpu_sample(self) -> float:
r = self.s.get(f"{self.base}/api/info")
r.raise_for_status()
return float(r.json()["device_info"]["cpu_usage"])
def close_webrtc_session(self, session_id: str) -> None:
if not session_id:
return
self.s.post(f"{self.base}/api/webrtc/close", json={"session_id": session_id})
class MjpegStream:
def __init__(self, url: str, cookie: str) -> None:
self._stop = threading.Event()
self._resp = requests.get(url, stream=True, headers={"Cookie": f"{SESSION_COOKIE}={cookie}"})
self._thread = threading.Thread(target=self._reader, daemon=True)
self._thread.start()
def _reader(self) -> None:
try:
for chunk in self._resp.iter_content(chunk_size=4096):
if self._stop.is_set():
break
if not chunk:
time.sleep(0.01)
except Exception:
pass
def close(self) -> None:
self._stop.set()
try:
self._resp.close()
except Exception:
pass
def parse_matrix(values: Optional[List[str]]) -> List[Tuple[int, int, int]]:
if not values:
return DEFAULT_MATRIX
result: List[Tuple[int, int, int]] = []
for item in values:
# WIDTHxHEIGHT@FPS
part = item.strip().lower()
if "@" not in part or "x" not in part:
raise ValueError(f"invalid matrix item: {item}")
res_part, fps_part = part.split("@", 1)
w_str, h_str = res_part.split("x", 1)
result.append((int(w_str), int(h_str), int(fps_part)))
return result
def avg(values: Iterable[float]) -> float:
vals = list(values)
return sum(vals) / len(vals) if vals else 0.0
def normalize_format(fmt: str) -> str:
return fmt.strip().upper()
def select_device(devices: Dict, preferred: Optional[str]) -> Optional[Dict]:
video_devices = devices.get("video", [])
if preferred:
for d in video_devices:
if d.get("path") == preferred:
return d
return video_devices[0] if video_devices else None
def build_supported_map(device: Dict) -> Dict[str, Dict[Tuple[int, int], List[int]]]:
supported: Dict[str, Dict[Tuple[int, int], List[int]]] = {}
for fmt in device.get("formats", []):
fmt_name = normalize_format(fmt.get("format", ""))
res_map: Dict[Tuple[int, int], List[int]] = {}
for res in fmt.get("resolutions", []):
key = (int(res.get("width", 0)), int(res.get("height", 0)))
fps_list = [int(f) for f in res.get("fps", [])]
res_map[key] = fps_list
supported[fmt_name] = res_map
return supported
def is_combo_supported(
supported: Dict[str, Dict[Tuple[int, int], List[int]]],
fmt: str,
width: int,
height: int,
fps: int,
) -> bool:
res_map = supported.get(fmt)
if not res_map:
return False
fps_list = res_map.get((width, height), [])
return fps in fps_list
async def mjpeg_sample(
base_url: str,
cookie: str,
client_id: str,
duration_sec: float,
cpu_sample_fn,
) -> Tuple[float, float]:
mjpeg_url = f"{base_url}/api/stream/mjpeg?client_id={client_id}"
stream = MjpegStream(mjpeg_url, cookie)
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/ws"
fps_samples: List[float] = []
cpu_samples: List[float] = []
# discard first cpu sample (needs delta)
cpu_sample_fn()
try:
async with websockets.connect(ws_url, extra_headers={"Cookie": f"{SESSION_COOKIE}={cookie}"}) as ws:
start = time.time()
while time.time() - start < duration_sec:
try:
msg = await asyncio.wait_for(ws.recv(), timeout=1.0)
except asyncio.TimeoutError:
msg = None
if msg:
data = json.loads(msg)
if data.get("type") == "stream.stats_update":
clients = data.get("clients_stat", {})
if client_id in clients:
fps = float(clients[client_id].get("fps", 0))
fps_samples.append(fps)
cpu_samples.append(float(cpu_sample_fn()))
finally:
stream.close()
return avg(fps_samples), avg(cpu_samples)
async def webrtc_sample(
base_url: str,
cookie: str,
duration_sec: float,
cpu_sample_fn,
headless: bool,
) -> Tuple[float, float, str]:
fps_samples: List[float] = []
cpu_samples: List[float] = []
session_id = ""
# discard first cpu sample (needs delta)
cpu_sample_fn()
async with async_playwright() as p:
browser = await p.chromium.launch(headless=headless)
context = await browser.new_context()
await context.add_cookies([{
"name": SESSION_COOKIE,
"value": cookie,
"url": base_url,
"path": "/",
}])
page = await context.new_page()
await page.goto(base_url + "/", wait_until="domcontentloaded")
await page.evaluate(
"""
async (base) => {
const pc = new RTCPeerConnection();
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
pc.onicecandidate = async (e) => {
if (e.candidate && window.__sid) {
await fetch(base + "/api/webrtc/ice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: window.__sid, candidate: e.candidate })
});
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(base + "/api/webrtc/offer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sdp: offer.sdp })
});
const ans = await resp.json();
window.__sid = ans.session_id;
await pc.setRemoteDescription({ type: "answer", sdp: ans.sdp });
(ans.ice_candidates || []).forEach(c => pc.addIceCandidate(c));
window.__kvmStats = { pc, lastTs: 0, lastFrames: 0 };
}
""",
base_url,
)
try:
await page.wait_for_function(
"window.__kvmStats && window.__kvmStats.pc && window.__kvmStats.pc.connectionState === 'connected'",
timeout=15000,
)
except Exception:
pass
start = time.time()
while time.time() - start < duration_sec:
fps = await page.evaluate(
"""
async () => {
const s = window.__kvmStats;
const report = await s.pc.getStats();
let fps = 0;
for (const r of report.values()) {
if (r.type === "inbound-rtp" && r.kind === "video") {
if (r.framesPerSecond) {
fps = r.framesPerSecond;
} else if (r.framesDecoded && s.lastTs) {
const dt = (r.timestamp - s.lastTs) / 1000.0;
const df = r.framesDecoded - s.lastFrames;
fps = dt > 0 ? df / dt : 0;
}
s.lastTs = r.timestamp;
s.lastFrames = r.framesDecoded || s.lastFrames;
break;
}
}
return fps;
}
"""
)
fps_samples.append(float(fps))
cpu_samples.append(float(cpu_sample_fn()))
await asyncio.sleep(1)
session_id = await page.evaluate("window.__sid || ''")
await browser.close()
return avg(fps_samples), avg(cpu_samples), session_id
async def run_case(
client: KvmClient,
device: Optional[str],
case: Case,
duration_sec: float,
warmup_sec: float,
headless: bool,
) -> Result:
client.patch_video(device, case.input_format, case.width, case.height, case.fps)
if case.output_codec != "mjpeg":
client.patch_stream(case.encoder)
client.set_mode(case.output_codec)
client.wait_mode_ready(case.output_codec)
client.start_stream()
time.sleep(warmup_sec)
note = ""
if case.output_codec == "mjpeg":
avg_fps, avg_cpu = await mjpeg_sample(
client.base,
client.get_cookie(),
client_id=f"bench-{int(time.time() * 1000)}",
duration_sec=duration_sec,
cpu_sample_fn=client.cpu_sample,
)
else:
avg_fps, avg_cpu, session_id = await webrtc_sample(
client.base,
client.get_cookie(),
duration_sec=duration_sec,
cpu_sample_fn=client.cpu_sample,
headless=headless,
)
if session_id:
client.close_webrtc_session(session_id)
else:
note = "no-session-id"
client.stop_stream()
return Result(
input_format=case.input_format,
output_codec=case.output_codec,
encoder=case.encoder or "n/a",
width=case.width,
height=case.height,
fps=case.fps,
avg_fps=avg_fps,
avg_cpu=avg_cpu,
note=note,
)
def write_csv(results: List[Result], path: str) -> None:
with open(path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["input_format", "output_codec", "encoder", "width", "height", "fps", "avg_fps", "avg_cpu", "note"])
for r in results:
w.writerow([r.input_format, r.output_codec, r.encoder, r.width, r.height, r.fps, f"{r.avg_fps:.2f}", f"{r.avg_cpu:.2f}", r.note])
def write_md(results: List[Result], path: str) -> None:
lines = [
"| input_format | output_codec | encoder | width | height | fps | avg_fps | avg_cpu | note |",
"|---|---|---|---:|---:|---:|---:|---:|---|",
]
for r in results:
lines.append(
f"| {r.input_format} | {r.output_codec} | {r.encoder} | {r.width} | {r.height} | {r.fps} | {r.avg_fps:.2f} | {r.avg_cpu:.2f} | {r.note} |"
)
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
def main() -> int:
parser = argparse.ArgumentParser(description="One-KVM benchmark (FPS + CPU)")
parser.add_argument("--base-url", required=True, help="e.g. http://192.168.1.50")
parser.add_argument("--username", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--device", help="video device path, e.g. /dev/video0")
parser.add_argument("--input-formats", help="comma list, e.g. MJPEG,YUYV,NV12")
parser.add_argument("--output-codecs", help="comma list, e.g. mjpeg,h264,h265,vp8,vp9")
parser.add_argument("--encoder-backends", help="comma list, e.g. software,auto,vaapi,nvenc,qsv,amf,rkmpp,v4l2m2m")
parser.add_argument("--matrix", action="append", help="repeatable WIDTHxHEIGHT@FPS, e.g. 1920x1080@30")
parser.add_argument("--duration", type=float, default=30.0, help="sample duration seconds (default 30)")
parser.add_argument("--warmup", type=float, default=3.0, help="warmup seconds before sampling")
parser.add_argument("--csv", default="bench_results.csv")
parser.add_argument("--md", default="bench_results.md")
parser.add_argument("--headless", action="store_true", help="run browser headless (default: headful)")
args = parser.parse_args()
if sys.platform.startswith("win"):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
base_url = args.base_url.strip()
if not base_url.startswith(("http://", "https://")):
base_url = "http://" + base_url
client = KvmClient(base_url, args.username, args.password)
devices = client.get_devices()
video_cfg = client.get_video_config()
device_path = args.device or video_cfg.get("device")
device_info = select_device(devices, device_path)
if not device_info:
print("No video device found.", file=sys.stderr)
return 2
device_path = device_info.get("path")
supported_map = build_supported_map(device_info)
if args.input_formats:
input_formats = [normalize_format(f) for f in args.input_formats.split(",") if f.strip()]
else:
input_formats = list(supported_map.keys())
matrix = parse_matrix(args.matrix)
codecs_info = client.get_codecs()
available_codecs = {c["id"] for c in codecs_info.get("codecs", []) if c.get("available")}
available_codecs.add("mjpeg")
if args.output_codecs:
output_codecs = [c.strip().lower() for c in args.output_codecs.split(",") if c.strip()]
else:
output_codecs = sorted(list(available_codecs))
if args.encoder_backends:
encoder_backends = [e.strip().lower() for e in args.encoder_backends.split(",") if e.strip()]
else:
encoder_backends = ["software", "auto"]
cases: List[Case] = []
for fmt in input_formats:
for (w, h, fps) in matrix:
if not is_combo_supported(supported_map, fmt, w, h, fps):
continue
for codec in output_codecs:
if codec not in available_codecs:
continue
if codec == "mjpeg":
cases.append(Case(fmt, codec, None, w, h, fps))
else:
for enc in encoder_backends:
cases.append(Case(fmt, codec, enc, w, h, fps))
print(f"Total cases: {len(cases)}")
results: List[Result] = []
for idx, case in enumerate(cases, 1):
print(f"[{idx}/{len(cases)}] {case.input_format} {case.output_codec} {case.encoder or 'n/a'} {case.width}x{case.height}@{case.fps}")
try:
result = asyncio.run(
run_case(
client,
device=device_path,
case=case,
duration_sec=args.duration,
warmup_sec=args.warmup,
headless=args.headless,
)
)
results.append(result)
print(f" -> avg_fps={result.avg_fps:.2f}, avg_cpu={result.avg_cpu:.2f}")
except Exception as exc:
results.append(
Result(
input_format=case.input_format,
output_codec=case.output_codec,
encoder=case.encoder or "n/a",
width=case.width,
height=case.height,
fps=case.fps,
avg_fps=0.0,
avg_cpu=0.0,
note=f"error: {exc}",
)
)
print(f" -> error: {exc}")
write_csv(results, args.csv)
write_md(results, args.md)
print(f"Saved: {args.csv}, {args.md}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "web", "name": "web",
"version": "0.1.5", "version": "0.1.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "web", "name": "web",
"version": "0.1.5", "version": "0.1.7",
"dependencies": { "dependencies": {
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@@ -1,7 +1,7 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "0.1.5", "version": "0.1.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, watch } from 'vue' import { KeepAlive, onMounted, watch } from 'vue'
import { RouterView, useRouter } from 'vue-router' import { RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useSystemStore } from '@/stores/system' import { useSystemStore } from '@/stores/system'
@@ -56,5 +56,10 @@ watch(
</script> </script>
<template> <template>
<RouterView /> <RouterView v-slot="{ Component, route }">
<KeepAlive v-if="authStore.isAuthenticated">
<component :is="Component" v-if="route.name === 'Console'" />
</KeepAlive>
<component :is="Component" v-if="route.name !== 'Console' || !authStore.isAuthenticated" />
</RouterView>
</template> </template>

View File

@@ -30,7 +30,6 @@ import type {
GostcConfigUpdate, GostcConfigUpdate,
EasytierConfig, EasytierConfig,
EasytierConfigUpdate, EasytierConfigUpdate,
TtydStatus,
} from '@/types/generated' } from '@/types/generated'
import { request } from './request' import { request } from './request'
@@ -236,11 +235,6 @@ export const extensionsApi = {
logs: (id: string, lines = 100) => logs: (id: string, lines = 100) =>
request<ExtensionLogs>(`/extensions/${id}/logs?lines=${lines}`), request<ExtensionLogs>(`/extensions/${id}/logs?lines=${lines}`),
/**
* 获取 ttyd 状态(简化版,用于控制台)
*/
getTtydStatus: () => request<TtydStatus>('/extensions/ttyd/status'),
/** /**
* 更新 ttyd 配置 * 更新 ttyd 配置
*/ */

View File

@@ -1,6 +1,7 @@
// API client for One-KVM backend // API client for One-KVM backend
import { request, ApiError } from './request' import { request, ApiError } from './request'
import type { CanonicalKey } from '@/types/generated'
const API_BASE = '/api' const API_BASE = '/api'
@@ -85,6 +86,9 @@ export const systemApi = {
hid_ch9329_baudrate?: number hid_ch9329_baudrate?: number
hid_otg_udc?: string hid_otg_udc?: string
hid_otg_profile?: string hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean
encoder_backend?: string encoder_backend?: string
audio_device?: string audio_device?: string
ttyd_enabled?: boolean ttyd_enabled?: boolean
@@ -177,6 +181,31 @@ export interface StreamConstraintsResponse {
current_mode: string current_mode: string
} }
export interface VideoEncoderSelfCheckCodec {
id: string
name: string
}
export interface VideoEncoderSelfCheckCell {
codec_id: string
ok: boolean
elapsed_ms?: number | null
}
export interface VideoEncoderSelfCheckRow {
resolution_id: string
resolution_label: string
width: number
height: number
cells: VideoEncoderSelfCheckCell[]
}
export interface VideoEncoderSelfCheckResponse {
current_hardware_encoder: string
codecs: VideoEncoderSelfCheckCodec[]
rows: VideoEncoderSelfCheckRow[]
}
export const streamApi = { export const streamApi = {
status: () => status: () =>
request<{ request<{
@@ -217,6 +246,9 @@ export const streamApi = {
getConstraints: () => getConstraints: () =>
request<StreamConstraintsResponse>('/stream/constraints'), request<StreamConstraintsResponse>('/stream/constraints'),
encoderSelfCheck: () =>
request<VideoEncoderSelfCheckResponse>('/video/encoder/self-check'),
setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) => setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) =>
request<{ success: boolean; message?: string }>('/stream/bitrate', { request<{ success: boolean; message?: string }>('/stream/bitrate', {
method: 'POST', method: 'POST',
@@ -299,8 +331,20 @@ export const hidApi = {
available: boolean available: boolean
backend: string backend: string
initialized: boolean initialized: boolean
online: boolean
supports_absolute_mouse: boolean supports_absolute_mouse: boolean
keyboard_leds_enabled: boolean
led_state: {
num_lock: boolean
caps_lock: boolean
scroll_lock: boolean
compose: boolean
kana: boolean
}
screen_resolution: [number, number] | null screen_resolution: [number, number] | null
device: string | null
error: string | null
error_code: string | null
}>('/hid/status'), }>('/hid/status'),
otgSelfCheck: () => otgSelfCheck: () =>
@@ -325,7 +369,7 @@ export const hidApi = {
}> }>
}>('/hid/otg/self-check'), }>('/hid/otg/self-check'),
keyboard: async (type: 'down' | 'up', key: number, modifier?: number) => { keyboard: async (type: 'down' | 'up', key: CanonicalKey, modifier?: number) => {
await ensureHidConnection() await ensureHidConnection()
const event: HidKeyboardEvent = { const event: HidKeyboardEvent = {
type: type === 'down' ? 'keydown' : 'keyup', type: type === 'down' ? 'keydown' : 'keyup',

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -18,6 +18,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Power, RotateCcw, CircleDot, Wifi, Send } from 'lucide-vue-next' import { Power, RotateCcw, CircleDot, Wifi, Send } from 'lucide-vue-next'
import { atxApi } from '@/api'
import { atxConfigApi } from '@/api/config' import { atxConfigApi } from '@/api/config'
const emit = defineEmits<{ const emit = defineEmits<{
@@ -34,6 +35,7 @@ const activeTab = ref('atx')
// ATX state // ATX state
const powerState = ref<'on' | 'off' | 'unknown'>('unknown') const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
let powerStateTimer: number | null = null
// Decouple action data from dialog visibility to prevent race conditions // Decouple action data from dialog visibility to prevent race conditions
const pendingAction = ref<'short' | 'long' | 'reset' | null>(null) const pendingAction = ref<'short' | 'long' | 'reset' | null>(null)
const confirmDialogOpen = ref(false) const confirmDialogOpen = ref(false)
@@ -71,6 +73,9 @@ function handleAction() {
else if (pendingAction.value === 'long') emit('powerLong') else if (pendingAction.value === 'long') emit('powerLong')
else if (pendingAction.value === 'reset') emit('reset') else if (pendingAction.value === 'reset') emit('reset')
confirmDialogOpen.value = false confirmDialogOpen.value = false
setTimeout(() => {
refreshPowerState().catch(() => {})
}, 1200)
} }
const confirmTitle = computed(() => { const confirmTitle = computed(() => {
@@ -139,6 +144,29 @@ async function loadWolHistory() {
} }
} }
async function refreshPowerState() {
try {
const state = await atxApi.status()
powerState.value = state.power_status
} catch {
powerState.value = 'unknown'
}
}
onMounted(() => {
refreshPowerState().catch(() => {})
powerStateTimer = window.setInterval(() => {
refreshPowerState().catch(() => {})
}, 3000)
})
onUnmounted(() => {
if (powerStateTimer !== null) {
window.clearInterval(powerStateTimer)
powerStateTimer = null
}
})
watch( watch(
() => activeTab.value, () => activeTab.value,
(tab) => { (tab) => {

View File

@@ -123,8 +123,7 @@ async function applyConfig() {
} }
await audioApi.start() await audioApi.start()
// Note: handleAudioStateChanged in ConsoleView will handle the connection // ConsoleView will react when system.device_info reflects streaming=true.
// when it receives the audio.state_changed event with streaming=true
} catch (startError) { } catch (startError) {
// Audio start failed - config was saved but streaming not started // Audio start failed - config was saved but streaming not started
console.info('[AudioConfig] Audio start failed:', startError) console.info('[AudioConfig] Audio start failed:', startError)

View File

@@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { CanonicalKey } from '@/types/generated'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const props = defineProps<{ const props = defineProps<{
pressedKeys?: string[] pressedKeys?: CanonicalKey[]
capsLock?: boolean capsLock?: boolean
numLock?: boolean
scrollLock?: boolean
keyboardLedEnabled?: boolean
mousePosition?: { x: number; y: number } mousePosition?: { x: number; y: number }
debugMode?: boolean debugMode?: boolean
compact?: boolean compact?: boolean
@@ -18,13 +22,14 @@ const keyNameMap: Record<string, string> = {
MetaLeft: 'Win', MetaRight: 'Win', MetaLeft: 'Win', MetaRight: 'Win',
ControlLeft: 'Ctrl', ControlRight: 'Ctrl', ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
ShiftLeft: 'Shift', ShiftRight: 'Shift', ShiftLeft: 'Shift', ShiftRight: 'Shift',
AltLeft: 'Alt', AltRight: 'Alt', AltLeft: 'Alt', AltRight: 'AltGr',
CapsLock: 'Caps', NumLock: 'Num', ScrollLock: 'Scroll', CapsLock: 'Caps', NumLock: 'Num', ScrollLock: 'Scroll',
Backspace: 'Back', Delete: 'Del', Backspace: 'Back', Delete: 'Del',
ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→',
Escape: 'Esc', Enter: 'Enter', Tab: 'Tab', Space: 'Space', Escape: 'Esc', Enter: 'Enter', Tab: 'Tab', Space: 'Space',
PageUp: 'PgUp', PageDown: 'PgDn', PageUp: 'PgUp', PageDown: 'PgDn',
Insert: 'Ins', Home: 'Home', End: 'End', Insert: 'Ins', Home: 'Home', End: 'End',
ContextMenu: 'Menu',
} }
const keysDisplay = computed(() => { const keysDisplay = computed(() => {
@@ -40,12 +45,21 @@ const keysDisplay = computed(() => {
<!-- Compact mode for small screens --> <!-- Compact mode for small screens -->
<div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5"> <div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5">
<!-- LED indicator only in compact mode --> <!-- LED indicator only in compact mode -->
<div class="flex items-center gap-1"> <div v-if="keyboardLedEnabled" class="flex items-center gap-1">
<span <span
v-if="capsLock" v-if="capsLock"
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium" class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
>C</span> >C</span>
<span v-else class="text-muted-foreground/40 text-[10px]">-</span> <span v-else class="text-muted-foreground/40 text-[10px]">C</span>
<span
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>N</span>
<span
:class="scrollLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
>S</span>
</div>
<div v-else class="text-[10px] text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div> </div>
<!-- Keys in compact mode --> <!-- Keys in compact mode -->
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]"> <div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]">
@@ -70,8 +84,9 @@ const keysDisplay = computed(() => {
</div> </div>
</div> </div>
<!-- Right side: Caps Lock LED state --> <!-- Right side: Keyboard LED states -->
<div class="flex items-center shrink-0"> <div class="flex items-center shrink-0">
<template v-if="keyboardLedEnabled">
<div <div
:class="cn( :class="cn(
'px-2 py-1 select-none transition-colors', 'px-2 py-1 select-none transition-colors',
@@ -81,6 +96,28 @@ const keysDisplay = computed(() => {
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span> <span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
<span class="sm:hidden">C</span> <span class="sm:hidden">C</span>
</div> </div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.num') }}</span>
<span class="sm:hidden">N</span>
</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.scroll') }}</span>
<span class="sm:hidden">S</span>
</div>
</template>
<div v-else class="px-3 py-1 text-muted-foreground/60">
{{ t('infobar.keyboardLedUnavailable') }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -69,24 +69,24 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
return true return true
} }
const { hidCode, shift } = mapping const { key, shift } = mapping
const modifier = shift ? 0x02 : 0 const modifier = shift ? 0x02 : 0
try { try {
// Send keydown // Send keydown
await hidApi.keyboard('down', hidCode, modifier) await hidApi.keyboard('down', key, modifier)
// Small delay between down and up to ensure key is registered // Small delay between down and up to ensure key is registered
await sleep(5) await sleep(5)
if (signal.aborted) { if (signal.aborted) {
// Even if aborted, still send keyup to release the key // Even if aborted, still send keyup to release the key
await hidApi.keyboard('up', hidCode, modifier) await hidApi.keyboard('up', key, modifier)
return false return false
} }
// Send keyup // Send keyup
await hidApi.keyboard('up', hidCode, modifier) await hidApi.keyboard('up', key, modifier)
// Additional small delay after keyup to ensure it's processed // Additional small delay after keyup to ensure it's processed
await sleep(2) await sleep(2)
@@ -96,7 +96,7 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
console.error('[Paste] Failed to type character:', char, error) console.error('[Paste] Failed to type character:', char, error)
// Try to release the key even on error // Try to release the key even on error
try { try {
await hidApi.keyboard('up', hidCode, modifier) await hidApi.keyboard('up', key, modifier)
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }

View File

@@ -27,6 +27,7 @@ import {
type BitratePreset, type BitratePreset,
type StreamConstraintsResponse, type StreamConstraintsResponse,
} from '@/api' } from '@/api'
import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format-support'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -167,6 +168,12 @@ const isBrowserSupported = (codecId: string): boolean => {
return browserSupportedCodecs.value.has(codecId) return browserSupportedCodecs.value.has(codecId)
} }
const getFormatState = (formatName: string) =>
getVideoFormatState(formatName, props.videoMode, currentEncoderBackend.value)
const isFormatUnsupported = (formatName: string): boolean =>
getFormatState(formatName) === 'unsupported'
// Translate backend name for display // Translate backend name for display
const translateBackendName = (backend: string | undefined): string => { const translateBackendName = (backend: string | undefined): string => {
if (!backend) return '' if (!backend) return ''
@@ -189,6 +196,10 @@ const hasHighFps = (format: { resolutions: { fps: number[] }[] }): boolean => {
// Check if a format is recommended based on video mode // Check if a format is recommended based on video mode
const isFormatRecommended = (formatName: string): boolean => { const isFormatRecommended = (formatName: string): boolean => {
if (!isVideoFormatSelectable(formatName, props.videoMode, currentEncoderBackend.value)) {
return false
}
const formats = availableFormats.value const formats = availableFormats.value
const upperFormat = formatName.toUpperCase() const upperFormat = formatName.toUpperCase()
@@ -225,12 +236,7 @@ const isFormatRecommended = (formatName: string): boolean => {
// Check if a format is not recommended for current video mode // Check if a format is not recommended for current video mode
// In WebRTC mode, compressed formats (MJPEG/JPEG) are not recommended // In WebRTC mode, compressed formats (MJPEG/JPEG) are not recommended
const isFormatNotRecommended = (formatName: string): boolean => { const isFormatNotRecommended = (formatName: string): boolean => {
const upperFormat = formatName.toUpperCase() return getFormatState(formatName) === 'not_recommended'
// WebRTC mode: MJPEG/JPEG are not recommended (require decoding before encoding)
if (props.videoMode !== 'mjpeg') {
return upperFormat === 'MJPEG' || upperFormat === 'JPEG'
}
return false
} }
// Selected values (mode comes from props) // Selected values (mode comes from props)
@@ -303,6 +309,14 @@ const availableFormats = computed(() => {
return device?.formats || [] return device?.formats || []
}) })
const availableFormatOptions = computed(() => {
return availableFormats.value.map(format => ({
...format,
state: getFormatState(format.format),
disabled: isFormatUnsupported(format.format),
}))
})
const availableResolutions = computed(() => { const availableResolutions = computed(() => {
const format = availableFormats.value.find(f => f.format === selectedFormat.value) const format = availableFormats.value.find(f => f.format === selectedFormat.value)
return format?.resolutions || [] return format?.resolutions || []
@@ -317,8 +331,8 @@ const availableFps = computed(() => {
// Get selected format description for display in trigger // Get selected format description for display in trigger
const selectedFormatInfo = computed(() => { const selectedFormatInfo = computed(() => {
const format = availableFormats.value.find(f => f.format === selectedFormat.value) const format = availableFormatOptions.value.find(f => f.format === selectedFormat.value)
return format ? { description: format.description, format: format.format } : null return format
}) })
// Get selected codec info for display in trigger // Get selected codec info for display in trigger
@@ -423,6 +437,37 @@ function handleVideoModeChange(mode: unknown) {
emit('update:videoMode', mode as VideoMode) emit('update:videoMode', mode as VideoMode)
} }
function findFirstSelectableFormat(
formats: VideoDevice['formats'],
): VideoDevice['formats'][number] | undefined {
return formats.find(format =>
isVideoFormatSelectable(format.format, props.videoMode, currentEncoderBackend.value),
)
}
function clearFormatSelection() {
selectedFormat.value = ''
selectedResolution.value = ''
selectedFps.value = 30
}
function selectFormatWithDefaults(format: string) {
if (isFormatUnsupported(format)) return
selectedFormat.value = format
const formatData = availableFormats.value.find(f => f.format === format)
const resolution = formatData?.resolutions[0]
if (!resolution) {
selectedResolution.value = ''
selectedFps.value = 30
return
}
selectedResolution.value = `${resolution.width}x${resolution.height}`
selectedFps.value = resolution.fps[0] || 30
}
// Handle device change // Handle device change
function handleDeviceChange(devicePath: unknown) { function handleDeviceChange(devicePath: unknown) {
if (typeof devicePath !== 'string') return if (typeof devicePath !== 'string') return
@@ -431,31 +476,22 @@ function handleDeviceChange(devicePath: unknown) {
// Auto-select first format // Auto-select first format
const device = devices.value.find(d => d.path === devicePath) const device = devices.value.find(d => d.path === devicePath)
if (device?.formats[0]) { const format = device ? findFirstSelectableFormat(device.formats) : undefined
selectedFormat.value = device.formats[0].format if (!format) {
clearFormatSelection()
return
}
// Auto-select first resolution selectFormatWithDefaults(format.format)
const resolution = device.formats[0].resolutions[0]
if (resolution) {
selectedResolution.value = `${resolution.width}x${resolution.height}`
selectedFps.value = resolution.fps[0] || 30
}
}
} }
// Handle format change // Handle format change
function handleFormatChange(format: unknown) { function handleFormatChange(format: unknown) {
if (typeof format !== 'string') return if (typeof format !== 'string') return
selectedFormat.value = format if (isFormatUnsupported(format)) return
isDirty.value = true
// Auto-select first resolution for this format selectFormatWithDefaults(format)
const formatData = availableFormats.value.find(f => f.format === format) isDirty.value = true
if (formatData?.resolutions[0]) {
const resolution = formatData.resolutions[0]
selectedResolution.value = `${resolution.width}x${resolution.height}`
selectedFps.value = resolution.fps[0] || 30
}
} }
// Handle resolution change // Handle resolution change
@@ -567,6 +603,29 @@ watch(currentConfig, () => {
if (props.open && isDirty.value) return if (props.open && isDirty.value) return
syncFromCurrentIfChanged() syncFromCurrentIfChanged()
}, { deep: true }) }, { deep: true })
watch(
[availableFormatOptions, () => props.videoMode, currentEncoderBackend],
() => {
if (!selectedDevice.value) return
const currentFormat = availableFormatOptions.value.find(
format => format.format === selectedFormat.value,
)
if (currentFormat && !currentFormat.disabled) {
return
}
const fallback = availableFormatOptions.value.find(format => !format.disabled)
if (!fallback) {
clearFormatSelection()
return
}
selectFormatWithDefaults(fallback.format)
},
{ deep: true },
)
</script> </script>
<template> <template>
@@ -770,6 +829,12 @@ watch(currentConfig, () => {
<SelectTrigger class="h-8 text-xs"> <SelectTrigger class="h-8 text-xs">
<div v-if="selectedFormatInfo" class="flex items-center gap-1.5 truncate"> <div v-if="selectedFormatInfo" class="flex items-center gap-1.5 truncate">
<span class="truncate">{{ selectedFormatInfo.description }}</span> <span class="truncate">{{ selectedFormatInfo.description }}</span>
<span
v-if="selectedFormatInfo.state === 'unsupported'"
class="shrink-0 text-muted-foreground"
>
{{ t('common.notSupportedYet') }}
</span>
<span <span
v-if="isFormatRecommended(selectedFormatInfo.format)" v-if="isFormatRecommended(selectedFormatInfo.format)"
class="text-[10px] px-1 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 shrink-0" class="text-[10px] px-1 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 shrink-0"
@@ -787,13 +852,20 @@ watch(currentConfig, () => {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem
v-for="format in availableFormats" v-for="format in availableFormatOptions"
:key="format.format" :key="format.format"
:value="format.format" :value="format.format"
class="text-xs" :disabled="format.disabled"
:class="['text-xs', { 'opacity-50': format.disabled }]"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span>{{ format.description }}</span> <span>{{ format.description }}</span>
<span
v-if="format.state === 'unsupported'"
class="text-muted-foreground"
>
{{ t('common.notSupportedYet') }}
</span>
<span <span
v-if="isFormatRecommended(format.format)" v-if="isFormatRecommended(format.format)"
class="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300" class="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"

View File

@@ -4,12 +4,13 @@ import { useI18n } from 'vue-i18n'
import Keyboard from 'simple-keyboard' import Keyboard from 'simple-keyboard'
import 'simple-keyboard/build/css/index.css' import 'simple-keyboard/build/css/index.css'
import { hidApi } from '@/api' import { hidApi } from '@/api'
import { CanonicalKey } from '@/types/generated'
import { import {
keys, keys,
consumerKeys, consumerKeys,
latchingKeys, latchingKeys,
modifiers, modifiers,
updateModifierMaskForHidKey, updateModifierMaskForKey,
type KeyName, type KeyName,
type ConsumerKeyName, type ConsumerKeyName,
} from '@/lib/keyboardMappings' } from '@/lib/keyboardMappings'
@@ -23,13 +24,16 @@ import {
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
attached?: boolean attached?: boolean
capsLock?: boolean
pressedKeys?: CanonicalKey[]
consumerEnabled?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:visible', value: boolean): void (e: 'update:visible', value: boolean): void
(e: 'update:attached', value: boolean): void (e: 'update:attached', value: boolean): void
(e: 'keyDown', key: string): void (e: 'keyDown', key: CanonicalKey): void
(e: 'keyUp', key: string): void (e: 'keyUp', key: CanonicalKey): void
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -45,13 +49,17 @@ const arrowsKeyboard = ref<Keyboard | null>(null)
// Pressed keys tracking // Pressed keys tracking
const pressedModifiers = ref<number>(0) const pressedModifiers = ref<number>(0)
const keysDown = ref<string[]>([]) const keysDown = ref<CanonicalKey[]>([])
// Shift state for display // Shift state for display
const isShiftActive = computed(() => { const isShiftActive = computed(() => {
return (pressedModifiers.value & 0x22) !== 0 return (pressedModifiers.value & 0x22) !== 0
}) })
const areLettersUppercase = computed(() => {
return Boolean(props.capsLock) !== isShiftActive.value
})
const layoutName = computed(() => { const layoutName = computed(() => {
return isShiftActive.value ? 'shift' : 'default' return isShiftActive.value ? 'shift' : 'default'
}) })
@@ -63,7 +71,12 @@ const keyNamesForDownKeys = computed(() => {
.filter(([_, mask]) => (activeModifierMask & mask) !== 0) .filter(([_, mask]) => (activeModifierMask & mask) !== 0)
.map(([name]) => name) .map(([name]) => name)
return [...modifierNames, ...keysDown.value, ' '] return Array.from(new Set([
...modifierNames,
...(props.pressedKeys ?? []),
...keysDown.value,
...(props.capsLock ? ['CapsLock'] : []),
]))
}) })
// Dragging state (for floating mode) // Dragging state (for floating mode)
@@ -88,7 +101,7 @@ const keyboardLayout = {
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash', 'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter', 'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter',
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight', 'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight',
'ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight', 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
], ],
shift: [ shift: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace', 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
@@ -97,7 +110,7 @@ const keyboardLayout = {
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)', 'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)',
'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter', 'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter',
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight', 'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight',
'ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight', 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
], ],
}, },
control: { control: {
@@ -148,11 +161,10 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
ShiftLeft: 'Shift', ShiftLeft: 'Shift',
ShiftRight: 'Shift', ShiftRight: 'Shift',
AltLeft: 'Alt', AltLeft: 'Alt',
AltRight: 'Alt', AltRight: 'AltGr',
AltGr: 'AltGr',
MetaLeft: metaLabel, MetaLeft: metaLabel,
MetaRight: metaLabel, MetaRight: metaLabel,
Menu: 'Menu', ContextMenu: 'Menu',
// Special keys // Special keys
Escape: 'Esc', Escape: 'Esc',
@@ -187,20 +199,60 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12', F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
// Letters // Letters
KeyA: 'a', KeyB: 'b', KeyC: 'c', KeyD: 'd', KeyE: 'e', KeyA: areLettersUppercase.value ? 'A' : 'a',
KeyF: 'f', KeyG: 'g', KeyH: 'h', KeyI: 'i', KeyJ: 'j', KeyB: areLettersUppercase.value ? 'B' : 'b',
KeyK: 'k', KeyL: 'l', KeyM: 'm', KeyN: 'n', KeyO: 'o', KeyC: areLettersUppercase.value ? 'C' : 'c',
KeyP: 'p', KeyQ: 'q', KeyR: 'r', KeyS: 's', KeyT: 't', KeyD: areLettersUppercase.value ? 'D' : 'd',
KeyU: 'u', KeyV: 'v', KeyW: 'w', KeyX: 'x', KeyY: 'y', KeyE: areLettersUppercase.value ? 'E' : 'e',
KeyZ: 'z', KeyF: areLettersUppercase.value ? 'F' : 'f',
KeyG: areLettersUppercase.value ? 'G' : 'g',
KeyH: areLettersUppercase.value ? 'H' : 'h',
KeyI: areLettersUppercase.value ? 'I' : 'i',
KeyJ: areLettersUppercase.value ? 'J' : 'j',
KeyK: areLettersUppercase.value ? 'K' : 'k',
KeyL: areLettersUppercase.value ? 'L' : 'l',
KeyM: areLettersUppercase.value ? 'M' : 'm',
KeyN: areLettersUppercase.value ? 'N' : 'n',
KeyO: areLettersUppercase.value ? 'O' : 'o',
KeyP: areLettersUppercase.value ? 'P' : 'p',
KeyQ: areLettersUppercase.value ? 'Q' : 'q',
KeyR: areLettersUppercase.value ? 'R' : 'r',
KeyS: areLettersUppercase.value ? 'S' : 's',
KeyT: areLettersUppercase.value ? 'T' : 't',
KeyU: areLettersUppercase.value ? 'U' : 'u',
KeyV: areLettersUppercase.value ? 'V' : 'v',
KeyW: areLettersUppercase.value ? 'W' : 'w',
KeyX: areLettersUppercase.value ? 'X' : 'x',
KeyY: areLettersUppercase.value ? 'Y' : 'y',
KeyZ: areLettersUppercase.value ? 'Z' : 'z',
// Capital letters // Letter labels in the shifted layout follow CapsLock xor Shift too
'(KeyA)': 'A', '(KeyB)': 'B', '(KeyC)': 'C', '(KeyD)': 'D', '(KeyE)': 'E', '(KeyA)': areLettersUppercase.value ? 'A' : 'a',
'(KeyF)': 'F', '(KeyG)': 'G', '(KeyH)': 'H', '(KeyI)': 'I', '(KeyJ)': 'J', '(KeyB)': areLettersUppercase.value ? 'B' : 'b',
'(KeyK)': 'K', '(KeyL)': 'L', '(KeyM)': 'M', '(KeyN)': 'N', '(KeyO)': 'O', '(KeyC)': areLettersUppercase.value ? 'C' : 'c',
'(KeyP)': 'P', '(KeyQ)': 'Q', '(KeyR)': 'R', '(KeyS)': 'S', '(KeyT)': 'T', '(KeyD)': areLettersUppercase.value ? 'D' : 'd',
'(KeyU)': 'U', '(KeyV)': 'V', '(KeyW)': 'W', '(KeyX)': 'X', '(KeyY)': 'Y', '(KeyE)': areLettersUppercase.value ? 'E' : 'e',
'(KeyZ)': 'Z', '(KeyF)': areLettersUppercase.value ? 'F' : 'f',
'(KeyG)': areLettersUppercase.value ? 'G' : 'g',
'(KeyH)': areLettersUppercase.value ? 'H' : 'h',
'(KeyI)': areLettersUppercase.value ? 'I' : 'i',
'(KeyJ)': areLettersUppercase.value ? 'J' : 'j',
'(KeyK)': areLettersUppercase.value ? 'K' : 'k',
'(KeyL)': areLettersUppercase.value ? 'L' : 'l',
'(KeyM)': areLettersUppercase.value ? 'M' : 'm',
'(KeyN)': areLettersUppercase.value ? 'N' : 'n',
'(KeyO)': areLettersUppercase.value ? 'O' : 'o',
'(KeyP)': areLettersUppercase.value ? 'P' : 'p',
'(KeyQ)': areLettersUppercase.value ? 'Q' : 'q',
'(KeyR)': areLettersUppercase.value ? 'R' : 'r',
'(KeyS)': areLettersUppercase.value ? 'S' : 's',
'(KeyT)': areLettersUppercase.value ? 'T' : 't',
'(KeyU)': areLettersUppercase.value ? 'U' : 'u',
'(KeyV)': areLettersUppercase.value ? 'V' : 'v',
'(KeyW)': areLettersUppercase.value ? 'W' : 'w',
'(KeyX)': areLettersUppercase.value ? 'X' : 'x',
'(KeyY)': areLettersUppercase.value ? 'Y' : 'y',
'(KeyZ)': areLettersUppercase.value ? 'Z' : 'z',
// Numbers // Numbers
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5', Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
@@ -303,47 +355,47 @@ async function onKeyDown(key: string) {
const keyCode = keys[cleanKey as KeyName] const keyCode = keys[cleanKey as KeyName]
// Handle latching keys (Caps Lock, etc.) // Handle latching keys (Caps Lock, etc.)
if ((latchingKeys as readonly string[]).includes(cleanKey)) { if (latchingKeys.some(latchingKey => latchingKey === keyCode)) {
emit('keyDown', cleanKey) emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask) await sendKeyPress(keyCode, true, currentMask)
setTimeout(() => { setTimeout(() => {
sendKeyPress(keyCode, false, currentMask) sendKeyPress(keyCode, false, currentMask)
emit('keyUp', cleanKey) emit('keyUp', keyCode)
}, 100) }, 100)
return return
} }
// Handle modifier keys (toggle) // Handle modifier keys (toggle)
if (cleanKey in modifiers) { const mask = modifiers[keyCode] ?? 0
const mask = modifiers[cleanKey as keyof typeof modifiers] if (mask !== 0) {
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0 const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
if (isCurrentlyDown) { if (isCurrentlyDown) {
const nextMask = pressedModifiers.value & ~mask const nextMask = pressedModifiers.value & ~mask
pressedModifiers.value = nextMask pressedModifiers.value = nextMask
await sendKeyPress(keyCode, false, nextMask) await sendKeyPress(keyCode, false, nextMask)
emit('keyUp', cleanKey) emit('keyUp', keyCode)
} else { } else {
const nextMask = pressedModifiers.value | mask const nextMask = pressedModifiers.value | mask
pressedModifiers.value = nextMask pressedModifiers.value = nextMask
await sendKeyPress(keyCode, true, nextMask) await sendKeyPress(keyCode, true, nextMask)
emit('keyDown', cleanKey) emit('keyDown', keyCode)
} }
updateKeyboardButtonTheme() updateKeyboardButtonTheme()
return return
} }
// Regular key: press and release // Regular key: press and release
keysDown.value.push(cleanKey) keysDown.value.push(keyCode)
emit('keyDown', cleanKey) emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask) await sendKeyPress(keyCode, true, currentMask)
updateKeyboardButtonTheme() updateKeyboardButtonTheme()
setTimeout(async () => { setTimeout(async () => {
keysDown.value = keysDown.value.filter(k => k !== cleanKey) keysDown.value = keysDown.value.filter(k => k !== keyCode)
await sendKeyPress(keyCode, false, currentMask) await sendKeyPress(keyCode, false, currentMask)
emit('keyUp', cleanKey) emit('keyUp', keyCode)
updateKeyboardButtonTheme() updateKeyboardButtonTheme()
}, 50) }, 50)
} }
@@ -352,7 +404,7 @@ async function onKeyUp() {
// Not used for now - we handle up in onKeyDown with setTimeout // Not used for now - we handle up in onKeyDown with setTimeout
} }
async function sendKeyPress(keyCode: number, press: boolean, modifierMask: number) { async function sendKeyPress(keyCode: CanonicalKey, press: boolean, modifierMask: number) {
try { try {
await hidApi.keyboard(press ? 'down' : 'up', keyCode, modifierMask & 0xff) await hidApi.keyboard(press ? 'down' : 'up', keyCode, modifierMask & 0xff)
} catch (err) { } catch (err) {
@@ -372,7 +424,7 @@ async function executeMacro(steps: MacroStep[]) {
for (const mod of step.modifiers) { for (const mod of step.modifiers) {
if (mod in keys) { if (mod in keys) {
const modHid = keys[mod as KeyName] const modHid = keys[mod as KeyName]
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, true) macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, true)
await sendKeyPress(modHid, true, macroModifierMask) await sendKeyPress(modHid, true, macroModifierMask)
} }
} }
@@ -394,7 +446,7 @@ async function executeMacro(steps: MacroStep[]) {
for (const mod of step.modifiers) { for (const mod of step.modifiers) {
if (mod in keys) { if (mod in keys) {
const modHid = keys[mod as KeyName] const modHid = keys[mod as KeyName]
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, false) macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, false)
await sendKeyPress(modHid, false, macroModifierMask) await sendKeyPress(modHid, false, macroModifierMask)
} }
} }
@@ -421,8 +473,12 @@ function updateKeyboardButtonTheme() {
} }
// Update layout when shift state changes // Update layout when shift state changes
watch(layoutName, (name) => { watch([layoutName, () => props.capsLock], ([name]) => {
mainKeyboard.value?.setOptions({ layoutName: name }) mainKeyboard.value?.setOptions({
layoutName: name,
display: keyDisplayMap.value,
})
updateKeyboardButtonTheme()
}) })
// Initialize keyboards with unique selectors // Initialize keyboards with unique selectors
@@ -663,7 +719,7 @@ onUnmounted(() => {
<!-- Keyboard body --> <!-- Keyboard body -->
<div class="vkb-body"> <div class="vkb-body">
<!-- Media keys row --> <!-- Media keys row -->
<div class="vkb-media-row"> <div v-if="props.consumerEnabled !== false" class="vkb-media-row">
<button <button
v-for="key in mediaKeys" v-for="key in mediaKeys"
:key="key" :key="key"
@@ -835,12 +891,12 @@ onUnmounted(() => {
min-width: 55px; min-width: 55px;
} }
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"] { .vkb .simple-keyboard .hg-button[data-skbtn="AltRight"] {
flex-grow: 1.25; flex-grow: 1.25;
min-width: 55px; min-width: 55px;
} }
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] { .vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
flex-grow: 1.25; flex-grow: 1.25;
min-width: 55px; min-width: 55px;
} }
@@ -1194,8 +1250,8 @@ html.dark .hg-theme-default .hg-button.down-key,
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"], .vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"], .vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"], .vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"], .vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] { .vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 46px; min-width: 46px;
} }

View File

@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system' import { useSystemStore } from '@/stores/system'
import { useWebSocket } from '@/composables/useWebSocket' import { useWebSocket } from '@/composables/useWebSocket'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
export interface ConsoleEventHandlers { export interface ConsoleEventHandlers {
onStreamConfigChanging?: (data: { reason?: string }) => void onStreamConfigChanging?: (data: { reason?: string }) => void
@@ -20,119 +19,13 @@ export interface ConsoleEventHandlers {
onStreamReconnecting?: (data: { device: string; attempt: number }) => void onStreamReconnecting?: (data: { device: string; attempt: number }) => void
onStreamRecovered?: (data: { device: string }) => void onStreamRecovered?: (data: { device: string }) => void
onDeviceInfo?: (data: any) => void onDeviceInfo?: (data: any) => void
onAudioStateChanged?: (data: { streaming: boolean; device: string | null }) => void
} }
export function useConsoleEvents(handlers: ConsoleEventHandlers) { export function useConsoleEvents(handlers: ConsoleEventHandlers) {
const { t } = useI18n() const { t } = useI18n()
const systemStore = useSystemStore() const systemStore = useSystemStore()
const { on, off, connect } = useWebSocket() const { on, off, connect } = useWebSocket()
const unifiedAudio = getUnifiedAudio()
const noop = () => {} const noop = () => {}
const HID_TOAST_DEDUPE_MS = 30_000
const hidLastToastAt = new Map<string, number>()
function hidErrorHint(errorCode?: string, backend?: string): string {
switch (errorCode) {
case 'udc_not_configured':
return t('hid.errorHints.udcNotConfigured')
case 'enoent':
return t('hid.errorHints.hidDeviceMissing')
case 'port_not_found':
case 'port_not_opened':
return t('hid.errorHints.portNotFound')
case 'no_response':
return t('hid.errorHints.noResponse')
case 'protocol_error':
case 'invalid_response':
return t('hid.errorHints.protocolError')
case 'health_check_failed':
case 'health_check_join_failed':
return t('hid.errorHints.healthCheckFailed')
case 'eio':
case 'epipe':
case 'eshutdown':
if (backend === 'otg') {
return t('hid.errorHints.otgIoError')
}
if (backend === 'ch9329') {
return t('hid.errorHints.ch9329IoError')
}
return t('hid.errorHints.ioError')
default:
return ''
}
}
function formatHidReason(reason: string, errorCode?: string, backend?: string): string {
const hint = hidErrorHint(errorCode, backend)
if (!hint) return reason
return `${reason} (${hint})`
}
// HID event handlers
function handleHidStateChanged(data: {
backend: string
initialized: boolean
error?: string | null
error_code?: string | null
}) {
systemStore.updateHidStateFromEvent({
backend: data.backend,
initialized: data.initialized,
error: data.error ?? null,
error_code: data.error_code ?? null,
})
}
function handleHidDeviceLost(data: { backend: string; device?: string; reason: string; error_code: string }) {
const temporaryErrors = ['eagain', 'eagain_retry']
if (temporaryErrors.includes(data.error_code)) return
systemStore.updateHidStateFromEvent({
backend: data.backend,
initialized: false,
error: data.reason,
error_code: data.error_code,
})
const dedupeKey = `${data.backend}:${data.error_code}`
const now = Date.now()
const last = hidLastToastAt.get(dedupeKey) ?? 0
if (now - last < HID_TOAST_DEDUPE_MS) {
return
}
hidLastToastAt.set(dedupeKey, now)
const reason = formatHidReason(data.reason, data.error_code, data.backend)
toast.error(t('hid.deviceLost'), {
description: t('hid.deviceLostDesc', { backend: data.backend, reason }),
duration: 5000,
})
}
function handleHidReconnecting(data: { backend: string; attempt: number }) {
if (data.attempt === 1 || data.attempt % 5 === 0) {
toast.info(t('hid.reconnecting'), {
description: t('hid.reconnectingDesc', { attempt: data.attempt }),
duration: 3000,
})
}
}
function handleHidRecovered(data: { backend: string }) {
systemStore.updateHidStateFromEvent({
backend: data.backend,
initialized: true,
error: null,
error_code: null,
})
toast.success(t('hid.recovered'), {
description: t('hid.recoveredDesc', { backend: data.backend }),
duration: 3000,
})
}
// Stream device monitoring handlers // Stream device monitoring handlers
function handleStreamDeviceLost(data: { device: string; reason: string }) { function handleStreamDeviceLost(data: { device: string; reason: string }) {
if (systemStore.stream) { if (systemStore.stream) {
@@ -177,93 +70,8 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
handlers.onStreamStateChanged?.(data) handlers.onStreamStateChanged?.(data)
} }
// Audio device monitoring handlers
function handleAudioDeviceLost(data: { device?: string; reason: string; error_code: string }) {
if (systemStore.audio) {
systemStore.audio.streaming = false
systemStore.audio.error = data.reason
}
toast.error(t('audio.deviceLost'), {
description: t('audio.deviceLostDesc', { device: data.device || 'default', reason: data.reason }),
duration: 5000,
})
}
function handleAudioReconnecting(data: { attempt: number }) {
if (data.attempt === 1 || data.attempt % 5 === 0) {
toast.info(t('audio.reconnecting'), {
description: t('audio.reconnectingDesc', { attempt: data.attempt }),
duration: 3000,
})
}
}
function handleAudioRecovered(data: { device?: string }) {
if (systemStore.audio) {
systemStore.audio.error = null
}
toast.success(t('audio.recovered'), {
description: t('audio.recoveredDesc', { device: data.device || 'default' }),
duration: 3000,
})
}
async function handleAudioStateChanged(data: { streaming: boolean; device: string | null }) {
if (!data.streaming) {
unifiedAudio.disconnect()
return
}
handlers.onAudioStateChanged?.(data)
}
// MSD event handlers
function handleMsdStateChanged(_data: { mode: string; connected: boolean }) {
systemStore.fetchMsdState().catch(() => null)
}
function handleMsdImageMounted(data: { image_id: string; image_name: string; size: number; cdrom: boolean }) {
toast.success(t('msd.imageMounted', { name: data.image_name }), {
description: `${(data.size / 1024 / 1024).toFixed(2)} MB - ${data.cdrom ? 'CD-ROM' : 'Disk'}`,
duration: 3000,
})
systemStore.fetchMsdState().catch(() => null)
}
function handleMsdImageUnmounted() {
toast.info(t('msd.imageUnmounted'), {
duration: 2000,
})
systemStore.fetchMsdState().catch(() => null)
}
function handleMsdError(data: { reason: string; error_code: string }) {
if (systemStore.msd) {
systemStore.msd.error = data.reason
}
toast.error(t('msd.error'), {
description: t('msd.errorDesc', { reason: data.reason }),
duration: 5000,
})
}
function handleMsdRecovered() {
if (systemStore.msd) {
systemStore.msd.error = null
}
toast.success(t('msd.recovered'), {
description: t('msd.recoveredDesc'),
duration: 3000,
})
}
// Subscribe to all events // Subscribe to all events
function subscribe() { function subscribe() {
// HID events
on('hid.state_changed', handleHidStateChanged)
on('hid.device_lost', handleHidDeviceLost)
on('hid.reconnecting', handleHidReconnecting)
on('hid.recovered', handleHidRecovered)
// Stream events // Stream events
on('stream.config_changing', handlers.onStreamConfigChanging ?? noop) on('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
on('stream.config_applied', handlers.onStreamConfigApplied ?? noop) on('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
@@ -277,19 +85,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
on('stream.reconnecting', handleStreamReconnecting) on('stream.reconnecting', handleStreamReconnecting)
on('stream.recovered', handleStreamRecovered) on('stream.recovered', handleStreamRecovered)
// Audio events
on('audio.state_changed', handleAudioStateChanged)
on('audio.device_lost', handleAudioDeviceLost)
on('audio.reconnecting', handleAudioReconnecting)
on('audio.recovered', handleAudioRecovered)
// MSD events
on('msd.state_changed', handleMsdStateChanged)
on('msd.image_mounted', handleMsdImageMounted)
on('msd.image_unmounted', handleMsdImageUnmounted)
on('msd.error', handleMsdError)
on('msd.recovered', handleMsdRecovered)
// System events // System events
on('system.device_info', handlers.onDeviceInfo ?? noop) on('system.device_info', handlers.onDeviceInfo ?? noop)
@@ -299,11 +94,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
// Unsubscribe from all events // Unsubscribe from all events
function unsubscribe() { function unsubscribe() {
off('hid.state_changed', handleHidStateChanged)
off('hid.device_lost', handleHidDeviceLost)
off('hid.reconnecting', handleHidReconnecting)
off('hid.recovered', handleHidRecovered)
off('stream.config_changing', handlers.onStreamConfigChanging ?? noop) off('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
off('stream.config_applied', handlers.onStreamConfigApplied ?? noop) off('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
off('stream.stats_update', handlers.onStreamStatsUpdate ?? noop) off('stream.stats_update', handlers.onStreamStatsUpdate ?? noop)
@@ -316,17 +106,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
off('stream.reconnecting', handleStreamReconnecting) off('stream.reconnecting', handleStreamReconnecting)
off('stream.recovered', handleStreamRecovered) off('stream.recovered', handleStreamRecovered)
off('audio.state_changed', handleAudioStateChanged)
off('audio.device_lost', handleAudioDeviceLost)
off('audio.reconnecting', handleAudioReconnecting)
off('audio.recovered', handleAudioRecovered)
off('msd.state_changed', handleMsdStateChanged)
off('msd.image_mounted', handleMsdImageMounted)
off('msd.image_unmounted', handleMsdImageUnmounted)
off('msd.error', handleMsdError)
off('msd.recovered', handleMsdRecovered)
off('system.device_info', handlers.onDeviceInfo ?? noop) off('system.device_info', handlers.onDeviceInfo ?? noop)
} }

View File

@@ -1,343 +0,0 @@
// HID input composable - manages keyboard and mouse input
// Extracted from ConsoleView.vue for better separation of concerns
import { ref, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { hidApi } from '@/api'
import { keyboardEventToHidCode, updateModifierMaskForHidKey } from '@/lib/keyboardMappings'
export interface HidInputState {
mouseMode: Ref<'absolute' | 'relative'>
pressedKeys: Ref<string[]>
keyboardLed: Ref<{ capsLock: boolean; numLock: boolean; scrollLock: boolean }>
mousePosition: Ref<{ x: number; y: number }>
isPointerLocked: Ref<boolean>
cursorVisible: Ref<boolean>
}
export interface UseHidInputOptions {
videoContainerRef: Ref<HTMLDivElement | null>
getVideoElement: () => HTMLElement | null
isFullscreen: Ref<boolean>
}
export function useHidInput(options: UseHidInputOptions) {
const { t } = useI18n()
// State
const mouseMode = ref<'absolute' | 'relative'>('absolute')
const pressedKeys = ref<string[]>([])
const keyboardLed = ref({
capsLock: false,
numLock: false,
scrollLock: false,
})
const activeModifierMask = ref(0)
const mousePosition = ref({ x: 0, y: 0 })
const lastMousePosition = ref({ x: 0, y: 0 })
const isPointerLocked = ref(false)
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
const pressedMouseButton = ref<'left' | 'right' | 'middle' | null>(null)
// Error handling - silently handle all HID errors
function handleHidError(_error: unknown, _operation: string) {
// All HID errors are silently ignored
}
// Check if a key should be blocked
function shouldBlockKey(e: KeyboardEvent): boolean {
if (options.isFullscreen.value) return true
const key = e.key.toUpperCase()
if (e.ctrlKey && ['W', 'T', 'N'].includes(key)) return false
if (key === 'F11') return false
if (e.altKey && key === 'TAB') return false
return true
}
// Keyboard handlers
function handleKeyDown(e: KeyboardEvent) {
const container = options.videoContainerRef.value
if (!container) return
if (!options.isFullscreen.value && !container.contains(document.activeElement)) return
if (shouldBlockKey(e)) {
e.preventDefault()
e.stopPropagation()
}
if (!options.isFullscreen.value && (e.metaKey || e.key === 'Meta')) {
toast.info(t('console.metaKeyHint'), {
description: t('console.metaKeyHintDesc'),
duration: 3000,
})
}
const keyName = e.key === ' ' ? 'Space' : e.key
if (!pressedKeys.value.includes(keyName)) {
pressedKeys.value = [...pressedKeys.value, keyName]
}
keyboardLed.value.capsLock = e.getModifierState('CapsLock')
keyboardLed.value.numLock = e.getModifierState('NumLock')
keyboardLed.value.scrollLock = e.getModifierState('ScrollLock')
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
return
}
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, true)
activeModifierMask.value = modifierMask
hidApi.keyboard('down', hidKey, modifierMask).catch(err => handleHidError(err, 'keyboard down'))
}
function handleKeyUp(e: KeyboardEvent) {
const container = options.videoContainerRef.value
if (!container) return
if (!options.isFullscreen.value && !container.contains(document.activeElement)) return
if (shouldBlockKey(e)) {
e.preventDefault()
e.stopPropagation()
}
const keyName = e.key === ' ' ? 'Space' : e.key
pressedKeys.value = pressedKeys.value.filter(k => k !== keyName)
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
return
}
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, false)
activeModifierMask.value = modifierMask
hidApi.keyboard('up', hidKey, modifierMask).catch(err => handleHidError(err, 'keyboard up'))
}
// Mouse handlers
function handleMouseMove(e: MouseEvent) {
const videoElement = options.getVideoElement()
if (!videoElement) return
if (mouseMode.value === 'absolute') {
const rect = videoElement.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) / rect.width * 32767)
const y = Math.round((e.clientY - rect.top) / rect.height * 32767)
mousePosition.value = { x, y }
hidApi.mouse({ type: 'move_abs', x, y }).catch(err => handleHidError(err, 'mouse move'))
} else {
if (isPointerLocked.value) {
const dx = e.movementX
const dy = e.movementY
if (dx !== 0 || dy !== 0) {
const clampedDx = Math.max(-127, Math.min(127, dx))
const clampedDy = Math.max(-127, Math.min(127, dy))
hidApi.mouse({ type: 'move', x: clampedDx, y: clampedDy }).catch(err => handleHidError(err, 'mouse move'))
}
mousePosition.value = {
x: mousePosition.value.x + dx,
y: mousePosition.value.y + dy,
}
}
}
}
function handleMouseDown(e: MouseEvent) {
e.preventDefault()
const container = options.videoContainerRef.value
if (container && document.activeElement !== container) {
if (typeof container.focus === 'function') {
container.focus()
}
}
if (mouseMode.value === 'relative' && !isPointerLocked.value) {
requestPointerLock()
return
}
const button = e.button === 0 ? 'left' : e.button === 2 ? 'right' : 'middle'
pressedMouseButton.value = button
hidApi.mouse({ type: 'down', button }).catch(err => handleHidError(err, 'mouse down'))
}
function handleMouseUp(e: MouseEvent) {
e.preventDefault()
handleMouseUpInternal(e.button)
}
function handleWindowMouseUp(e: MouseEvent) {
if (pressedMouseButton.value !== null) {
handleMouseUpInternal(e.button)
}
}
function handleMouseUpInternal(rawButton: number) {
if (mouseMode.value === 'relative' && !isPointerLocked.value) {
pressedMouseButton.value = null
return
}
const button = rawButton === 0 ? 'left' : rawButton === 2 ? 'right' : 'middle'
if (pressedMouseButton.value !== button) return
pressedMouseButton.value = null
hidApi.mouse({ type: 'up', button }).catch(err => handleHidError(err, 'mouse up'))
}
function handleWheel(e: WheelEvent) {
e.preventDefault()
const scroll = e.deltaY > 0 ? -1 : 1
hidApi.mouse({ type: 'scroll', scroll }).catch(err => handleHidError(err, 'mouse scroll'))
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault()
}
// Pointer lock
function requestPointerLock() {
const container = options.videoContainerRef.value
if (!container) return
container.requestPointerLock().catch((err: Error) => {
toast.error(t('console.pointerLockFailed'), {
description: err.message,
})
})
}
function exitPointerLock() {
if (document.pointerLockElement) {
document.exitPointerLock()
}
}
function handlePointerLockChange() {
const container = options.videoContainerRef.value
isPointerLocked.value = document.pointerLockElement === container
if (isPointerLocked.value) {
mousePosition.value = { x: 0, y: 0 }
toast.info(t('console.pointerLocked'), {
description: t('console.pointerLockedDesc'),
duration: 3000,
})
}
}
function handlePointerLockError() {
isPointerLocked.value = false
}
function handleBlur() {
pressedKeys.value = []
activeModifierMask.value = 0
if (pressedMouseButton.value !== null) {
const button = pressedMouseButton.value
pressedMouseButton.value = null
hidApi.mouse({ type: 'up', button }).catch(err => handleHidError(err, 'mouse up (blur)'))
}
}
// Mode toggle
function toggleMouseMode() {
if (mouseMode.value === 'relative' && isPointerLocked.value) {
exitPointerLock()
}
mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute'
lastMousePosition.value = { x: 0, y: 0 }
mousePosition.value = { x: 0, y: 0 }
if (mouseMode.value === 'relative') {
toast.info(t('console.relativeModeHint'), {
description: t('console.relativeModeHintDesc'),
duration: 5000,
})
}
}
// Virtual keyboard handlers
function handleVirtualKeyDown(key: string) {
if (!pressedKeys.value.includes(key)) {
pressedKeys.value = [...pressedKeys.value, key]
}
}
function handleVirtualKeyUp(key: string) {
pressedKeys.value = pressedKeys.value.filter(k => k !== key)
}
// Cursor visibility
function handleCursorVisibilityChange(e: Event) {
const customEvent = e as CustomEvent<{ visible: boolean }>
cursorVisible.value = customEvent.detail.visible
}
// Setup event listeners
function setupEventListeners() {
document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('pointerlockerror', handlePointerLockError)
window.addEventListener('blur', handleBlur)
window.addEventListener('mouseup', handleWindowMouseUp)
window.addEventListener('cursor-visibility-change', handleCursorVisibilityChange)
}
function cleanupEventListeners() {
document.removeEventListener('pointerlockchange', handlePointerLockChange)
document.removeEventListener('pointerlockerror', handlePointerLockError)
window.removeEventListener('blur', handleBlur)
window.removeEventListener('mouseup', handleWindowMouseUp)
window.removeEventListener('cursor-visibility-change', handleCursorVisibilityChange)
}
return {
// State
mouseMode,
pressedKeys,
keyboardLed,
mousePosition,
isPointerLocked,
cursorVisible,
// Keyboard handlers
handleKeyDown,
handleKeyUp,
// Mouse handlers
handleMouseMove,
handleMouseDown,
handleMouseUp,
handleWheel,
handleContextMenu,
// Pointer lock
requestPointerLock,
exitPointerLock,
// Mode toggle
toggleMouseMode,
// Virtual keyboard
handleVirtualKeyDown,
handleVirtualKeyUp,
// Cursor visibility
handleCursorVisibilityChange,
// Lifecycle
setupEventListeners,
cleanupEventListeners,
}
}

View File

@@ -16,13 +16,40 @@ type EventHandler = (data: any) => void
let wsInstance: WebSocket | null = null let wsInstance: WebSocket | null = null
let handlers = new Map<string, EventHandler[]>() let handlers = new Map<string, EventHandler[]>()
let subscribedTopics: string[] = []
const connected = ref(false) const connected = ref(false)
const reconnectAttempts = ref(0) const reconnectAttempts = ref(0)
const networkError = ref(false) const networkError = ref(false)
const networkErrorMessage = ref<string | null>(null) const networkErrorMessage = ref<string | null>(null)
function getSubscribedTopics(): string[] {
return Array.from(handlers.entries())
.filter(([, eventHandlers]) => eventHandlers.length > 0)
.map(([event]) => event)
.sort()
}
function arraysEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index])
}
function syncSubscriptions() {
const topics = getSubscribedTopics()
if (arraysEqual(topics, subscribedTopics)) {
return
}
subscribedTopics = topics
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
subscribe(topics)
}
}
function connect() { function connect() {
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) { if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
syncSubscriptions()
return return
} }
@@ -37,8 +64,7 @@ function connect() {
networkErrorMessage.value = null networkErrorMessage.value = null
reconnectAttempts.value = 0 reconnectAttempts.value = 0
// Subscribe to all events by default syncSubscriptions()
subscribe(['*'])
} }
wsInstance.onmessage = (e) => { wsInstance.onmessage = (e) => {
@@ -78,6 +104,7 @@ function disconnect() {
wsInstance.close() wsInstance.close()
wsInstance = null wsInstance = null
} }
subscribedTopics = []
} }
function subscribe(topics: string[]) { function subscribe(topics: string[]) {
@@ -94,6 +121,7 @@ function on(event: string, handler: EventHandler) {
handlers.set(event, []) handlers.set(event, [])
} }
handlers.get(event)!.push(handler) handlers.get(event)!.push(handler)
syncSubscriptions()
} }
function off(event: string, handler: EventHandler) { function off(event: string, handler: EventHandler) {
@@ -103,8 +131,12 @@ function off(event: string, handler: EventHandler) {
if (index > -1) { if (index > -1) {
eventHandlers.splice(index, 1) eventHandlers.splice(index, 1)
} }
if (eventHandlers.length === 0) {
handlers.delete(event)
} }
} }
syncSubscriptions()
}
function handleEvent(payload: WsEvent) { function handleEvent(payload: WsEvent) {
const eventName = payload.event const eventName = payload.event

View File

@@ -32,6 +32,7 @@ export default {
menu: 'Menu', menu: 'Menu',
optional: 'Optional', optional: 'Optional',
recommended: 'Recommended', recommended: 'Recommended',
notSupportedYet: ' (Not Yet Supported)',
create: 'Create', create: 'Create',
creating: 'Creating...', creating: 'Creating...',
deleting: 'Deleting...', deleting: 'Deleting...',
@@ -61,7 +62,6 @@ export default {
password: 'Password', password: 'Password',
enterUsername: 'Enter username', enterUsername: 'Enter username',
enterPassword: 'Enter password', enterPassword: 'Enter password',
loginPrompt: 'Enter your credentials to login',
loginFailed: 'Login failed', loginFailed: 'Login failed',
invalidPassword: 'Invalid username or password', invalidPassword: 'Invalid username or password',
changePassword: 'Change Password', changePassword: 'Change Password',
@@ -169,6 +169,7 @@ export default {
caps: 'Caps', caps: 'Caps',
num: 'Num', num: 'Num',
scroll: 'Scroll', scroll: 'Scroll',
keyboardLedUnavailable: 'Keyboard LED status is disabled or unsupported',
}, },
paste: { paste: {
title: 'Paste Text', title: 'Paste Text',
@@ -270,7 +271,7 @@ export default {
otgAdvanced: 'Advanced: OTG Preset', otgAdvanced: 'Advanced: OTG Preset',
otgProfile: 'Initial HID Preset', otgProfile: 'Initial HID Preset',
otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.', otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.',
otgLowEndpointHint: 'Detected low-endpoint UDC; multimedia keys will be disabled automatically.', otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.',
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.', videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.', videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
// Extensions // Extensions
@@ -362,15 +363,22 @@ export default {
recovered: 'HID Recovered', recovered: 'HID Recovered',
recoveredDesc: '{backend} HID device reconnected successfully', recoveredDesc: '{backend} HID device reconnected successfully',
errorHints: { errorHints: {
udcNotConfigured: 'Target host has not finished USB enumeration yet', udcNotConfigured: 'OTG is ready, waiting for the target host to connect and finish USB enumeration',
disabled: 'HID backend is disabled',
hidDeviceMissing: 'HID gadget device node is missing, try restarting HID service', hidDeviceMissing: 'HID gadget device node is missing, try restarting HID service',
notOpened: 'HID device is not open, try restarting HID service',
portNotFound: 'Serial port not found, check CH9329 wiring and device path', portNotFound: 'Serial port not found, check CH9329 wiring and device path',
noResponse: 'No response from CH9329, check baud rate and power', noResponse: 'No response from CH9329, check baud rate and power',
noResponseWithCmd: 'No response from CH9329, check baud rate and power (cmd {cmd})',
invalidConfig: 'Serial port parameters are invalid, check device path and baud rate',
protocolError: 'CH9329 replied with invalid protocol data', protocolError: 'CH9329 replied with invalid protocol data',
healthCheckFailed: 'Background health check failed', deviceDisconnected: 'HID device disconnected, check cable and host port',
ioError: 'I/O communication error detected', ioError: 'I/O communication error detected',
otgIoError: 'OTG link is unstable, check USB cable and host port', otgIoError: 'OTG link is unstable, check USB cable and host port',
ch9329IoError: 'CH9329 serial link is unstable, check wiring and power', ch9329IoError: 'CH9329 serial link is unstable, check wiring and power',
serialError: 'Serial communication error, check CH9329 wiring and config',
initFailed: 'CH9329 initialization failed, check serial settings and power',
shutdown: 'HID backend has stopped',
}, },
}, },
audio: { audio: {
@@ -644,28 +652,28 @@ export default {
hidBackend: 'HID Backend', hidBackend: 'HID Backend',
serialDevice: 'Serial Device', serialDevice: 'Serial Device',
baudRate: 'Baud Rate', baudRate: 'Baud Rate',
otgHidProfile: 'OTG HID Profile', otgHidProfile: 'OTG HID Functions',
otgHidProfileDesc: 'Select which HID functions are exposed to the host', otgHidProfileDesc: 'Select which HID functions are exposed to the host',
profile: 'Profile', otgEndpointBudget: 'Max Endpoints',
otgProfileFull: 'Keyboard + relative mouse + absolute mouse + multimedia + MSD', otgEndpointBudgetUnlimited: 'Unlimited',
otgProfileFullNoMsd: 'Keyboard + relative mouse + absolute mouse + multimedia (no MSD)', otgEndpointBudgetHint: 'This is a hardware limit. If the OTG selection exceeds the real hardware endpoint count, OTG will fail.',
otgProfileFullNoConsumer: 'Keyboard + relative mouse + absolute mouse + MSD (no multimedia)', otgEndpointUsage: 'Endpoint usage: {used} / {limit}',
otgProfileFullNoConsumerNoMsd: 'Keyboard + relative mouse + absolute mouse (no multimedia, no MSD)', otgEndpointUsageUnlimited: 'Endpoint usage: {used} / unlimited',
otgProfileLegacyKeyboard: 'Keyboard only', otgEndpointExceeded: 'The current OTG selection needs {used} endpoints, exceeding the limit {limit}.',
otgProfileLegacyMouseRelative: 'Relative mouse only',
otgProfileCustom: 'Custom',
otgFunctionKeyboard: 'Keyboard', otgFunctionKeyboard: 'Keyboard',
otgFunctionKeyboardDesc: 'Standard HID keyboard device', otgFunctionKeyboardDesc: 'Standard HID keyboard device',
otgKeyboardLeds: 'Keyboard LED Status',
otgKeyboardLedsDesc: 'Enable Caps/Num/Scroll LED feedback from the host',
otgFunctionMouseRelative: 'Relative Mouse', otgFunctionMouseRelative: 'Relative Mouse',
otgFunctionMouseRelativeDesc: 'Traditional mouse movement (HID boot mouse)', otgFunctionMouseRelativeDesc: 'Traditional mouse movement (HID boot mouse)',
otgFunctionMouseAbsolute: 'Absolute Mouse', otgFunctionMouseAbsolute: 'Absolute Mouse',
otgFunctionMouseAbsoluteDesc: 'Absolute positioning (touchscreen-like)', otgFunctionMouseAbsoluteDesc: 'Absolute positioning (touchscreen-like)',
otgFunctionConsumer: 'Consumer Control', otgFunctionConsumer: 'Consumer Control Keyboard',
otgFunctionConsumerDesc: 'Media keys like volume/play/pause', otgFunctionConsumerDesc: 'Consumer Control keys such as volume/play/pause',
otgFunctionMsd: 'Mass Storage (MSD)', otgFunctionMsd: 'Mass Storage (MSD)',
otgFunctionMsdDesc: 'Expose USB storage to the host', otgFunctionMsdDesc: 'Expose USB storage to the host',
otgProfileWarning: 'Changing HID functions will reconnect the USB device', otgProfileWarning: 'Changing HID functions will reconnect the USB device',
otgLowEndpointHint: 'Low-endpoint UDC detected; multimedia keys will be disabled automatically.', otgLowEndpointHint: 'Low-endpoint UDC detected; Consumer Control Keyboard will be disabled automatically.',
otgFunctionMinWarning: 'Enable at least one HID function before saving', otgFunctionMinWarning: 'Enable at least one HID function before saving',
// OTG Descriptor // OTG Descriptor
otgDescriptor: 'USB Device Descriptor', otgDescriptor: 'USB Device Descriptor',
@@ -757,6 +765,15 @@ export default {
udc_speed: 'Device may not be fully enumerated; try reconnecting USB', udc_speed: 'Device may not be fully enumerated; try reconnecting USB',
}, },
}, },
encoderSelfCheck: {
title: 'Hardware Encoding Capability Test',
desc: 'Test hardware encoding capability across 720p, 1080p, 2K, and 4K',
run: 'Start Test',
failed: 'Failed to run hardware encoding capability test',
resolution: 'Resolution',
currentHardwareEncoder: 'Current Hardware Encoder',
none: 'None',
},
// WebRTC / ICE // WebRTC / ICE
webrtcSettings: 'WebRTC Settings', webrtcSettings: 'WebRTC Settings',
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal', webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
@@ -783,7 +800,7 @@ export default {
osWindows: 'Windows', osWindows: 'Windows',
osMac: 'Mac', osMac: 'Mac',
osAndroid: 'Android', osAndroid: 'Android',
mediaKeys: 'Media Keys', mediaKeys: 'Consumer Control Keyboard',
}, },
config: { config: {
applied: 'Configuration applied', applied: 'Configuration applied',

View File

@@ -32,6 +32,7 @@ export default {
menu: '菜单', menu: '菜单',
optional: '可选', optional: '可选',
recommended: '推荐', recommended: '推荐',
notSupportedYet: '(尚未支持)',
create: '创建', create: '创建',
creating: '创建中...', creating: '创建中...',
deleting: '删除中...', deleting: '删除中...',
@@ -61,7 +62,6 @@ export default {
password: '密码', password: '密码',
enterUsername: '请输入用户名', enterUsername: '请输入用户名',
enterPassword: '请输入密码', enterPassword: '请输入密码',
loginPrompt: '请输入您的账号和密码',
loginFailed: '登录失败', loginFailed: '登录失败',
invalidPassword: '用户名或密码错误', invalidPassword: '用户名或密码错误',
changePassword: '修改密码', changePassword: '修改密码',
@@ -169,6 +169,7 @@ export default {
caps: 'Caps', caps: 'Caps',
num: 'Num', num: 'Num',
scroll: 'Scroll', scroll: 'Scroll',
keyboardLedUnavailable: '键盘状态灯功能未开启或不支持',
}, },
paste: { paste: {
title: '粘贴文本', title: '粘贴文本',
@@ -270,7 +271,7 @@ export default {
otgAdvanced: '高级OTG 预设', otgAdvanced: '高级OTG 预设',
otgProfile: '初始 HID 预设', otgProfile: '初始 HID 预设',
otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。', otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。', otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。',
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。', videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。', videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。',
// Extensions // Extensions
@@ -362,15 +363,22 @@ export default {
recovered: 'HID 已恢复', recovered: 'HID 已恢复',
recoveredDesc: '{backend} HID 设备已成功重连', recoveredDesc: '{backend} HID 设备已成功重连',
errorHints: { errorHints: {
udcNotConfigured: '被控机尚未完成 USB 枚举', udcNotConfigured: 'OTG 已就绪,等待被控机连接并完成 USB 枚举',
disabled: 'HID 后端已禁用',
hidDeviceMissing: '未找到 HID 设备节点,可尝试重启 HID 服务', hidDeviceMissing: '未找到 HID 设备节点,可尝试重启 HID 服务',
notOpened: 'HID 设备尚未打开,可尝试重启 HID 服务',
portNotFound: '找不到串口设备,请检查 CH9329 接线与设备路径', portNotFound: '找不到串口设备,请检查 CH9329 接线与设备路径',
noResponse: 'CH9329 无响应,请检查波特率与供电', noResponse: 'CH9329 无响应,请检查波特率与供电',
noResponseWithCmd: 'CH9329 无响应,请检查波特率与供电(命令 {cmd}',
invalidConfig: '串口参数无效,请检查设备路径与波特率配置',
protocolError: 'CH9329 返回了无效协议数据', protocolError: 'CH9329 返回了无效协议数据',
healthCheckFailed: '后台健康检查失败', deviceDisconnected: 'HID 设备已断开,请检查线缆与接口',
ioError: '检测到 I/O 通信异常', ioError: '检测到 I/O 通信异常',
otgIoError: 'OTG 链路不稳定,请检查 USB 线和被控机接口', otgIoError: 'OTG 链路不稳定,请检查 USB 线和被控机接口',
ch9329IoError: 'CH9329 串口链路不稳定,请检查接线与供电', ch9329IoError: 'CH9329 串口链路不稳定,请检查接线与供电',
serialError: '串口通信异常,请检查 CH9329 接线与配置',
initFailed: 'CH9329 初始化失败,请检查串口参数与供电',
shutdown: 'HID 后端已停止',
}, },
}, },
audio: { audio: {
@@ -644,28 +652,28 @@ export default {
hidBackend: 'HID 后端', hidBackend: 'HID 后端',
serialDevice: '串口设备', serialDevice: '串口设备',
baudRate: '波特率', baudRate: '波特率',
otgHidProfile: 'OTG HID 组合', otgHidProfile: 'OTG HID 功能',
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能', otgHidProfileDesc: '选择对目标主机暴露的 HID 功能',
profile: '组合', otgEndpointBudget: '最大端点数量',
otgProfileFull: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体 + 虚拟媒体', otgEndpointBudgetUnlimited: '无限制',
otgProfileFullNoMsd: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体(不含虚拟媒体)', otgEndpointBudgetHint: '此为硬件限制。若超出硬件端点数量OTG 功能将无法使用。',
otgProfileFullNoConsumer: '键盘 + 相对鼠标 + 绝对鼠标 + 虚拟媒体(不含多媒体)', otgEndpointUsage: '当前端点占用:{used} / {limit}',
otgProfileFullNoConsumerNoMsd: '键盘 + 相对鼠标 + 绝对鼠标(不含多媒体与虚拟媒体)', otgEndpointUsageUnlimited: '当前端点占用:{used} / 不限',
otgProfileLegacyKeyboard: '仅键盘', otgEndpointExceeded: '当前 OTG 组合需要 {used} 个端点,已超出上限 {limit}。',
otgProfileLegacyMouseRelative: '仅相对鼠标',
otgProfileCustom: '自定义',
otgFunctionKeyboard: '键盘', otgFunctionKeyboard: '键盘',
otgFunctionKeyboardDesc: '标准 HID 键盘设备', otgFunctionKeyboardDesc: '标准 HID 键盘设备',
otgKeyboardLeds: '键盘状态灯',
otgKeyboardLedsDesc: '启用 Caps/Num/Scroll 状态灯回读',
otgFunctionMouseRelative: '相对鼠标', otgFunctionMouseRelative: '相对鼠标',
otgFunctionMouseRelativeDesc: '传统鼠标移动HID 启动鼠标)', otgFunctionMouseRelativeDesc: '传统鼠标移动HID 启动鼠标)',
otgFunctionMouseAbsolute: '绝对鼠标', otgFunctionMouseAbsolute: '绝对鼠标',
otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)', otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)',
otgFunctionConsumer: '多媒体控制', otgFunctionConsumer: '多媒体键盘',
otgFunctionConsumerDesc: '音量/播放/暂停等按键', otgFunctionConsumerDesc: '音量/播放/暂停等多媒体按键',
otgFunctionMsd: '虚拟媒体MSD', otgFunctionMsd: '虚拟媒体MSD',
otgFunctionMsdDesc: '向目标主机暴露 USB 存储', otgFunctionMsdDesc: '向目标主机暴露 USB 存储',
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接', otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。', otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。',
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存', otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
// OTG Descriptor // OTG Descriptor
otgDescriptor: 'USB 设备描述符', otgDescriptor: 'USB 设备描述符',
@@ -757,6 +765,15 @@ export default {
udc_speed: '设备可能未完成枚举,可尝试重插 USB', udc_speed: '设备可能未完成枚举,可尝试重插 USB',
}, },
}, },
encoderSelfCheck: {
title: '硬件编码能力测试',
desc: '按 720p、1080p、2K、4K 测试硬件编码能力',
run: '开始测试',
failed: '执行硬件编码能力测试失败',
resolution: '分辨率',
currentHardwareEncoder: '当前硬件编码器',
none: '无',
},
// WebRTC / ICE // WebRTC / ICE
webrtcSettings: 'WebRTC 设置', webrtcSettings: 'WebRTC 设置',
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透', webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
@@ -783,7 +800,7 @@ export default {
osWindows: 'Windows', osWindows: 'Windows',
osMac: 'Mac', osMac: 'Mac',
osAndroid: 'Android', osAndroid: 'Android',
mediaKeys: '多媒体键', mediaKeys: '多媒体键',
}, },
config: { config: {
applied: '配置已应用', applied: '配置已应用',

View File

@@ -1,129 +1,130 @@
// Character to HID usage mapping for text paste functionality. // Character to HID usage mapping for text paste functionality.
// The table follows US QWERTY layout semantics. // The table follows US QWERTY layout semantics.
import { type CanonicalKey } from '@/types/generated'
import { keys } from '@/lib/keyboardMappings' import { keys } from '@/lib/keyboardMappings'
export interface CharKeyMapping { export interface CharKeyMapping {
hidCode: number // USB HID usage code key: CanonicalKey
shift: boolean // Whether Shift modifier is needed shift: boolean // Whether Shift modifier is needed
} }
const charToKeyMap: Record<string, CharKeyMapping> = { const charToKeyMap: Record<string, CharKeyMapping> = {
// Lowercase letters // Lowercase letters
a: { hidCode: keys.KeyA, shift: false }, a: { key: keys.KeyA, shift: false },
b: { hidCode: keys.KeyB, shift: false }, b: { key: keys.KeyB, shift: false },
c: { hidCode: keys.KeyC, shift: false }, c: { key: keys.KeyC, shift: false },
d: { hidCode: keys.KeyD, shift: false }, d: { key: keys.KeyD, shift: false },
e: { hidCode: keys.KeyE, shift: false }, e: { key: keys.KeyE, shift: false },
f: { hidCode: keys.KeyF, shift: false }, f: { key: keys.KeyF, shift: false },
g: { hidCode: keys.KeyG, shift: false }, g: { key: keys.KeyG, shift: false },
h: { hidCode: keys.KeyH, shift: false }, h: { key: keys.KeyH, shift: false },
i: { hidCode: keys.KeyI, shift: false }, i: { key: keys.KeyI, shift: false },
j: { hidCode: keys.KeyJ, shift: false }, j: { key: keys.KeyJ, shift: false },
k: { hidCode: keys.KeyK, shift: false }, k: { key: keys.KeyK, shift: false },
l: { hidCode: keys.KeyL, shift: false }, l: { key: keys.KeyL, shift: false },
m: { hidCode: keys.KeyM, shift: false }, m: { key: keys.KeyM, shift: false },
n: { hidCode: keys.KeyN, shift: false }, n: { key: keys.KeyN, shift: false },
o: { hidCode: keys.KeyO, shift: false }, o: { key: keys.KeyO, shift: false },
p: { hidCode: keys.KeyP, shift: false }, p: { key: keys.KeyP, shift: false },
q: { hidCode: keys.KeyQ, shift: false }, q: { key: keys.KeyQ, shift: false },
r: { hidCode: keys.KeyR, shift: false }, r: { key: keys.KeyR, shift: false },
s: { hidCode: keys.KeyS, shift: false }, s: { key: keys.KeyS, shift: false },
t: { hidCode: keys.KeyT, shift: false }, t: { key: keys.KeyT, shift: false },
u: { hidCode: keys.KeyU, shift: false }, u: { key: keys.KeyU, shift: false },
v: { hidCode: keys.KeyV, shift: false }, v: { key: keys.KeyV, shift: false },
w: { hidCode: keys.KeyW, shift: false }, w: { key: keys.KeyW, shift: false },
x: { hidCode: keys.KeyX, shift: false }, x: { key: keys.KeyX, shift: false },
y: { hidCode: keys.KeyY, shift: false }, y: { key: keys.KeyY, shift: false },
z: { hidCode: keys.KeyZ, shift: false }, z: { key: keys.KeyZ, shift: false },
// Uppercase letters // Uppercase letters
A: { hidCode: keys.KeyA, shift: true }, A: { key: keys.KeyA, shift: true },
B: { hidCode: keys.KeyB, shift: true }, B: { key: keys.KeyB, shift: true },
C: { hidCode: keys.KeyC, shift: true }, C: { key: keys.KeyC, shift: true },
D: { hidCode: keys.KeyD, shift: true }, D: { key: keys.KeyD, shift: true },
E: { hidCode: keys.KeyE, shift: true }, E: { key: keys.KeyE, shift: true },
F: { hidCode: keys.KeyF, shift: true }, F: { key: keys.KeyF, shift: true },
G: { hidCode: keys.KeyG, shift: true }, G: { key: keys.KeyG, shift: true },
H: { hidCode: keys.KeyH, shift: true }, H: { key: keys.KeyH, shift: true },
I: { hidCode: keys.KeyI, shift: true }, I: { key: keys.KeyI, shift: true },
J: { hidCode: keys.KeyJ, shift: true }, J: { key: keys.KeyJ, shift: true },
K: { hidCode: keys.KeyK, shift: true }, K: { key: keys.KeyK, shift: true },
L: { hidCode: keys.KeyL, shift: true }, L: { key: keys.KeyL, shift: true },
M: { hidCode: keys.KeyM, shift: true }, M: { key: keys.KeyM, shift: true },
N: { hidCode: keys.KeyN, shift: true }, N: { key: keys.KeyN, shift: true },
O: { hidCode: keys.KeyO, shift: true }, O: { key: keys.KeyO, shift: true },
P: { hidCode: keys.KeyP, shift: true }, P: { key: keys.KeyP, shift: true },
Q: { hidCode: keys.KeyQ, shift: true }, Q: { key: keys.KeyQ, shift: true },
R: { hidCode: keys.KeyR, shift: true }, R: { key: keys.KeyR, shift: true },
S: { hidCode: keys.KeyS, shift: true }, S: { key: keys.KeyS, shift: true },
T: { hidCode: keys.KeyT, shift: true }, T: { key: keys.KeyT, shift: true },
U: { hidCode: keys.KeyU, shift: true }, U: { key: keys.KeyU, shift: true },
V: { hidCode: keys.KeyV, shift: true }, V: { key: keys.KeyV, shift: true },
W: { hidCode: keys.KeyW, shift: true }, W: { key: keys.KeyW, shift: true },
X: { hidCode: keys.KeyX, shift: true }, X: { key: keys.KeyX, shift: true },
Y: { hidCode: keys.KeyY, shift: true }, Y: { key: keys.KeyY, shift: true },
Z: { hidCode: keys.KeyZ, shift: true }, Z: { key: keys.KeyZ, shift: true },
// Number row // Number row
'0': { hidCode: keys.Digit0, shift: false }, '0': { key: keys.Digit0, shift: false },
'1': { hidCode: keys.Digit1, shift: false }, '1': { key: keys.Digit1, shift: false },
'2': { hidCode: keys.Digit2, shift: false }, '2': { key: keys.Digit2, shift: false },
'3': { hidCode: keys.Digit3, shift: false }, '3': { key: keys.Digit3, shift: false },
'4': { hidCode: keys.Digit4, shift: false }, '4': { key: keys.Digit4, shift: false },
'5': { hidCode: keys.Digit5, shift: false }, '5': { key: keys.Digit5, shift: false },
'6': { hidCode: keys.Digit6, shift: false }, '6': { key: keys.Digit6, shift: false },
'7': { hidCode: keys.Digit7, shift: false }, '7': { key: keys.Digit7, shift: false },
'8': { hidCode: keys.Digit8, shift: false }, '8': { key: keys.Digit8, shift: false },
'9': { hidCode: keys.Digit9, shift: false }, '9': { key: keys.Digit9, shift: false },
// Shifted number row symbols // Shifted number row symbols
')': { hidCode: keys.Digit0, shift: true }, ')': { key: keys.Digit0, shift: true },
'!': { hidCode: keys.Digit1, shift: true }, '!': { key: keys.Digit1, shift: true },
'@': { hidCode: keys.Digit2, shift: true }, '@': { key: keys.Digit2, shift: true },
'#': { hidCode: keys.Digit3, shift: true }, '#': { key: keys.Digit3, shift: true },
'$': { hidCode: keys.Digit4, shift: true }, '$': { key: keys.Digit4, shift: true },
'%': { hidCode: keys.Digit5, shift: true }, '%': { key: keys.Digit5, shift: true },
'^': { hidCode: keys.Digit6, shift: true }, '^': { key: keys.Digit6, shift: true },
'&': { hidCode: keys.Digit7, shift: true }, '&': { key: keys.Digit7, shift: true },
'*': { hidCode: keys.Digit8, shift: true }, '*': { key: keys.Digit8, shift: true },
'(': { hidCode: keys.Digit9, shift: true }, '(': { key: keys.Digit9, shift: true },
// Punctuation and symbols // Punctuation and symbols
'-': { hidCode: keys.Minus, shift: false }, '-': { key: keys.Minus, shift: false },
'=': { hidCode: keys.Equal, shift: false }, '=': { key: keys.Equal, shift: false },
'[': { hidCode: keys.BracketLeft, shift: false }, '[': { key: keys.BracketLeft, shift: false },
']': { hidCode: keys.BracketRight, shift: false }, ']': { key: keys.BracketRight, shift: false },
'\\': { hidCode: keys.Backslash, shift: false }, '\\': { key: keys.Backslash, shift: false },
';': { hidCode: keys.Semicolon, shift: false }, ';': { key: keys.Semicolon, shift: false },
"'": { hidCode: keys.Quote, shift: false }, "'": { key: keys.Quote, shift: false },
'`': { hidCode: keys.Backquote, shift: false }, '`': { key: keys.Backquote, shift: false },
',': { hidCode: keys.Comma, shift: false }, ',': { key: keys.Comma, shift: false },
'.': { hidCode: keys.Period, shift: false }, '.': { key: keys.Period, shift: false },
'/': { hidCode: keys.Slash, shift: false }, '/': { key: keys.Slash, shift: false },
// Shifted punctuation and symbols // Shifted punctuation and symbols
_: { hidCode: keys.Minus, shift: true }, _: { key: keys.Minus, shift: true },
'+': { hidCode: keys.Equal, shift: true }, '+': { key: keys.Equal, shift: true },
'{': { hidCode: keys.BracketLeft, shift: true }, '{': { key: keys.BracketLeft, shift: true },
'}': { hidCode: keys.BracketRight, shift: true }, '}': { key: keys.BracketRight, shift: true },
'|': { hidCode: keys.Backslash, shift: true }, '|': { key: keys.Backslash, shift: true },
':': { hidCode: keys.Semicolon, shift: true }, ':': { key: keys.Semicolon, shift: true },
'"': { hidCode: keys.Quote, shift: true }, '"': { key: keys.Quote, shift: true },
'~': { hidCode: keys.Backquote, shift: true }, '~': { key: keys.Backquote, shift: true },
'<': { hidCode: keys.Comma, shift: true }, '<': { key: keys.Comma, shift: true },
'>': { hidCode: keys.Period, shift: true }, '>': { key: keys.Period, shift: true },
'?': { hidCode: keys.Slash, shift: true }, '?': { key: keys.Slash, shift: true },
// Whitespace and control // Whitespace and control
' ': { hidCode: keys.Space, shift: false }, ' ': { key: keys.Space, shift: false },
'\t': { hidCode: keys.Tab, shift: false }, '\t': { key: keys.Tab, shift: false },
'\n': { hidCode: keys.Enter, shift: false }, '\n': { key: keys.Enter, shift: false },
'\r': { hidCode: keys.Enter, shift: false }, '\r': { key: keys.Enter, shift: false },
} }
/** /**
* Get HID usage code and modifier state for a character * Get canonical key and modifier state for a character
* @param char - Single character to convert * @param char - Single character to convert
* @returns CharKeyMapping or null if character is not mappable * @returns CharKeyMapping or null if character is not mappable
*/ */

View File

@@ -1,77 +1,15 @@
// Keyboard layout definitions for virtual keyboard // Virtual keyboard layout data shared by the on-screen keyboard.
export interface KeyboardLayout {
id: string
name: string
// Key display labels
keyLabels: Record<string, string>
// Shift variant labels (key in parentheses)
shiftLabels: Record<string, string>
// Virtual keyboard layout rows
layout: {
main: {
macros: string[]
functionRow: string[]
default: string[][]
shift: string[][]
}
control: string[][]
arrows: string[][]
media: string[] // Media keys row
}
}
// OS-specific keyboard layout type
export type KeyboardOsType = 'windows' | 'mac' | 'android' export type KeyboardOsType = 'windows' | 'mac' | 'android'
// Bottom row layouts for different OS
export const osBottomRows: Record<KeyboardOsType, string[]> = { export const osBottomRows: Record<KeyboardOsType, string[]> = {
// Windows: Ctrl - Win - Alt - Space - Alt - Win - Menu - Ctrl windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'],
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
// Mac: Ctrl - Option - Cmd - Space - Cmd - Option - Ctrl
mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'], mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'],
// Android: simplified layout
android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'], android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'],
} }
// OS-specific modifier display names
export const osModifierLabels: Record<KeyboardOsType, Record<string, string>> = {
windows: {
ControlLeft: '^Ctrl',
ControlRight: 'Ctrl^',
MetaLeft: '⊞Win',
MetaRight: 'Win⊞',
AltLeft: 'Alt',
AltRight: 'Alt',
AltGr: 'AltGr',
Menu: 'Menu',
},
mac: {
ControlLeft: '^Ctrl',
ControlRight: 'Ctrl^',
MetaLeft: '⌘Cmd',
MetaRight: 'Cmd⌘',
AltLeft: '⌥Opt',
AltRight: 'Opt⌥',
AltGr: '⌥Opt',
Menu: 'Menu',
},
android: {
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
MetaLeft: 'Meta',
MetaRight: 'Meta',
AltLeft: 'Alt',
AltRight: 'Alt',
AltGr: 'Alt',
Menu: 'Menu',
},
}
// Media keys (Consumer Control)
export const mediaKeys = ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp'] export const mediaKeys = ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp']
// Media key display names
export const mediaKeyLabels: Record<string, string> = { export const mediaKeyLabels: Record<string, string> = {
PlayPause: '⏯', PlayPause: '⏯',
Stop: '⏹', Stop: '⏹',
@@ -81,158 +19,3 @@ export const mediaKeyLabels: Record<string, string> = {
VolumeUp: '🔊', VolumeUp: '🔊',
VolumeDown: '🔉', VolumeDown: '🔉',
} }
// English US Layout
export const enUSLayout: KeyboardLayout = {
id: 'en-US',
name: 'English (US)',
keyLabels: {
// Macros
CtrlAltDelete: 'Ctrl+Alt+Del',
AltMetaEscape: 'Alt+Meta+Esc',
CtrlAltBackspace: 'Ctrl+Alt+Back',
// Modifiers
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
AltLeft: 'Alt',
AltRight: 'Alt',
AltGr: 'AltGr',
MetaLeft: 'Meta',
MetaRight: 'Meta',
// Special keys
Escape: 'Esc',
Backspace: 'Back',
Tab: 'Tab',
CapsLock: 'Caps',
Enter: 'Enter',
Space: ' ',
Menu: 'Menu',
// Navigation
Insert: 'Ins',
Delete: 'Del',
Home: 'Home',
End: 'End',
PageUp: 'PgUp',
PageDown: 'PgDn',
// Arrows
ArrowUp: '\u2191',
ArrowDown: '\u2193',
ArrowLeft: '\u2190',
ArrowRight: '\u2192',
// Control cluster
PrintScreen: 'PrtSc',
ScrollLock: 'ScrLk',
Pause: 'Pause',
// Function keys
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4',
F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8',
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
// Letters
KeyA: 'a', KeyB: 'b', KeyC: 'c', KeyD: 'd', KeyE: 'e',
KeyF: 'f', KeyG: 'g', KeyH: 'h', KeyI: 'i', KeyJ: 'j',
KeyK: 'k', KeyL: 'l', KeyM: 'm', KeyN: 'n', KeyO: 'o',
KeyP: 'p', KeyQ: 'q', KeyR: 'r', KeyS: 's', KeyT: 't',
KeyU: 'u', KeyV: 'v', KeyW: 'w', KeyX: 'x', KeyY: 'y',
KeyZ: 'z',
// Numbers
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0',
// Symbols
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Backquote: '`',
Comma: ',',
Period: '.',
Slash: '/',
},
shiftLabels: {
// Capital letters
KeyA: 'A', KeyB: 'B', KeyC: 'C', KeyD: 'D', KeyE: 'E',
KeyF: 'F', KeyG: 'G', KeyH: 'H', KeyI: 'I', KeyJ: 'J',
KeyK: 'K', KeyL: 'L', KeyM: 'M', KeyN: 'N', KeyO: 'O',
KeyP: 'P', KeyQ: 'Q', KeyR: 'R', KeyS: 'S', KeyT: 'T',
KeyU: 'U', KeyV: 'V', KeyW: 'W', KeyX: 'X', KeyY: 'Y',
KeyZ: 'Z',
// Shifted numbers
Digit1: '!', Digit2: '@', Digit3: '#', Digit4: '$', Digit5: '%',
Digit6: '^', Digit7: '&', Digit8: '*', Digit9: '(', Digit0: ')',
// Shifted symbols
Minus: '_',
Equal: '+',
BracketLeft: '{',
BracketRight: '}',
Backslash: '|',
Semicolon: ':',
Quote: '"',
Backquote: '~',
Comma: '<',
Period: '>',
Slash: '?',
},
layout: {
main: {
macros: ['CtrlAltDelete', 'AltMetaEscape', 'CtrlAltBackspace'],
functionRow: ['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'],
default: [
['Backquote', 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0', 'Minus', 'Equal', 'Backspace'],
['Tab', 'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP', 'BracketLeft', 'BracketRight', 'Backslash'],
['CapsLock', 'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL', 'Semicolon', 'Quote', 'Enter'],
['ShiftLeft', 'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM', 'Comma', 'Period', 'Slash', 'ShiftRight'],
['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
],
shift: [
['Backquote', 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0', 'Minus', 'Equal', 'Backspace'],
['Tab', 'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP', 'BracketLeft', 'BracketRight', 'Backslash'],
['CapsLock', 'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL', 'Semicolon', 'Quote', 'Enter'],
['ShiftLeft', 'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM', 'Comma', 'Period', 'Slash', 'ShiftRight'],
['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
],
},
control: [
['PrintScreen', 'ScrollLock', 'Pause'],
['Insert', 'Home', 'PageUp'],
['Delete', 'End', 'PageDown'],
],
arrows: [
['ArrowUp'],
['ArrowLeft', 'ArrowDown', 'ArrowRight'],
],
media: ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp'],
},
}
// All available layouts
export const keyboardLayouts: Record<string, KeyboardLayout> = {
'en-US': enUSLayout,
}
// Get layout by ID or return default
export function getKeyboardLayout(id: string): KeyboardLayout {
return keyboardLayouts[id] || enUSLayout
}
// Get key label for display
export function getKeyLabel(layout: KeyboardLayout, keyName: string, isShift: boolean): string {
if (isShift && layout.shiftLabels[keyName]) {
return layout.shiftLabels[keyName]
}
return layout.keyLabels[keyName] || keyName
}

View File

@@ -1,157 +1,128 @@
// Key codes and modifiers correspond to definitions in the import { CanonicalKey } from '@/types/generated'
// [Linux USB HID gadget driver](https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt)
// [Universal Serial Bus HID Usage Tables: Section 10](https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf)
export const keys = { export const keys = {
// Letters KeyA: CanonicalKey.KeyA,
KeyA: 0x04, KeyB: CanonicalKey.KeyB,
KeyB: 0x05, KeyC: CanonicalKey.KeyC,
KeyC: 0x06, KeyD: CanonicalKey.KeyD,
KeyD: 0x07, KeyE: CanonicalKey.KeyE,
KeyE: 0x08, KeyF: CanonicalKey.KeyF,
KeyF: 0x09, KeyG: CanonicalKey.KeyG,
KeyG: 0x0a, KeyH: CanonicalKey.KeyH,
KeyH: 0x0b, KeyI: CanonicalKey.KeyI,
KeyI: 0x0c, KeyJ: CanonicalKey.KeyJ,
KeyJ: 0x0d, KeyK: CanonicalKey.KeyK,
KeyK: 0x0e, KeyL: CanonicalKey.KeyL,
KeyL: 0x0f, KeyM: CanonicalKey.KeyM,
KeyM: 0x10, KeyN: CanonicalKey.KeyN,
KeyN: 0x11, KeyO: CanonicalKey.KeyO,
KeyO: 0x12, KeyP: CanonicalKey.KeyP,
KeyP: 0x13, KeyQ: CanonicalKey.KeyQ,
KeyQ: 0x14, KeyR: CanonicalKey.KeyR,
KeyR: 0x15, KeyS: CanonicalKey.KeyS,
KeyS: 0x16, KeyT: CanonicalKey.KeyT,
KeyT: 0x17, KeyU: CanonicalKey.KeyU,
KeyU: 0x18, KeyV: CanonicalKey.KeyV,
KeyV: 0x19, KeyW: CanonicalKey.KeyW,
KeyW: 0x1a, KeyX: CanonicalKey.KeyX,
KeyX: 0x1b, KeyY: CanonicalKey.KeyY,
KeyY: 0x1c, KeyZ: CanonicalKey.KeyZ,
KeyZ: 0x1d, Digit1: CanonicalKey.Digit1,
Digit2: CanonicalKey.Digit2,
// Numbers Digit3: CanonicalKey.Digit3,
Digit1: 0x1e, Digit4: CanonicalKey.Digit4,
Digit2: 0x1f, Digit5: CanonicalKey.Digit5,
Digit3: 0x20, Digit6: CanonicalKey.Digit6,
Digit4: 0x21, Digit7: CanonicalKey.Digit7,
Digit5: 0x22, Digit8: CanonicalKey.Digit8,
Digit6: 0x23, Digit9: CanonicalKey.Digit9,
Digit7: 0x24, Digit0: CanonicalKey.Digit0,
Digit8: 0x25, Enter: CanonicalKey.Enter,
Digit9: 0x26, Escape: CanonicalKey.Escape,
Digit0: 0x27, Backspace: CanonicalKey.Backspace,
Tab: CanonicalKey.Tab,
// Control keys Space: CanonicalKey.Space,
Enter: 0x28, Minus: CanonicalKey.Minus,
Escape: 0x29, Equal: CanonicalKey.Equal,
Backspace: 0x2a, BracketLeft: CanonicalKey.BracketLeft,
Tab: 0x2b, BracketRight: CanonicalKey.BracketRight,
Space: 0x2c, Backslash: CanonicalKey.Backslash,
Semicolon: CanonicalKey.Semicolon,
// Symbols Quote: CanonicalKey.Quote,
Minus: 0x2d, Backquote: CanonicalKey.Backquote,
Equal: 0x2e, Comma: CanonicalKey.Comma,
BracketLeft: 0x2f, Period: CanonicalKey.Period,
BracketRight: 0x30, Slash: CanonicalKey.Slash,
Backslash: 0x31, CapsLock: CanonicalKey.CapsLock,
Semicolon: 0x33, F1: CanonicalKey.F1,
Quote: 0x34, F2: CanonicalKey.F2,
Backquote: 0x35, F3: CanonicalKey.F3,
Comma: 0x36, F4: CanonicalKey.F4,
Period: 0x37, F5: CanonicalKey.F5,
Slash: 0x38, F6: CanonicalKey.F6,
F7: CanonicalKey.F7,
// Lock keys F8: CanonicalKey.F8,
CapsLock: 0x39, F9: CanonicalKey.F9,
F10: CanonicalKey.F10,
// Function keys F11: CanonicalKey.F11,
F1: 0x3a, F12: CanonicalKey.F12,
F2: 0x3b, PrintScreen: CanonicalKey.PrintScreen,
F3: 0x3c, ScrollLock: CanonicalKey.ScrollLock,
F4: 0x3d, Pause: CanonicalKey.Pause,
F5: 0x3e, Insert: CanonicalKey.Insert,
F6: 0x3f, Home: CanonicalKey.Home,
F7: 0x40, PageUp: CanonicalKey.PageUp,
F8: 0x41, Delete: CanonicalKey.Delete,
F9: 0x42, End: CanonicalKey.End,
F10: 0x43, PageDown: CanonicalKey.PageDown,
F11: 0x44, ArrowRight: CanonicalKey.ArrowRight,
F12: 0x45, ArrowLeft: CanonicalKey.ArrowLeft,
ArrowDown: CanonicalKey.ArrowDown,
// Control cluster ArrowUp: CanonicalKey.ArrowUp,
PrintScreen: 0x46, NumLock: CanonicalKey.NumLock,
ScrollLock: 0x47, NumpadDivide: CanonicalKey.NumpadDivide,
Pause: 0x48, NumpadMultiply: CanonicalKey.NumpadMultiply,
Insert: 0x49, NumpadSubtract: CanonicalKey.NumpadSubtract,
Home: 0x4a, NumpadAdd: CanonicalKey.NumpadAdd,
PageUp: 0x4b, NumpadEnter: CanonicalKey.NumpadEnter,
Delete: 0x4c, Numpad1: CanonicalKey.Numpad1,
End: 0x4d, Numpad2: CanonicalKey.Numpad2,
PageDown: 0x4e, Numpad3: CanonicalKey.Numpad3,
Numpad4: CanonicalKey.Numpad4,
// Arrow keys Numpad5: CanonicalKey.Numpad5,
ArrowRight: 0x4f, Numpad6: CanonicalKey.Numpad6,
ArrowLeft: 0x50, Numpad7: CanonicalKey.Numpad7,
ArrowDown: 0x51, Numpad8: CanonicalKey.Numpad8,
ArrowUp: 0x52, Numpad9: CanonicalKey.Numpad9,
Numpad0: CanonicalKey.Numpad0,
// Numpad NumpadDecimal: CanonicalKey.NumpadDecimal,
NumLock: 0x53, IntlBackslash: CanonicalKey.IntlBackslash,
NumpadDivide: 0x54, ContextMenu: CanonicalKey.ContextMenu,
NumpadMultiply: 0x55, F13: CanonicalKey.F13,
NumpadSubtract: 0x56, F14: CanonicalKey.F14,
NumpadAdd: 0x57, F15: CanonicalKey.F15,
NumpadEnter: 0x58, F16: CanonicalKey.F16,
Numpad1: 0x59, F17: CanonicalKey.F17,
Numpad2: 0x5a, F18: CanonicalKey.F18,
Numpad3: 0x5b, F19: CanonicalKey.F19,
Numpad4: 0x5c, F20: CanonicalKey.F20,
Numpad5: 0x5d, F21: CanonicalKey.F21,
Numpad6: 0x5e, F22: CanonicalKey.F22,
Numpad7: 0x5f, F23: CanonicalKey.F23,
Numpad8: 0x60, F24: CanonicalKey.F24,
Numpad9: 0x61, ControlLeft: CanonicalKey.ControlLeft,
Numpad0: 0x62, ShiftLeft: CanonicalKey.ShiftLeft,
NumpadDecimal: 0x63, AltLeft: CanonicalKey.AltLeft,
MetaLeft: CanonicalKey.MetaLeft,
// Non-US keys ControlRight: CanonicalKey.ControlRight,
IntlBackslash: 0x64, ShiftRight: CanonicalKey.ShiftRight,
ContextMenu: 0x65, AltRight: CanonicalKey.AltRight,
Menu: 0x65, MetaRight: CanonicalKey.MetaRight,
Application: 0x65,
// Extended function keys
F13: 0x68,
F14: 0x69,
F15: 0x6a,
F16: 0x6b,
F17: 0x6c,
F18: 0x6d,
F19: 0x6e,
F20: 0x6f,
F21: 0x70,
F22: 0x71,
F23: 0x72,
F24: 0x73,
// Modifiers (these are special - HID codes 0xE0-0xE7)
ControlLeft: 0xe0,
ShiftLeft: 0xe1,
AltLeft: 0xe2,
MetaLeft: 0xe3,
ControlRight: 0xe4,
ShiftRight: 0xe5,
AltRight: 0xe6,
AltGr: 0xe6,
MetaRight: 0xe7,
} as const } as const
export type KeyName = keyof typeof keys export type KeyName = keyof typeof keys
// Consumer Control Usage codes (for multimedia keys) // Consumer Control Usage codes (for multimedia keys)
// These are sent via a separate Consumer Control HID report
export const consumerKeys = { export const consumerKeys = {
PlayPause: 0x00cd, PlayPause: 0x00cd,
Stop: 0x00b7, Stop: 0x00b7,
@@ -164,69 +135,153 @@ export const consumerKeys = {
export type ConsumerKeyName = keyof typeof consumerKeys export type ConsumerKeyName = keyof typeof consumerKeys
// Modifier bitmasks for HID report byte 0 export const modifiers: Partial<Record<CanonicalKey, number>> = {
export const modifiers = { [CanonicalKey.ControlLeft]: 0x01,
ControlLeft: 0x01, [CanonicalKey.ShiftLeft]: 0x02,
ShiftLeft: 0x02, [CanonicalKey.AltLeft]: 0x04,
AltLeft: 0x04, [CanonicalKey.MetaLeft]: 0x08,
MetaLeft: 0x08, [CanonicalKey.ControlRight]: 0x10,
ControlRight: 0x10, [CanonicalKey.ShiftRight]: 0x20,
ShiftRight: 0x20, [CanonicalKey.AltRight]: 0x40,
AltRight: 0x40, [CanonicalKey.MetaRight]: 0x80,
AltGr: 0x40,
MetaRight: 0x80,
} as const
export type ModifierName = keyof typeof modifiers
// Map HID key codes to modifier bitmasks
export const hidKeyToModifierMask: Record<number, number> = {
0xe0: 0x01, // ControlLeft
0xe1: 0x02, // ShiftLeft
0xe2: 0x04, // AltLeft
0xe3: 0x08, // MetaLeft
0xe4: 0x10, // ControlRight
0xe5: 0x20, // ShiftRight
0xe6: 0x40, // AltRight
0xe7: 0x80, // MetaRight
} }
// Update modifier mask when a HID modifier key is pressed/released. export const keyToHidUsage = {
export function updateModifierMaskForHidKey(mask: number, hidKey: number, press: boolean): number { [CanonicalKey.KeyA]: 0x04,
const bit = hidKeyToModifierMask[hidKey] ?? 0 [CanonicalKey.KeyB]: 0x05,
[CanonicalKey.KeyC]: 0x06,
[CanonicalKey.KeyD]: 0x07,
[CanonicalKey.KeyE]: 0x08,
[CanonicalKey.KeyF]: 0x09,
[CanonicalKey.KeyG]: 0x0a,
[CanonicalKey.KeyH]: 0x0b,
[CanonicalKey.KeyI]: 0x0c,
[CanonicalKey.KeyJ]: 0x0d,
[CanonicalKey.KeyK]: 0x0e,
[CanonicalKey.KeyL]: 0x0f,
[CanonicalKey.KeyM]: 0x10,
[CanonicalKey.KeyN]: 0x11,
[CanonicalKey.KeyO]: 0x12,
[CanonicalKey.KeyP]: 0x13,
[CanonicalKey.KeyQ]: 0x14,
[CanonicalKey.KeyR]: 0x15,
[CanonicalKey.KeyS]: 0x16,
[CanonicalKey.KeyT]: 0x17,
[CanonicalKey.KeyU]: 0x18,
[CanonicalKey.KeyV]: 0x19,
[CanonicalKey.KeyW]: 0x1a,
[CanonicalKey.KeyX]: 0x1b,
[CanonicalKey.KeyY]: 0x1c,
[CanonicalKey.KeyZ]: 0x1d,
[CanonicalKey.Digit1]: 0x1e,
[CanonicalKey.Digit2]: 0x1f,
[CanonicalKey.Digit3]: 0x20,
[CanonicalKey.Digit4]: 0x21,
[CanonicalKey.Digit5]: 0x22,
[CanonicalKey.Digit6]: 0x23,
[CanonicalKey.Digit7]: 0x24,
[CanonicalKey.Digit8]: 0x25,
[CanonicalKey.Digit9]: 0x26,
[CanonicalKey.Digit0]: 0x27,
[CanonicalKey.Enter]: 0x28,
[CanonicalKey.Escape]: 0x29,
[CanonicalKey.Backspace]: 0x2a,
[CanonicalKey.Tab]: 0x2b,
[CanonicalKey.Space]: 0x2c,
[CanonicalKey.Minus]: 0x2d,
[CanonicalKey.Equal]: 0x2e,
[CanonicalKey.BracketLeft]: 0x2f,
[CanonicalKey.BracketRight]: 0x30,
[CanonicalKey.Backslash]: 0x31,
[CanonicalKey.Semicolon]: 0x33,
[CanonicalKey.Quote]: 0x34,
[CanonicalKey.Backquote]: 0x35,
[CanonicalKey.Comma]: 0x36,
[CanonicalKey.Period]: 0x37,
[CanonicalKey.Slash]: 0x38,
[CanonicalKey.CapsLock]: 0x39,
[CanonicalKey.F1]: 0x3a,
[CanonicalKey.F2]: 0x3b,
[CanonicalKey.F3]: 0x3c,
[CanonicalKey.F4]: 0x3d,
[CanonicalKey.F5]: 0x3e,
[CanonicalKey.F6]: 0x3f,
[CanonicalKey.F7]: 0x40,
[CanonicalKey.F8]: 0x41,
[CanonicalKey.F9]: 0x42,
[CanonicalKey.F10]: 0x43,
[CanonicalKey.F11]: 0x44,
[CanonicalKey.F12]: 0x45,
[CanonicalKey.PrintScreen]: 0x46,
[CanonicalKey.ScrollLock]: 0x47,
[CanonicalKey.Pause]: 0x48,
[CanonicalKey.Insert]: 0x49,
[CanonicalKey.Home]: 0x4a,
[CanonicalKey.PageUp]: 0x4b,
[CanonicalKey.Delete]: 0x4c,
[CanonicalKey.End]: 0x4d,
[CanonicalKey.PageDown]: 0x4e,
[CanonicalKey.ArrowRight]: 0x4f,
[CanonicalKey.ArrowLeft]: 0x50,
[CanonicalKey.ArrowDown]: 0x51,
[CanonicalKey.ArrowUp]: 0x52,
[CanonicalKey.NumLock]: 0x53,
[CanonicalKey.NumpadDivide]: 0x54,
[CanonicalKey.NumpadMultiply]: 0x55,
[CanonicalKey.NumpadSubtract]: 0x56,
[CanonicalKey.NumpadAdd]: 0x57,
[CanonicalKey.NumpadEnter]: 0x58,
[CanonicalKey.Numpad1]: 0x59,
[CanonicalKey.Numpad2]: 0x5a,
[CanonicalKey.Numpad3]: 0x5b,
[CanonicalKey.Numpad4]: 0x5c,
[CanonicalKey.Numpad5]: 0x5d,
[CanonicalKey.Numpad6]: 0x5e,
[CanonicalKey.Numpad7]: 0x5f,
[CanonicalKey.Numpad8]: 0x60,
[CanonicalKey.Numpad9]: 0x61,
[CanonicalKey.Numpad0]: 0x62,
[CanonicalKey.NumpadDecimal]: 0x63,
[CanonicalKey.IntlBackslash]: 0x64,
[CanonicalKey.ContextMenu]: 0x65,
[CanonicalKey.F13]: 0x68,
[CanonicalKey.F14]: 0x69,
[CanonicalKey.F15]: 0x6a,
[CanonicalKey.F16]: 0x6b,
[CanonicalKey.F17]: 0x6c,
[CanonicalKey.F18]: 0x6d,
[CanonicalKey.F19]: 0x6e,
[CanonicalKey.F20]: 0x6f,
[CanonicalKey.F21]: 0x70,
[CanonicalKey.F22]: 0x71,
[CanonicalKey.F23]: 0x72,
[CanonicalKey.F24]: 0x73,
[CanonicalKey.ControlLeft]: 0xe0,
[CanonicalKey.ShiftLeft]: 0xe1,
[CanonicalKey.AltLeft]: 0xe2,
[CanonicalKey.MetaLeft]: 0xe3,
[CanonicalKey.ControlRight]: 0xe4,
[CanonicalKey.ShiftRight]: 0xe5,
[CanonicalKey.AltRight]: 0xe6,
[CanonicalKey.MetaRight]: 0xe7,
} as const satisfies Record<CanonicalKey, number>
export function canonicalKeyToHidUsage(key: CanonicalKey): number {
return keyToHidUsage[key]
}
export function updateModifierMaskForKey(mask: number, key: CanonicalKey, press: boolean): number {
const bit = modifiers[key] ?? 0
if (bit === 0) return mask if (bit === 0) return mask
return press ? (mask | bit) : (mask & ~bit) return press ? (mask | bit) : (mask & ~bit)
} }
// Keys that latch (toggle state) instead of being held export const latchingKeys = [
export const latchingKeys = ['CapsLock', 'ScrollLock', 'NumLock'] as const CanonicalKey.CapsLock,
CanonicalKey.ScrollLock,
// Modifier key names CanonicalKey.NumLock,
export const modifierKeyNames = [
'ControlLeft',
'ControlRight',
'ShiftLeft',
'ShiftRight',
'AltLeft',
'AltRight',
'AltGr',
'MetaLeft',
'MetaRight',
] as const ] as const
// Check if a key is a modifier
export function isModifierKey(keyName: string): keyName is ModifierName {
return keyName in modifiers
}
// Get modifier bitmask for a key name
export function getModifierMask(keyName: string): number {
if (keyName in modifiers) {
return modifiers[keyName as ModifierName]
}
return 0
}
// Normalize browser-specific KeyboardEvent.code variants. // Normalize browser-specific KeyboardEvent.code variants.
export function normalizeKeyboardCode(code: string, key: string): string { export function normalizeKeyboardCode(code: string, key: string): string {
if (code === 'IntlBackslash' && (key === '`' || key === '~')) return 'Backquote' if (code === 'IntlBackslash' && (key === '`' || key === '~')) return 'Backquote'
@@ -238,18 +293,10 @@ export function normalizeKeyboardCode(code: string, key: string): string {
return code return code
} }
// Convert KeyboardEvent.code/key to USB HID usage code. export function keyboardEventToCanonicalKey(code: string, key: string): CanonicalKey | undefined {
export function keyboardEventToHidCode(code: string, key: string): number | undefined {
const normalizedCode = normalizeKeyboardCode(code, key) const normalizedCode = normalizeKeyboardCode(code, key)
if (normalizedCode in keys) {
return keys[normalizedCode as KeyName] return keys[normalizedCode as KeyName]
} }
return undefined
// Decode modifier byte into individual states
export function decodeModifiers(modifier: number) {
return {
isShiftActive: (modifier & 0x22) !== 0, // ShiftLeft | ShiftRight
isControlActive: (modifier & 0x11) !== 0, // ControlLeft | ControlRight
isAltActive: (modifier & 0x44) !== 0, // AltLeft | AltRight
isMetaActive: (modifier & 0x88) !== 0, // MetaLeft | MetaRight
}
} }

View File

@@ -0,0 +1,97 @@
export type VideoFormatSupportContext = 'config' | 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
export type VideoFormatState = 'supported' | 'not_recommended' | 'unsupported'
const MJPEG_MODE_SUPPORTED_FORMATS = new Set([
'MJPEG',
'JPEG',
'YUYV',
'YVYU',
'NV12',
'RGB24',
'BGR24',
])
const CONFIG_SUPPORTED_FORMATS = new Set([
'MJPEG',
'JPEG',
'YUYV',
'YVYU',
'NV12',
'NV21',
'NV16',
'YUV420',
'RGB24',
'BGR24',
])
const WEBRTC_SUPPORTED_FORMATS = new Set([
'MJPEG',
'JPEG',
'YUYV',
'NV12',
'NV21',
'NV16',
'YUV420',
'RGB24',
'BGR24',
])
function normalizeFormat(formatName: string): string {
return formatName.trim().toUpperCase()
}
function isCompressedFormat(formatName: string): boolean {
return formatName === 'MJPEG' || formatName === 'JPEG'
}
function isRkmppBackend(backendId?: string): boolean {
return backendId?.toLowerCase() === 'rkmpp'
}
export function getVideoFormatState(
formatName: string,
context: VideoFormatSupportContext,
encoderBackend = 'auto',
): VideoFormatState {
const normalizedFormat = normalizeFormat(formatName)
if (context === 'mjpeg') {
return MJPEG_MODE_SUPPORTED_FORMATS.has(normalizedFormat) ? 'supported' : 'unsupported'
}
if (context === 'config') {
if (CONFIG_SUPPORTED_FORMATS.has(normalizedFormat)) {
return 'supported'
}
if (
normalizedFormat === 'NV24'
&& isRkmppBackend(encoderBackend)
) {
return 'supported'
}
return 'unsupported'
}
if (WEBRTC_SUPPORTED_FORMATS.has(normalizedFormat)) {
return isCompressedFormat(normalizedFormat) ? 'not_recommended' : 'supported'
}
if (
normalizedFormat === 'NV24'
&& isRkmppBackend(encoderBackend)
&& (context === 'h264' || context === 'h265')
) {
return 'supported'
}
return 'unsupported'
}
export function isVideoFormatSelectable(
formatName: string,
context: VideoFormatSupportContext,
encoderBackend = 'auto',
): boolean {
return getVideoFormatState(formatName, context, encoderBackend) !== 'unsupported'
}

View File

@@ -85,6 +85,9 @@ export const useAuthStore = defineStore('auth', () => {
hid_ch9329_baudrate?: number hid_ch9329_baudrate?: number
hid_otg_udc?: string hid_otg_udc?: string
hid_otg_profile?: string hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean
encoder_backend?: string encoder_backend?: string
audio_device?: string audio_device?: string
ttyd_enabled?: boolean ttyd_enabled?: boolean

View File

@@ -32,7 +32,14 @@ interface HidState {
available: boolean available: boolean
backend: string backend: string
initialized: boolean initialized: boolean
online: boolean
supportsAbsoluteMouse: boolean supportsAbsoluteMouse: boolean
keyboardLedsEnabled: boolean
ledState: {
numLock: boolean
capsLock: boolean
scrollLock: boolean
}
device: string | null device: string | null
error: string | null error: string | null
errorCode: string | null errorCode: string | null
@@ -86,9 +93,19 @@ export interface HidDeviceInfo {
available: boolean available: boolean
backend: string backend: string
initialized: boolean initialized: boolean
online: boolean
supports_absolute_mouse: boolean supports_absolute_mouse: boolean
keyboard_leds_enabled: boolean
led_state: {
num_lock: boolean
caps_lock: boolean
scroll_lock: boolean
compose: boolean
kana: boolean
}
device: string | null device: string | null
error: string | null error: string | null
error_code?: string | null
} }
export interface MsdDeviceInfo { export interface MsdDeviceInfo {
@@ -115,12 +132,18 @@ export interface AudioDeviceInfo {
error: string | null error: string | null
} }
export interface TtydDeviceInfo {
available: boolean
running: boolean
}
export interface DeviceInfoEvent { export interface DeviceInfoEvent {
video: VideoDeviceInfo video: VideoDeviceInfo
hid: HidDeviceInfo hid: HidDeviceInfo
msd: MsdDeviceInfo | null msd: MsdDeviceInfo | null
atx: AtxDeviceInfo | null atx: AtxDeviceInfo | null
audio: AudioDeviceInfo | null audio: AudioDeviceInfo | null
ttyd: TtydDeviceInfo
} }
export const useSystemStore = defineStore('system', () => { export const useSystemStore = defineStore('system', () => {
@@ -183,10 +206,17 @@ export const useSystemStore = defineStore('system', () => {
available: state.available, available: state.available,
backend: state.backend, backend: state.backend,
initialized: state.initialized, initialized: state.initialized,
online: state.online,
supportsAbsoluteMouse: state.supports_absolute_mouse, supportsAbsoluteMouse: state.supports_absolute_mouse,
device: null, keyboardLedsEnabled: state.keyboard_leds_enabled,
error: null, ledState: {
errorCode: null, numLock: state.led_state.num_lock,
capsLock: state.led_state.caps_lock,
scrollLock: state.led_state.scroll_lock,
},
device: state.device ?? null,
error: state.error ?? null,
errorCode: state.error_code ?? null,
} }
return state return state
} catch (e) { } catch (e) {
@@ -286,11 +316,17 @@ export const useSystemStore = defineStore('system', () => {
available: data.hid.available, available: data.hid.available,
backend: data.hid.backend, backend: data.hid.backend,
initialized: data.hid.initialized, initialized: data.hid.initialized,
online: data.hid.online,
supportsAbsoluteMouse: data.hid.supports_absolute_mouse, supportsAbsoluteMouse: data.hid.supports_absolute_mouse,
keyboardLedsEnabled: data.hid.keyboard_leds_enabled,
ledState: {
numLock: data.hid.led_state.num_lock,
capsLock: data.hid.led_state.caps_lock,
scrollLock: data.hid.led_state.scroll_lock,
},
device: data.hid.device, device: data.hid.device,
error: data.hid.error, error: data.hid.error,
// system.device_info does not include HID error_code, keep latest one when error still exists. errorCode: data.hid.error_code ?? null,
errorCode: data.hid.error ? (hid.value?.errorCode ?? null) : null,
} }
// Update MSD state (optional) // Update MSD state (optional)
@@ -360,28 +396,6 @@ export const useSystemStore = defineStore('system', () => {
} }
} }
/**
* Update HID state from hid.state_changed / hid.device_lost events.
*/
function updateHidStateFromEvent(data: {
backend: string
initialized: boolean
error?: string | null
error_code?: string | null
}) {
const current = hid.value
const nextBackend = data.backend || current?.backend || 'unknown'
hid.value = {
available: nextBackend !== 'none',
backend: nextBackend,
initialized: data.initialized,
supportsAbsoluteMouse: current?.supportsAbsoluteMouse ?? false,
device: current?.device ?? null,
error: data.error ?? null,
errorCode: data.error_code ?? null,
}
}
return { return {
version, version,
buildDate, buildDate,
@@ -406,7 +420,6 @@ export const useSystemStore = defineStore('system', () => {
updateWsConnection, updateWsConnection,
updateHidWsConnection, updateHidWsConnection,
updateFromDeviceInfo, updateFromDeviceInfo,
updateHidStateFromEvent,
updateStreamClients, updateStreamClients,
setStreamOnline, setStreamOnline,
} }

View File

@@ -58,12 +58,8 @@ export interface OtgDescriptorConfig {
export enum OtgHidProfile { export enum OtgHidProfile {
/** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */ /** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */
Full = "full", Full = "full",
/** Full HID device set without MSD */
FullNoMsd = "full_no_msd",
/** Full HID device set without consumer control */ /** Full HID device set without consumer control */
FullNoConsumer = "full_no_consumer", FullNoConsumer = "full_no_consumer",
/** Full HID device set without consumer control and MSD */
FullNoConsumerNoMsd = "full_no_consumer_no_msd",
/** Legacy profile: only keyboard */ /** Legacy profile: only keyboard */
LegacyKeyboard = "legacy_keyboard", LegacyKeyboard = "legacy_keyboard",
/** Legacy profile: only relative mouse */ /** Legacy profile: only relative mouse */
@@ -72,6 +68,18 @@ export enum OtgHidProfile {
Custom = "custom", Custom = "custom",
} }
/** OTG endpoint budget policy. */
export enum OtgEndpointBudget {
/** Derive a safe default from the selected UDC. */
Auto = "auto",
/** Limit OTG gadget functions to 5 endpoints. */
Five = "five",
/** Limit OTG gadget functions to 6 endpoints. */
Six = "six",
/** Do not impose a software endpoint budget. */
Unlimited = "unlimited",
}
/** OTG HID function selection (used when profile is Custom) */ /** OTG HID function selection (used when profile is Custom) */
export interface OtgHidFunctions { export interface OtgHidFunctions {
keyboard: boolean; keyboard: boolean;
@@ -84,18 +92,18 @@ export interface OtgHidFunctions {
export interface HidConfig { export interface HidConfig {
/** HID backend type */ /** HID backend type */
backend: HidBackend; backend: HidBackend;
/** OTG keyboard device path */
otg_keyboard: string;
/** OTG mouse device path */
otg_mouse: string;
/** OTG UDC (USB Device Controller) name */ /** OTG UDC (USB Device Controller) name */
otg_udc?: string; otg_udc?: string;
/** OTG USB device descriptor configuration */ /** OTG USB device descriptor configuration */
otg_descriptor?: OtgDescriptorConfig; otg_descriptor?: OtgDescriptorConfig;
/** OTG HID function profile */ /** OTG HID function profile */
otg_profile?: OtgHidProfile; otg_profile?: OtgHidProfile;
/** OTG endpoint budget policy */
otg_endpoint_budget?: OtgEndpointBudget;
/** OTG HID function selection (used when profile is Custom) */ /** OTG HID function selection (used when profile is Custom) */
otg_functions?: OtgHidFunctions; otg_functions?: OtgHidFunctions;
/** Enable keyboard LED/status feedback for OTG keyboard */
otg_keyboard_leds?: boolean;
/** CH9329 serial port */ /** CH9329 serial port */
ch9329_port: string; ch9329_port: string;
/** CH9329 baud rate */ /** CH9329 baud rate */
@@ -118,7 +126,7 @@ export enum AtxDriverType {
Gpio = "gpio", Gpio = "gpio",
/** USB HID relay module */ /** USB HID relay module */
UsbRelay = "usbrelay", UsbRelay = "usbrelay",
/** Serial/COM port relay (LCUS type) */ /** Serial/COM port relay (taobao LCUS type) */
Serial = "serial", Serial = "serial",
/** Disabled / Not configured */ /** Disabled / Not configured */
None = "none", None = "none",
@@ -149,6 +157,7 @@ export interface AtxKeyConfig {
* Pin or channel number: * Pin or channel number:
* - For GPIO: GPIO pin number * - For GPIO: GPIO pin number
* - For USB Relay: relay channel (0-based) * - For USB Relay: relay channel (0-based)
* - For Serial Relay (LCUS): relay channel (1-based)
*/ */
pin: number; pin: number;
/** Active level (only applicable to GPIO, ignored for USB Relay) */ /** Active level (only applicable to GPIO, ignored for USB Relay) */
@@ -444,11 +453,11 @@ export interface AtxConfigUpdate {
/** Available ATX devices for discovery */ /** Available ATX devices for discovery */
export interface AtxDevices { export interface AtxDevices {
/** Available GPIO chips (/dev/gpiochip*) */ /** Available GPIO chips (/dev/gpiochip*) */
/** Available Serial ports (/dev/ttyUSB*) */
serial_ports: string[];
gpio_chips: string[]; gpio_chips: string[];
/** Available USB HID relay devices (/dev/hidraw*) */ /** Available USB HID relay devices (/dev/hidraw*) */
usb_relays: string[]; usb_relays: string[];
/** Available Serial ports (/dev/ttyUSB*) */
serial_ports: string[];
} }
export interface AudioConfigUpdate { export interface AudioConfigUpdate {
@@ -579,7 +588,9 @@ export interface HidConfigUpdate {
otg_udc?: string; otg_udc?: string;
otg_descriptor?: OtgDescriptorConfigUpdate; otg_descriptor?: OtgDescriptorConfigUpdate;
otg_profile?: OtgHidProfile; otg_profile?: OtgHidProfile;
otg_endpoint_budget?: OtgEndpointBudget;
otg_functions?: OtgHidFunctionsUpdate; otg_functions?: OtgHidFunctionsUpdate;
otg_keyboard_leds?: boolean;
mouse_absolute?: boolean; mouse_absolute?: boolean;
} }
@@ -623,19 +634,19 @@ export interface RustDeskConfigUpdate {
device_password?: string; device_password?: string;
} }
/** Stream 配置响应(包含 has_turn_password 字段) */ /** Stream configuration response (includes has_turn_password) */
export interface StreamConfigResponse { export interface StreamConfigResponse {
mode: StreamMode; mode: StreamMode;
encoder: EncoderType; encoder: EncoderType;
bitrate_preset: BitratePreset; bitrate_preset: BitratePreset;
/** 是否有公共 ICE 服务器可用(编译时确定) */ /** Whether public ICE servers are available (compile-time decision) */
has_public_ice_servers: boolean; has_public_ice_servers: boolean;
/** 当前是否正在使用公共 ICE 服务器STUN/TURN 都为空时) */ /** Whether public ICE servers are currently in use (when STUN/TURN are unset) */
using_public_ice_servers: boolean; using_public_ice_servers: boolean;
stun_server?: string; stun_server?: string;
turn_server?: string; turn_server?: string;
turn_username?: string; turn_username?: string;
/** 指示是否已设置 TURN 密码(实际密码不返回) */ /** Indicates whether TURN password has been configured (password is not returned) */
has_turn_password: boolean; has_turn_password: boolean;
} }
@@ -666,12 +677,6 @@ export interface TtydConfigUpdate {
shell?: string; shell?: string;
} }
/** Simple ttyd status for console view */
export interface TtydStatus {
available: boolean;
running: boolean;
}
export interface VideoConfigUpdate { export interface VideoConfigUpdate {
device?: string; device?: string;
format?: string; format?: string;
@@ -688,3 +693,130 @@ export interface WebConfigUpdate {
bind_address?: string; bind_address?: string;
https_enabled?: boolean; https_enabled?: boolean;
} }
/**
* Shared canonical keyboard key identifiers used across frontend and backend.
*
* The enum names intentionally mirror `KeyboardEvent.code` style values so the
* browser, virtual keyboard, and HID backend can all speak the same language.
*/
export enum CanonicalKey {
KeyA = "KeyA",
KeyB = "KeyB",
KeyC = "KeyC",
KeyD = "KeyD",
KeyE = "KeyE",
KeyF = "KeyF",
KeyG = "KeyG",
KeyH = "KeyH",
KeyI = "KeyI",
KeyJ = "KeyJ",
KeyK = "KeyK",
KeyL = "KeyL",
KeyM = "KeyM",
KeyN = "KeyN",
KeyO = "KeyO",
KeyP = "KeyP",
KeyQ = "KeyQ",
KeyR = "KeyR",
KeyS = "KeyS",
KeyT = "KeyT",
KeyU = "KeyU",
KeyV = "KeyV",
KeyW = "KeyW",
KeyX = "KeyX",
KeyY = "KeyY",
KeyZ = "KeyZ",
Digit1 = "Digit1",
Digit2 = "Digit2",
Digit3 = "Digit3",
Digit4 = "Digit4",
Digit5 = "Digit5",
Digit6 = "Digit6",
Digit7 = "Digit7",
Digit8 = "Digit8",
Digit9 = "Digit9",
Digit0 = "Digit0",
Enter = "Enter",
Escape = "Escape",
Backspace = "Backspace",
Tab = "Tab",
Space = "Space",
Minus = "Minus",
Equal = "Equal",
BracketLeft = "BracketLeft",
BracketRight = "BracketRight",
Backslash = "Backslash",
Semicolon = "Semicolon",
Quote = "Quote",
Backquote = "Backquote",
Comma = "Comma",
Period = "Period",
Slash = "Slash",
CapsLock = "CapsLock",
F1 = "F1",
F2 = "F2",
F3 = "F3",
F4 = "F4",
F5 = "F5",
F6 = "F6",
F7 = "F7",
F8 = "F8",
F9 = "F9",
F10 = "F10",
F11 = "F11",
F12 = "F12",
PrintScreen = "PrintScreen",
ScrollLock = "ScrollLock",
Pause = "Pause",
Insert = "Insert",
Home = "Home",
PageUp = "PageUp",
Delete = "Delete",
End = "End",
PageDown = "PageDown",
ArrowRight = "ArrowRight",
ArrowLeft = "ArrowLeft",
ArrowDown = "ArrowDown",
ArrowUp = "ArrowUp",
NumLock = "NumLock",
NumpadDivide = "NumpadDivide",
NumpadMultiply = "NumpadMultiply",
NumpadSubtract = "NumpadSubtract",
NumpadAdd = "NumpadAdd",
NumpadEnter = "NumpadEnter",
Numpad1 = "Numpad1",
Numpad2 = "Numpad2",
Numpad3 = "Numpad3",
Numpad4 = "Numpad4",
Numpad5 = "Numpad5",
Numpad6 = "Numpad6",
Numpad7 = "Numpad7",
Numpad8 = "Numpad8",
Numpad9 = "Numpad9",
Numpad0 = "Numpad0",
NumpadDecimal = "NumpadDecimal",
IntlBackslash = "IntlBackslash",
ContextMenu = "ContextMenu",
F13 = "F13",
F14 = "F14",
F15 = "F15",
F16 = "F16",
F17 = "F17",
F18 = "F18",
F19 = "F19",
F20 = "F20",
F21 = "F21",
F22 = "F22",
F23 = "F23",
F24 = "F24",
ControlLeft = "ControlLeft",
ShiftLeft = "ShiftLeft",
AltLeft = "AltLeft",
MetaLeft = "MetaLeft",
ControlRight = "ControlRight",
ShiftRight = "ShiftRight",
AltRight = "AltRight",
MetaRight = "MetaRight",
}

View File

@@ -1,10 +1,13 @@
// HID (Human Interface Device) type definitions // HID (Human Interface Device) type definitions
// Shared between WebRTC DataChannel and WebSocket HID channels // Shared between WebRTC DataChannel and WebSocket HID channels
import { type CanonicalKey } from '@/types/generated'
import { canonicalKeyToHidUsage } from '@/lib/keyboardMappings'
/** Keyboard event for HID input */ /** Keyboard event for HID input */
export interface HidKeyboardEvent { export interface HidKeyboardEvent {
type: 'keydown' | 'keyup' type: 'keydown' | 'keyup'
key: number key: CanonicalKey
/** Raw HID modifier byte (bit0..bit7 = LCtrl..RMeta) */ /** Raw HID modifier byte (bit0..bit7 = LCtrl..RMeta) */
modifier?: number modifier?: number
} }
@@ -51,7 +54,7 @@ export function encodeKeyboardEvent(event: HidKeyboardEvent): ArrayBuffer {
view.setUint8(0, MSG_KEYBOARD) view.setUint8(0, MSG_KEYBOARD)
view.setUint8(1, event.type === 'keydown' ? KB_EVENT_DOWN : KB_EVENT_UP) view.setUint8(1, event.type === 'keydown' ? KB_EVENT_DOWN : KB_EVENT_UP)
view.setUint8(2, event.key & 0xff) view.setUint8(2, canonicalKeyToHidUsage(event.key) & 0xff)
view.setUint8(3, (event.modifier ?? 0) & 0xff) view.setUint8(3, (event.modifier ?? 0) & 0xff)

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue' import { ref, onMounted, onUnmounted, onActivated, onDeactivated, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system' import { useSystemStore } from '@/stores/system'
@@ -11,9 +11,10 @@ import { useHidWebSocket } from '@/composables/useHidWebSocket'
import { useWebRTC } from '@/composables/useWebRTC' import { useWebRTC } from '@/composables/useWebRTC'
import { useVideoSession } from '@/composables/useVideoSession' import { useVideoSession } from '@/composables/useVideoSession'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio' import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { streamApi, hidApi, atxApi, extensionsApi, atxConfigApi, authApi } from '@/api' import { streamApi, hidApi, atxApi, atxConfigApi, authApi } from '@/api'
import { CanonicalKey, HidBackend } from '@/types/generated'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid' import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { keyboardEventToHidCode, updateModifierMaskForHidKey } from '@/lib/keyboardMappings' import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { generateUUID } from '@/lib/utils' import { generateUUID } from '@/lib/utils'
import type { VideoMode } from '@/components/VideoConfigPopover.vue' import type { VideoMode } from '@/components/VideoConfigPopover.vue'
@@ -81,7 +82,6 @@ const consoleEvents = useConsoleEvents({
onStreamDeviceLost: handleStreamDeviceLost, onStreamDeviceLost: handleStreamDeviceLost,
onStreamRecovered: handleStreamRecovered, onStreamRecovered: handleStreamRecovered,
onDeviceInfo: handleDeviceInfo, onDeviceInfo: handleDeviceInfo,
onAudioStateChanged: handleAudioStateChanged,
}) })
// Video mode state // Video mode state
@@ -118,10 +118,13 @@ const myClientId = generateUUID()
// HID state // HID state
const mouseMode = ref<'absolute' | 'relative'>('absolute') const mouseMode = ref<'absolute' | 'relative'>('absolute')
const pressedKeys = ref<string[]>([]) const pressedKeys = ref<CanonicalKey[]>([])
const keyboardLed = ref({ const keyboardLed = computed(() => ({
capsLock: false, capsLock: systemStore.hid?.ledState.capsLock ?? false,
}) numLock: systemStore.hid?.ledState.numLock ?? false,
scrollLock: systemStore.hid?.ledState.scrollLock ?? false,
}))
const keyboardLedEnabled = computed(() => systemStore.hid?.keyboardLedsEnabled ?? false)
const activeModifierMask = ref(0) const activeModifierMask = ref(0)
const mousePosition = ref({ x: 0, y: 0 }) const mousePosition = ref({ x: 0, y: 0 })
const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode
@@ -137,6 +140,8 @@ let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas be
// Cursor visibility (from localStorage, updated via storage event) // Cursor visibility (from localStorage, updated via storage event)
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false') const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
let interactionListenersBound = false
const isConsoleActive = ref(false)
function syncMouseModeFromConfig() { function syncMouseModeFromConfig() {
const mouseAbsolute = configStore.hid?.mouse_absolute const mouseAbsolute = configStore.hid?.mouse_absolute
@@ -151,6 +156,12 @@ function syncMouseModeFromConfig() {
const virtualKeyboardVisible = ref(false) const virtualKeyboardVisible = ref(false)
const virtualKeyboardAttached = ref(true) const virtualKeyboardAttached = ref(true)
const statsSheetOpen = ref(false) const statsSheetOpen = ref(false)
const virtualKeyboardConsumerEnabled = computed(() => {
const hid = configStore.hid
if (!hid) return true
if (hid.backend !== HidBackend.Otg) return true
return hid.otg_functions?.consumer !== false
})
// Change password dialog state // Change password dialog state
const changePasswordDialogOpen = ref(false) const changePasswordDialogOpen = ref(false)
@@ -162,7 +173,6 @@ const changingPassword = ref(false)
// ttyd (web terminal) state // ttyd (web terminal) state
const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null) const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
const showTerminalDialog = ref(false) const showTerminalDialog = ref(false)
let ttydPollInterval: ReturnType<typeof setInterval> | null = null
// Theme // Theme
const isDark = ref(document.documentElement.classList.contains('dark')) const isDark = ref(document.documentElement.classList.contains('dark'))
@@ -174,6 +184,10 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
if (videoError.value) return 'error' if (videoError.value) return 'error'
if (videoLoading.value) return 'connecting' if (videoLoading.value) return 'connecting'
if (videoMode.value !== 'mjpeg') {
if (webrtc.isConnecting.value) return 'connecting'
if (webrtc.isConnected.value) return 'connected'
}
if (systemStore.stream?.online) return 'connected' if (systemStore.stream?.online) return 'connected'
return 'disconnected' return 'disconnected'
}) })
@@ -227,6 +241,7 @@ const videoDetails = computed<StatusDetail[]>(() => {
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => { const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const hid = systemStore.hid const hid = systemStore.hid
if (hid?.errorCode === 'udc_not_configured') return 'disconnected'
if (hid?.error) return 'error' if (hid?.error) return 'error'
// In WebRTC mode, check DataChannel status first // In WebRTC mode, check DataChannel status first
@@ -251,8 +266,8 @@ const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'
if (hidWs.hidUnavailable.value) return 'disconnected' if (hidWs.hidUnavailable.value) return 'disconnected'
// Normal status based on system state // Normal status based on system state
if (hid?.available && hid.initialized) return 'connected' if (hid?.available && hid.online) return 'connected'
if (hid?.available && !hid.initialized) return 'connecting' if (hid?.available && hid.initialized) return 'connecting'
return 'disconnected' return 'disconnected'
}) })
@@ -264,29 +279,54 @@ const hidQuickInfo = computed(() => {
return mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative') return mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative')
}) })
function hidErrorHint(errorCode?: string | null, backend?: string | null): string { function extractCh9329Command(reason?: string | null): string | null {
if (!reason) return null
const match = reason.match(/cmd 0x([0-9a-f]{2})/i)
const cmd = match?.[1]
return cmd ? `0x${cmd.toUpperCase()}` : null
}
function hidErrorHint(errorCode?: string | null, backend?: string | null, reason?: string | null): string {
const ch9329Command = extractCh9329Command(reason)
switch (errorCode) { switch (errorCode) {
case 'udc_not_configured': case 'udc_not_configured':
return t('hid.errorHints.udcNotConfigured') return t('hid.errorHints.udcNotConfigured')
case 'disabled':
return t('hid.errorHints.disabled')
case 'enoent': case 'enoent':
return t('hid.errorHints.hidDeviceMissing') return t('hid.errorHints.hidDeviceMissing')
case 'not_opened':
return t('hid.errorHints.notOpened')
case 'port_not_found': case 'port_not_found':
case 'port_not_opened':
return t('hid.errorHints.portNotFound') return t('hid.errorHints.portNotFound')
case 'invalid_config':
return t('hid.errorHints.invalidConfig')
case 'no_response': case 'no_response':
return t('hid.errorHints.noResponse') return t(ch9329Command ? 'hid.errorHints.noResponseWithCmd' : 'hid.errorHints.noResponse', {
cmd: ch9329Command ?? '',
})
case 'protocol_error': case 'protocol_error':
case 'invalid_response': case 'invalid_response':
return t('hid.errorHints.protocolError') return t('hid.errorHints.protocolError')
case 'health_check_failed': case 'enxio':
case 'health_check_join_failed': case 'enodev':
return t('hid.errorHints.healthCheckFailed') return t('hid.errorHints.deviceDisconnected')
case 'eio': case 'eio':
case 'epipe': case 'epipe':
case 'eshutdown': case 'eshutdown':
case 'io_error':
case 'write_failed':
case 'read_failed':
if (backend === 'otg') return t('hid.errorHints.otgIoError') if (backend === 'otg') return t('hid.errorHints.otgIoError')
if (backend === 'ch9329') return t('hid.errorHints.ch9329IoError') if (backend === 'ch9329') return t('hid.errorHints.ch9329IoError')
return t('hid.errorHints.ioError') return t('hid.errorHints.ioError')
case 'serial_error':
return t('hid.errorHints.serialError')
case 'init_failed':
return t('hid.errorHints.initFailed')
case 'shutdown':
return t('hid.errorHints.shutdown')
default: default:
return '' return ''
} }
@@ -294,8 +334,8 @@ function hidErrorHint(errorCode?: string | null, backend?: string | null): strin
function buildHidErrorMessage(reason?: string | null, errorCode?: string | null, backend?: string | null): string { function buildHidErrorMessage(reason?: string | null, errorCode?: string | null, backend?: string | null): string {
if (!reason && !errorCode) return '' if (!reason && !errorCode) return ''
const hint = hidErrorHint(errorCode, backend) const hint = hidErrorHint(errorCode, backend, reason)
if (reason && hint) return `${reason} (${hint})` if (hint) return hint
if (reason) return reason if (reason) return reason
return hint || t('common.error') return hint || t('common.error')
} }
@@ -309,19 +349,29 @@ const hidDetails = computed<StatusDetail[]>(() => {
const hid = systemStore.hid const hid = systemStore.hid
if (!hid) return [] if (!hid) return []
const errorMessage = buildHidErrorMessage(hid.error, hid.errorCode, hid.backend) const errorMessage = buildHidErrorMessage(hid.error, hid.errorCode, hid.backend)
const hidErrorStatus: StatusDetail['status'] =
hid.errorCode === 'udc_not_configured' ? 'warning' : 'error'
const details: StatusDetail[] = [ const details: StatusDetail[] = [
{ label: t('statusCard.device'), value: hid.device || '-' }, { label: t('statusCard.device'), value: hid.device || '-' },
{ label: t('statusCard.backend'), value: hid.backend || t('common.unknown') }, { label: t('statusCard.backend'), value: hid.backend || t('common.unknown') },
{ label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error ? 'error' : hid.initialized ? 'ok' : 'warning' }, { label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error && hid.errorCode !== 'udc_not_configured' ? 'error' : hid.initialized ? 'ok' : 'warning' },
{ label: t('statusCard.online'), value: hid.online ? t('statusCard.yes') : t('statusCard.no'), status: hid.online ? 'ok' : hid.initialized ? 'warning' : 'error' },
{ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' }, { label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' },
{
label: t('settings.otgKeyboardLeds'),
value: hid.keyboardLedsEnabled
? `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`
: t('infobar.keyboardLedUnavailable'),
status: hid.keyboardLedsEnabled ? 'ok' : undefined,
},
] ]
if (hid.errorCode) { if (hid.errorCode) {
details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: 'error' }) details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: hidErrorStatus })
} }
if (errorMessage) { if (errorMessage) {
details.push({ label: t('common.error'), value: errorMessage, status: 'error' }) details.push({ label: t('common.error'), value: errorMessage, status: hidErrorStatus })
} }
// Add HID channel info based on video mode // Add HID channel info based on video mode
@@ -581,6 +631,7 @@ function waitForVideoFirstFrame(el: HTMLVideoElement, timeoutMs = 2000): Promise
function shouldSuppressAutoReconnect(): boolean { function shouldSuppressAutoReconnect(): boolean {
return videoMode.value === 'mjpeg' return videoMode.value === 'mjpeg'
|| !isConsoleActive.value
|| videoSession.localSwitching.value || videoSession.localSwitching.value
|| videoSession.backendSwitching.value || videoSession.backendSwitching.value
|| videoRestarting.value || videoRestarting.value
@@ -932,7 +983,22 @@ async function restoreInitialMode(serverMode: VideoMode) {
} }
function handleDeviceInfo(data: any) { function handleDeviceInfo(data: any) {
const prevAudioStreaming = systemStore.audio?.streaming ?? false
const prevAudioDevice = systemStore.audio?.device ?? null
systemStore.updateFromDeviceInfo(data) systemStore.updateFromDeviceInfo(data)
ttydStatus.value = data.ttyd ?? null
const nextAudioStreaming = systemStore.audio?.streaming ?? false
const nextAudioDevice = systemStore.audio?.device ?? null
if (
prevAudioStreaming !== nextAudioStreaming ||
prevAudioDevice !== nextAudioDevice
) {
void handleAudioStateChanged({
streaming: nextAudioStreaming,
device: nextAudioDevice,
})
}
// Skip mode sync if video config is being changed // Skip mode sync if video config is being changed
// This prevents false-positive mode changes during config switching // This prevents false-positive mode changes during config switching
@@ -1440,14 +1506,6 @@ async function handleChangePassword() {
} }
// ttyd (web terminal) functions // ttyd (web terminal) functions
async function fetchTtydStatus() {
try {
ttydStatus.value = await extensionsApi.getTtydStatus()
} catch {
ttydStatus.value = null
}
}
function openTerminal() { function openTerminal() {
if (!ttydStatus.value?.running) return if (!ttydStatus.value?.running) return
showTerminalDialog.value = true showTerminalDialog.value = true
@@ -1500,7 +1558,7 @@ function handleHidError(_error: any, _operation: string) {
} }
// HID channel selection: use WebRTC DataChannel when available, fallback to WebSocket // HID channel selection: use WebRTC DataChannel when available, fallback to WebSocket
function sendKeyboardEvent(type: 'down' | 'up', key: number, modifier?: number) { function sendKeyboardEvent(type: 'down' | 'up', key: CanonicalKey, modifier?: number) {
// In WebRTC mode with DataChannel ready, use DataChannel for lower latency // In WebRTC mode with DataChannel ready, use DataChannel for lower latency
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) { if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
const event: HidKeyboardEvent = { const event: HidKeyboardEvent = {
@@ -1579,22 +1637,19 @@ function handleKeyDown(e: KeyboardEvent) {
}) })
} }
const keyName = e.key === ' ' ? 'Space' : e.key const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
if (!pressedKeys.value.includes(keyName)) { if (canonicalKey === undefined) {
pressedKeys.value = [...pressedKeys.value, keyName]
}
keyboardLed.value.capsLock = e.getModifierState('CapsLock')
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
console.warn(`[HID] Unmapped key down: code=${e.code}, key=${e.key}`) console.warn(`[HID] Unmapped key down: code=${e.code}, key=${e.key}`)
return return
} }
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, true) if (!pressedKeys.value.includes(canonicalKey)) {
pressedKeys.value = [...pressedKeys.value, canonicalKey]
}
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, true)
activeModifierMask.value = modifierMask activeModifierMask.value = modifierMask
sendKeyboardEvent('down', hidKey, modifierMask) sendKeyboardEvent('down', canonicalKey, modifierMask)
} }
function handleKeyUp(e: KeyboardEvent) { function handleKeyUp(e: KeyboardEvent) {
@@ -1610,30 +1665,99 @@ function handleKeyUp(e: KeyboardEvent) {
e.stopPropagation() e.stopPropagation()
} }
const keyName = e.key === ' ' ? 'Space' : e.key const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
pressedKeys.value = pressedKeys.value.filter(k => k !== keyName) if (canonicalKey === undefined) {
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
console.warn(`[HID] Unmapped key up: code=${e.code}, key=${e.key}`) console.warn(`[HID] Unmapped key up: code=${e.code}, key=${e.key}`)
return return
} }
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, false) pressedKeys.value = pressedKeys.value.filter(k => k !== canonicalKey)
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, false)
activeModifierMask.value = modifierMask activeModifierMask.value = modifierMask
sendKeyboardEvent('up', hidKey, modifierMask) sendKeyboardEvent('up', canonicalKey, modifierMask)
}
function getActiveVideoElement(): HTMLImageElement | HTMLVideoElement | null {
return videoMode.value !== 'mjpeg' ? webrtcVideoRef.value : videoRef.value
}
function getActiveVideoAspectRatio(): number | null {
if (videoMode.value !== 'mjpeg') {
const video = webrtcVideoRef.value
if (video?.videoWidth && video.videoHeight) {
return video.videoWidth / video.videoHeight
}
} else {
const image = videoRef.value
if (image?.naturalWidth && image.naturalHeight) {
return image.naturalWidth / image.naturalHeight
}
}
if (!videoAspectRatio.value) return null
const [width, height] = videoAspectRatio.value.split('/').map(Number)
if (!width || !height) return null
return width / height
}
function getRenderedVideoRect() {
const videoElement = getActiveVideoElement()
if (!videoElement) return null
const rect = videoElement.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
const contentAspectRatio = getActiveVideoAspectRatio()
if (!contentAspectRatio) {
return rect
}
const boxAspectRatio = rect.width / rect.height
if (!Number.isFinite(boxAspectRatio) || boxAspectRatio <= 0) {
return rect
}
if (boxAspectRatio > contentAspectRatio) {
const width = rect.height * contentAspectRatio
return {
left: rect.left + (rect.width - width) / 2,
top: rect.top,
width,
height: rect.height,
}
}
const height = rect.width / contentAspectRatio
return {
left: rect.left,
top: rect.top + (rect.height - height) / 2,
width: rect.width,
height,
}
}
function getAbsoluteMousePosition(e: MouseEvent) {
const rect = getRenderedVideoRect()
if (!rect) return null
const normalizedX = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const normalizedY = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
return {
x: Math.round(normalizedX * 32767),
y: Math.round(normalizedY * 32767),
}
} }
function handleMouseMove(e: MouseEvent) { function handleMouseMove(e: MouseEvent) {
// Use the appropriate video element based on current mode (WebRTC for h264/h265/vp8/vp9, MJPEG for mjpeg) const videoElement = getActiveVideoElement()
const videoElement = videoMode.value !== 'mjpeg' ? webrtcVideoRef.value : videoRef.value
if (!videoElement) return if (!videoElement) return
if (mouseMode.value === 'absolute') { if (mouseMode.value === 'absolute') {
// Absolute mode: send absolute coordinates (0-32767 range) const absolutePosition = getAbsoluteMousePosition(e)
const rect = videoElement.getBoundingClientRect() if (!absolutePosition) return
const x = Math.round((e.clientX - rect.left) / rect.width * 32767) const { x, y } = absolutePosition
const y = Math.round((e.clientY - rect.top) / rect.height * 32767)
mousePosition.value = { x, y } mousePosition.value = { x, y }
// Queue for throttled sending (absolute mode: just update pending position) // Queue for throttled sending (absolute mode: just update pending position)
@@ -1758,6 +1882,15 @@ function handleMouseDown(e: MouseEvent) {
return return
} }
if (mouseMode.value === 'absolute') {
const absolutePosition = getAbsoluteMousePosition(e)
if (absolutePosition) {
mousePosition.value = absolutePosition
sendMouseEvent({ type: 'move_abs', ...absolutePosition })
pendingMouseMove = null
}
}
const button = e.button === 0 ? 'left' : e.button === 2 ? 'right' : 'middle' const button = e.button === 0 ? 'left' : e.button === 2 ? 'right' : 'middle'
pressedMouseButton.value = button pressedMouseButton.value = button
sendMouseEvent({ type: 'down', button }) sendMouseEvent({ type: 'down', button })
@@ -1838,6 +1971,10 @@ function handlePointerLockError() {
isPointerLocked.value = false isPointerLocked.value = false
} }
function handleFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}
function handleBlur() { function handleBlur() {
pressedKeys.value = [] pressedKeys.value = []
activeModifierMask.value = 0 activeModifierMask.value = 0
@@ -1890,6 +2027,71 @@ function handleMouseSendIntervalStorage(e: StorageEvent) {
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage()) setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
} }
function registerInteractionListeners() {
if (interactionListenersBound) return
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', handleBlur)
window.addEventListener('mouseup', handleWindowMouseUp)
window.addEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.addEventListener('storage', handleMouseSendIntervalStorage)
document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('pointerlockerror', handlePointerLockError)
document.addEventListener('fullscreenchange', handleFullscreenChange)
interactionListenersBound = true
}
function unregisterInteractionListeners() {
if (!interactionListenersBound) return
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', handleBlur)
window.removeEventListener('mouseup', handleWindowMouseUp)
window.removeEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
window.removeEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.removeEventListener('storage', handleMouseSendIntervalStorage)
document.removeEventListener('pointerlockchange', handlePointerLockChange)
document.removeEventListener('pointerlockerror', handlePointerLockError)
document.removeEventListener('fullscreenchange', handleFullscreenChange)
interactionListenersBound = false
}
async function activateConsoleView() {
isConsoleActive.value = true
registerInteractionListeners()
if (videoMode.value !== 'mjpeg' && webrtc.videoTrack.value) {
await nextTick()
await rebindWebRTCVideo()
return
}
if (
videoMode.value !== 'mjpeg'
&& !webrtc.isConnected.value
&& !webrtc.isConnecting.value
&& !videoSession.localSwitching.value
&& !videoSession.backendSwitching.value
&& !initialModeRestoreInProgress
) {
await connectWebRTCOnly(videoMode.value)
}
}
function deactivateConsoleView() {
isConsoleActive.value = false
handleBlur()
exitPointerLock()
unregisterInteractionListeners()
}
// ActionBar handlers // ActionBar handlers
// (MSD and Settings are now handled by ActionBar component directly) // (MSD and Settings are now handled by ActionBar component directly)
@@ -1898,18 +2100,14 @@ function handleToggleVirtualKeyboard() {
} }
// Virtual keyboard key event handlers // Virtual keyboard key event handlers
function handleVirtualKeyDown(key: string) { function handleVirtualKeyDown(key: CanonicalKey) {
// Add to pressedKeys for InfoBar display // Add to pressedKeys for InfoBar display
if (!pressedKeys.value.includes(key)) { if (!pressedKeys.value.includes(key)) {
pressedKeys.value = [...pressedKeys.value, key] pressedKeys.value = [...pressedKeys.value, key]
} }
// Toggle CapsLock state when virtual keyboard presses CapsLock
if (key === 'CapsLock') {
keyboardLed.value.capsLock = !keyboardLed.value.capsLock
}
} }
function handleVirtualKeyUp(key: string) { function handleVirtualKeyUp(key: CanonicalKey) {
// Remove from pressedKeys // Remove from pressedKeys
pressedKeys.value = pressedKeys.value.filter(k => k !== key) pressedKeys.value = pressedKeys.value.filter(k => k !== key)
} }
@@ -1961,40 +2159,18 @@ onMounted(async () => {
syncMouseModeFromConfig() syncMouseModeFromConfig()
}).catch(() => {}) }).catch(() => {})
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', handleBlur)
window.addEventListener('mouseup', handleWindowMouseUp)
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage()) setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
// Listen for cursor visibility changes from HidConfigPopover
window.addEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.addEventListener('storage', handleMouseSendIntervalStorage)
watch(() => configStore.hid?.mouse_absolute, () => { watch(() => configStore.hid?.mouse_absolute, () => {
syncMouseModeFromConfig() syncMouseModeFromConfig()
}) })
// Pointer Lock event listeners
document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('pointerlockerror', handlePointerLockError)
document.addEventListener('fullscreenchange', () => {
isFullscreen.value = !!document.fullscreenElement
})
const storedTheme = localStorage.getItem('theme') const storedTheme = localStorage.getItem('theme')
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
isDark.value = true isDark.value = true
document.documentElement.classList.add('dark') document.documentElement.classList.add('dark')
} }
// Fetch ttyd status initially and poll every 10 seconds
fetchTtydStatus()
ttydPollInterval = setInterval(fetchTtydStatus, 10000)
// Note: Video mode is now synced from server via device_info event // Note: Video mode is now synced from server via device_info event
// The handleDeviceInfo function will automatically switch to the server's mode // The handleDeviceInfo function will automatically switch to the server's mode
// localStorage preference is only used when server mode matches // localStorage preference is only used when server mode matches
@@ -2009,7 +2185,17 @@ onMounted(async () => {
} }
}) })
onActivated(() => {
void activateConsoleView()
})
onDeactivated(() => {
deactivateConsoleView()
})
onUnmounted(() => { onUnmounted(() => {
deactivateConsoleView()
// Reset initial device info flag // Reset initial device info flag
initialDeviceInfoReceived = false initialDeviceInfoReceived = false
initialModeRestoreDone = false initialModeRestoreDone = false
@@ -2021,12 +2207,6 @@ onUnmounted(() => {
mouseFlushTimer = null mouseFlushTimer = null
} }
// Clear ttyd poll interval
if (ttydPollInterval) {
clearInterval(ttydPollInterval)
ttydPollInterval = null
}
// Clear all timers // Clear all timers
if (retryTimeoutId !== null) { if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId) clearTimeout(retryTimeoutId)
@@ -2051,18 +2231,6 @@ onUnmounted(() => {
// Exit pointer lock if active // Exit pointer lock if active
exitPointerLock() exitPointerLock()
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', handleBlur)
window.removeEventListener('mouseup', handleWindowMouseUp)
window.removeEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
window.removeEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.removeEventListener('storage', handleMouseSendIntervalStorage)
// Remove pointer lock event listeners
document.removeEventListener('pointerlockchange', handlePointerLockChange)
document.removeEventListener('pointerlockerror', handlePointerLockError)
}) })
</script> </script>
@@ -2367,6 +2535,9 @@ onUnmounted(() => {
v-if="virtualKeyboardVisible" v-if="virtualKeyboardVisible"
v-model:visible="virtualKeyboardVisible" v-model:visible="virtualKeyboardVisible"
v-model:attached="virtualKeyboardAttached" v-model:attached="virtualKeyboardAttached"
:caps-lock="keyboardLed.capsLock"
:pressed-keys="pressedKeys"
:consumer-enabled="virtualKeyboardConsumerEnabled"
@key-down="handleVirtualKeyDown" @key-down="handleVirtualKeyDown"
@key-up="handleVirtualKeyUp" @key-up="handleVirtualKeyUp"
/> />
@@ -2379,6 +2550,9 @@ onUnmounted(() => {
<InfoBar <InfoBar
:pressed-keys="pressedKeys" :pressed-keys="pressedKeys"
:caps-lock="keyboardLed.capsLock" :caps-lock="keyboardLed.capsLock"
:num-lock="keyboardLed.numLock"
:scroll-lock="keyboardLed.scrollLock"
:keyboard-led-enabled="keyboardLedEnabled"
:mouse-position="mousePosition" :mouse-position="mousePosition"
:debug-mode="false" :debug-mode="false"
/> />

View File

@@ -3,6 +3,15 @@ import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import {
setLanguage,
getCurrentLanguage,
type SupportedLocale,
} from '@/i18n'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Monitor, Lock, Eye, EyeOff, User } from 'lucide-vue-next' import { Monitor, Lock, Eye, EyeOff, User } from 'lucide-vue-next'
const { t } = useI18n() const { t } = useI18n()
@@ -10,12 +19,18 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const currentLanguage = ref<SupportedLocale>(getCurrentLanguage())
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const showPassword = ref(false) const showPassword = ref(false)
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
function handleLanguageChange(lang: SupportedLocale) {
currentLanguage.value = lang
setLanguage(lang)
}
async function handleLogin() { async function handleLogin() {
if (!username.value) { if (!username.value) {
error.value = t('auth.enterUsername') error.value = t('auth.enterUsername')
@@ -40,57 +55,66 @@ async function handleLogin() {
loading.value = false loading.value = false
} }
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleLogin()
}
}
</script> </script>
<template> <template>
<div class="min-h-screen min-h-dvh flex items-center justify-center bg-background p-4"> <div class="min-h-screen min-h-dvh flex items-center justify-center bg-background p-4">
<div class="w-full max-w-sm space-y-6"> <Card class="relative w-full max-w-sm">
<!-- Logo and Title --> <div class="absolute top-4 right-4 flex gap-2">
<div class="text-center space-y-2"> <Button
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10"> :variant="currentLanguage === 'zh-CN' ? 'default' : 'outline'"
<Monitor class="w-8 h-8 text-primary" /> size="sm"
</div> @click="handleLanguageChange('zh-CN')"
<h1 class="text-2xl font-bold text-foreground">One-KVM</h1> >
<p class="text-sm text-muted-foreground">{{ t('auth.loginPrompt') }}</p> 中文
</Button>
<Button
:variant="currentLanguage === 'en-US' ? 'default' : 'outline'"
size="sm"
@click="handleLanguageChange('en-US')"
>
English
</Button>
</div> </div>
<!-- Login Form --> <CardHeader class="space-y-2 pt-10 text-center sm:pt-12">
<div class="space-y-4"> <div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
<!-- Username Input --> <Monitor class="w-8 h-8 text-primary" />
<div class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
<User class="w-4 h-4" />
</div> </div>
<input <CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
<CardDescription>{{ t('auth.login') }}</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" @submit.prevent="handleLogin">
<div class="space-y-2">
<Label for="username">{{ t('auth.username') }}</Label>
<div class="relative">
<User class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="username"
v-model="username" v-model="username"
type="text" type="text"
:placeholder="t('auth.username')" :placeholder="t('auth.username')"
class="w-full h-10 pl-10 pr-4 rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" class="pl-10"
@keydown="handleKeydown"
/> />
</div> </div>
<!-- Password Input -->
<div class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
<Lock class="w-4 h-4" />
</div> </div>
<input
<div class="space-y-2">
<Label for="password">{{ t('auth.password') }}</Label>
<div class="relative">
<Lock class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
v-model="password" v-model="password"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:placeholder="t('auth.password')" :placeholder="t('auth.password')"
class="w-full h-10 pl-10 pr-10 rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" class="pl-10 pr-10"
@keydown="handleKeydown"
/> />
<button <button
type="button" type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
:aria-label="showPassword ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')" :aria-label="showPassword ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')"
@click="showPassword = !showPassword" @click="showPassword = !showPassword"
> >
@@ -98,18 +122,16 @@ function handleKeydown(e: KeyboardEvent) {
<EyeOff v-else class="w-4 h-4" /> <EyeOff v-else class="w-4 h-4" />
</button> </button>
</div> </div>
</div>
<button <p v-if="error" class="text-center text-sm text-destructive">{{ error }}</p>
class="w-full h-10 rounded-md bg-primary text-primary-foreground font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
:disabled="loading" <Button type="submit" class="w-full" :disabled="loading">
@click="handleLogin"
>
<span v-if="loading">{{ t('common.loading') }}</span> <span v-if="loading">{{ t('common.loading') }}</span>
<span v-else>{{ t('auth.login') }}</span> <span v-else>{{ t('auth.login') }}</span>
</button> </Button>
</form>
<p v-if="error" class="text-sm text-destructive text-center">{{ error }}</p> </CardContent>
</div> </Card>
</div>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useSystemStore } from '@/stores/system' import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -24,6 +25,7 @@ import {
type UpdateOverviewResponse, type UpdateOverviewResponse,
type UpdateStatusResponse, type UpdateStatusResponse,
type UpdateChannel, type UpdateChannel,
type VideoEncoderSelfCheckResponse,
} from '@/api' } from '@/api'
import type { import type {
ExtensionsStatus, ExtensionsStatus,
@@ -31,11 +33,13 @@ import type {
AtxDriverType, AtxDriverType,
ActiveLevel, ActiveLevel,
AtxDevices, AtxDevices,
OtgEndpointBudget,
OtgHidProfile, OtgHidProfile,
OtgHidFunctions, OtgHidFunctions,
} from '@/types/generated' } from '@/types/generated'
import { setLanguage } from '@/i18n' import { setLanguage } from '@/i18n'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { getVideoFormatState } from '@/lib/video-format-support'
import AppLayout from '@/components/AppLayout.vue' import AppLayout from '@/components/AppLayout.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -82,6 +86,7 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
const { t, te, locale } = useI18n() const { t, te, locale } = useI18n()
const route = useRoute()
const systemStore = useSystemStore() const systemStore = useSystemStore()
const configStore = useConfigStore() const configStore = useConfigStore()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -91,6 +96,21 @@ const activeSection = ref('appearance')
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
const loading = ref(false) const loading = ref(false)
const saved = ref(false) const saved = ref(false)
const SETTINGS_SECTION_IDS = new Set([
'appearance',
'account',
'access',
'video',
'hid',
'msd',
'atx',
'environment',
'ext-ttyd',
'ext-rustdesk',
'ext-rtsp',
'ext-remote-access',
'about',
])
// Navigation structure // Navigation structure
const navGroups = computed(() => [ const navGroups = computed(() => [
@@ -134,6 +154,10 @@ function selectSection(id: string) {
mobileMenuOpen.value = false mobileMenuOpen.value = false
} }
function normalizeSettingsSection(value: unknown): string | null {
return typeof value === 'string' && SETTINGS_SECTION_IDS.has(value) ? value : null
}
// Theme // Theme
const theme = ref<'light' | 'dark' | 'system'>('system') const theme = ref<'light' | 'dark' | 'system'>('system')
@@ -304,13 +328,15 @@ const config = ref({
hid_serial_device: '', hid_serial_device: '',
hid_serial_baudrate: 9600, hid_serial_baudrate: 9600,
hid_otg_udc: '', hid_otg_udc: '',
hid_otg_profile: 'full' as OtgHidProfile, hid_otg_profile: 'custom' as OtgHidProfile,
hid_otg_endpoint_budget: 'six' as OtgEndpointBudget,
hid_otg_functions: { hid_otg_functions: {
keyboard: true, keyboard: true,
mouse_relative: true, mouse_relative: true,
mouse_absolute: true, mouse_absolute: true,
consumer: true, consumer: true,
} as OtgHidFunctions, } as OtgHidFunctions,
hid_otg_keyboard_leds: false,
msd_enabled: false, msd_enabled: false,
msd_dir: '', msd_dir: '',
encoder_backend: 'auto', encoder_backend: 'auto',
@@ -323,20 +349,6 @@ const config = ref({
// Tracks whether TURN password is configured on the server // Tracks whether TURN password is configured on the server
const hasTurnPassword = ref(false) const hasTurnPassword = ref(false)
const configLoaded = ref(false)
const devicesLoaded = ref(false)
const hidProfileAligned = ref(false)
const isLowEndpointUdc = computed(() => {
if (config.value.hid_otg_udc) {
return /musb/i.test(config.value.hid_otg_udc)
}
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
})
const showLowEndpointHint = computed(() =>
config.value.hid_backend === 'otg' && isLowEndpointUdc.value
)
type OtgSelfCheckLevel = 'info' | 'warn' | 'error' type OtgSelfCheckLevel = 'info' | 'warn' | 'error'
type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped' type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped'
@@ -539,28 +551,138 @@ async function onRunOtgSelfCheckClick() {
await runOtgSelfCheck() await runOtgSelfCheck()
} }
function alignHidProfileForLowEndpoint() { type VideoEncoderSelfCheckCell = VideoEncoderSelfCheckResponse['rows'][number]['cells'][number]
if (hidProfileAligned.value) return type VideoEncoderSelfCheckRow = VideoEncoderSelfCheckResponse['rows'][number]
if (!configLoaded.value || !devicesLoaded.value) return
if (config.value.hid_backend !== 'otg') { const videoEncoderSelfCheckLoading = ref(false)
hidProfileAligned.value = true const videoEncoderSelfCheckResult = ref<VideoEncoderSelfCheckResponse | null>(null)
return const videoEncoderSelfCheckError = ref('')
const videoEncoderRunButtonPressed = ref(false)
function videoEncoderCell(row: VideoEncoderSelfCheckRow, codecId: string): VideoEncoderSelfCheckCell | undefined {
return row.cells.find(cell => cell.codec_id === codecId)
} }
if (!isLowEndpointUdc.value) {
hidProfileAligned.value = true const currentHardwareEncoderText = computed(() =>
return videoEncoderSelfCheckResult.value?.current_hardware_encoder === 'None'
? t('settings.encoderSelfCheck.none')
: (videoEncoderSelfCheckResult.value?.current_hardware_encoder || t('settings.encoderSelfCheck.none'))
)
function videoEncoderCodecLabel(codecId: string, codecName: string): string {
return codecId === 'h265' ? 'H.265' : codecName
} }
if (config.value.hid_otg_profile === 'full') {
config.value.hid_otg_profile = 'full_no_consumer' as OtgHidProfile function videoEncoderCellClass(ok: boolean | undefined): string {
} else if (config.value.hid_otg_profile === 'full_no_msd') { return ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
config.value.hid_otg_profile = 'full_no_consumer_no_msd' as OtgHidProfile }
function videoEncoderCellSymbol(ok: boolean | undefined): string {
return ok ? '✓' : '✗'
}
function videoEncoderCellTime(cell: VideoEncoderSelfCheckCell | undefined): string {
if (!cell || typeof cell.elapsed_ms !== 'number') return '-'
return `${cell.elapsed_ms}ms`
}
async function runVideoEncoderSelfCheck() {
videoEncoderSelfCheckLoading.value = true
videoEncoderSelfCheckError.value = ''
try {
videoEncoderSelfCheckResult.value = await streamApi.encoderSelfCheck()
} catch (e) {
console.error('Failed to run encoder self-check:', e)
videoEncoderSelfCheckError.value = t('settings.encoderSelfCheck.failed')
} finally {
videoEncoderSelfCheckLoading.value = false
}
}
async function onRunVideoEncoderSelfCheckClick() {
if (!videoEncoderSelfCheckLoading.value) {
videoEncoderRunButtonPressed.value = true
window.setTimeout(() => {
videoEncoderRunButtonPressed.value = false
}, 160)
}
await runVideoEncoderSelfCheck()
}
function defaultOtgEndpointBudgetForUdc(udc?: string): OtgEndpointBudget {
return /musb/i.test(udc || '') ? 'five' as OtgEndpointBudget : 'six' as OtgEndpointBudget
}
function normalizeOtgEndpointBudget(budget: OtgEndpointBudget | undefined, udc?: string): OtgEndpointBudget {
if (!budget || budget === 'auto') {
return defaultOtgEndpointBudgetForUdc(udc)
}
return budget
}
function endpointLimitForBudget(budget: OtgEndpointBudget): number | null {
if (budget === 'unlimited') return null
return budget === 'five' ? 5 : 6
}
const effectiveOtgFunctions = computed(() => ({ ...config.value.hid_otg_functions }))
const otgEndpointLimit = computed(() =>
endpointLimitForBudget(config.value.hid_otg_endpoint_budget)
)
const otgRequiredEndpoints = computed(() => {
if (config.value.hid_backend !== 'otg') return 0
const functions = effectiveOtgFunctions.value
let endpoints = 0
if (functions.keyboard) {
endpoints += 1
if (config.value.hid_otg_keyboard_leds) endpoints += 1
}
if (functions.mouse_relative) endpoints += 1
if (functions.mouse_absolute) endpoints += 1
if (functions.consumer) endpoints += 1
if (config.value.msd_enabled) endpoints += 2
return endpoints
})
const isOtgEndpointBudgetValid = computed(() => {
if (config.value.hid_backend !== 'otg') return true
const limit = otgEndpointLimit.value
return limit === null || otgRequiredEndpoints.value <= limit
})
const otgEndpointUsageText = computed(() => {
const limit = otgEndpointLimit.value
if (limit === null) {
return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value })
}
return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit })
})
const showOtgEndpointBudgetHint = computed(() =>
config.value.hid_backend === 'otg'
)
const isKeyboardLedToggleDisabled = computed(() =>
config.value.hid_backend !== 'otg' || !effectiveOtgFunctions.value.keyboard
)
function describeEndpointBudget(budget: OtgEndpointBudget): string {
switch (budget) {
case 'five':
return '5'
case 'six':
return '6'
case 'unlimited':
return t('settings.otgEndpointBudgetUnlimited')
default:
return '6'
} }
hidProfileAligned.value = true
} }
const isHidFunctionSelectionValid = computed(() => { const isHidFunctionSelectionValid = computed(() => {
if (config.value.hid_backend !== 'otg') return true if (config.value.hid_backend !== 'otg') return true
if (config.value.hid_otg_profile !== 'custom') return true
const f = config.value.hid_otg_functions const f = config.value.hid_otg_functions
return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer) return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer)
}) })
@@ -659,6 +781,21 @@ const availableFormats = computed(() => {
return selectedDevice.value.formats return selectedDevice.value.formats
}) })
const availableFormatOptions = computed(() => {
return availableFormats.value.map(format => {
const state = getVideoFormatState(format.format, 'config', config.value.encoder_backend)
return {
...format,
state,
disabled: state === 'unsupported',
}
})
})
const selectableFormats = computed(() => {
return availableFormatOptions.value.filter(format => !format.disabled)
})
const selectedFormat = computed(() => { const selectedFormat = computed(() => {
if (!selectedDevice.value || !config.value.video_format) return null if (!selectedDevice.value || !config.value.video_format) return null
return selectedDevice.value.formats.find(f => f.format === config.value.video_format) return selectedDevice.value.formats.find(f => f.format === config.value.video_format)
@@ -689,17 +826,22 @@ const availableFps = computed(() => {
return currentRes ? currentRes.fps : [] return currentRes ? currentRes.fps : []
}) })
// Watch for device change to set default format // Keep the selected format aligned with currently selectable formats.
watch(() => config.value.video_device, () => { watch(
if (availableFormats.value.length > 0) { selectableFormats,
const isValid = availableFormats.value.some(f => f.format === config.value.video_format) () => {
if (!isValid) { if (selectableFormats.value.length === 0) {
config.value.video_format = availableFormats.value[0]?.format || ''
}
} else {
config.value.video_format = '' config.value.video_format = ''
return
} }
})
const isValid = selectableFormats.value.some(f => f.format === config.value.video_format)
if (!isValid) {
config.value.video_format = selectableFormats.value[0]?.format || ''
}
},
{ deep: true },
)
// Watch for format change to set default resolution // Watch for format change to set default resolution
watch(() => config.value.video_format, () => { watch(() => config.value.video_format, () => {
@@ -866,26 +1008,9 @@ async function saveConfig() {
// HID config // HID config
if (activeSection.value === 'hid') { if (activeSection.value === 'hid') {
if (!isHidFunctionSelectionValid.value) { if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
return return
} }
let desiredMsdEnabled = config.value.msd_enabled
if (config.value.hid_backend === 'otg') {
if (config.value.hid_otg_profile === 'full') {
desiredMsdEnabled = true
} else if (config.value.hid_otg_profile === 'full_no_msd') {
desiredMsdEnabled = false
} else if (config.value.hid_otg_profile === 'full_no_consumer') {
desiredMsdEnabled = true
} else if (config.value.hid_otg_profile === 'full_no_consumer_no_msd') {
desiredMsdEnabled = false
} else if (
config.value.hid_otg_profile === 'legacy_keyboard'
|| config.value.hid_otg_profile === 'legacy_mouse_relative'
) {
desiredMsdEnabled = false
}
}
const hidUpdate: any = { const hidUpdate: any = {
backend: config.value.hid_backend as any, backend: config.value.hid_backend as any,
ch9329_port: config.value.hid_serial_device || undefined, ch9329_port: config.value.hid_serial_device || undefined,
@@ -900,16 +1025,15 @@ async function saveConfig() {
product: otgProduct.value || 'One-KVM USB Device', product: otgProduct.value || 'One-KVM USB Device',
serial_number: otgSerialNumber.value || undefined, serial_number: otgSerialNumber.value || undefined,
} }
hidUpdate.otg_profile = config.value.hid_otg_profile hidUpdate.otg_profile = 'custom'
hidUpdate.otg_endpoint_budget = config.value.hid_otg_endpoint_budget
hidUpdate.otg_functions = { ...config.value.hid_otg_functions } hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
} }
savePromises.push(configStore.updateHid(hidUpdate)) savePromises.push(configStore.updateHid(hidUpdate))
if (config.value.msd_enabled !== desiredMsdEnabled) {
config.value.msd_enabled = desiredMsdEnabled
}
savePromises.push( savePromises.push(
configStore.updateMsd({ configStore.updateMsd({
enabled: desiredMsdEnabled, enabled: config.value.msd_enabled,
}) })
) )
} }
@@ -954,13 +1078,15 @@ async function loadConfig() {
hid_serial_device: hid.ch9329_port || '', hid_serial_device: hid.ch9329_port || '',
hid_serial_baudrate: hid.ch9329_baudrate || 9600, hid_serial_baudrate: hid.ch9329_baudrate || 9600,
hid_otg_udc: hid.otg_udc || '', hid_otg_udc: hid.otg_udc || '',
hid_otg_profile: (hid.otg_profile || 'full') as OtgHidProfile, hid_otg_profile: 'custom' as OtgHidProfile,
hid_otg_endpoint_budget: normalizeOtgEndpointBudget(hid.otg_endpoint_budget, hid.otg_udc || ''),
hid_otg_functions: { hid_otg_functions: {
keyboard: hid.otg_functions?.keyboard ?? true, keyboard: hid.otg_functions?.keyboard ?? true,
mouse_relative: hid.otg_functions?.mouse_relative ?? true, mouse_relative: hid.otg_functions?.mouse_relative ?? true,
mouse_absolute: hid.otg_functions?.mouse_absolute ?? true, mouse_absolute: hid.otg_functions?.mouse_absolute ?? true,
consumer: hid.otg_functions?.consumer ?? true, consumer: hid.otg_functions?.consumer ?? true,
} as OtgHidFunctions, } as OtgHidFunctions,
hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false,
msd_enabled: msd.enabled || false, msd_enabled: msd.enabled || false,
msd_dir: msd.msd_dir || '', msd_dir: msd.msd_dir || '',
encoder_backend: stream.encoder || 'auto', encoder_backend: stream.encoder || 'auto',
@@ -985,9 +1111,6 @@ async function loadConfig() {
} catch (e) { } catch (e) {
console.error('Failed to load config:', e) console.error('Failed to load config:', e)
} finally {
configLoaded.value = true
alignHidProfileForLowEndpoint()
} }
} }
@@ -996,9 +1119,6 @@ async function loadDevices() {
devices.value = await configApi.listDevices() devices.value = await configApi.listDevices()
} catch (e) { } catch (e) {
console.error('Failed to load devices:', e) console.error('Failed to load devices:', e)
} finally {
devicesLoaded.value = true
alignHidProfileForLowEndpoint()
} }
} }
@@ -1781,17 +1901,23 @@ onMounted(async () => {
if (updateRunning.value) { if (updateRunning.value) {
startUpdatePolling() startUpdatePolling()
} }
await runOtgSelfCheck()
}) })
watch(updateChannel, async () => { watch(updateChannel, async () => {
await loadUpdateOverview() await loadUpdateOverview()
}) })
watch(() => config.value.hid_backend, async () => { watch(() => config.value.hid_backend, () => {
await runOtgSelfCheck() otgSelfCheckResult.value = null
otgSelfCheckError.value = ''
}) })
watch(() => route.query.tab, (tab) => {
const section = normalizeSettingsSection(tab)
if (section && activeSection.value !== section) {
selectSection(section)
}
}, { immediate: true })
</script> </script>
<template> <template>
@@ -1986,7 +2112,14 @@ watch(() => config.value.hid_backend, async () => {
<Label for="video-format">{{ t('settings.videoFormat') }}</Label> <Label for="video-format">{{ t('settings.videoFormat') }}</Label>
<select id="video-format" v-model="config.video_format" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_device"> <select id="video-format" v-model="config.video_format" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_device">
<option value="">{{ t('settings.selectFormat') }}</option> <option value="">{{ t('settings.selectFormat') }}</option>
<option v-for="fmt in availableFormats" :key="fmt.format" :value="fmt.format">{{ fmt.format }} - {{ fmt.description }}</option> <option
v-for="fmt in availableFormatOptions"
:key="fmt.format"
:value="fmt.format"
:disabled="fmt.disabled"
>
{{ fmt.format }} - {{ fmt.description }}{{ fmt.disabled ? t('common.notSupportedYet') : '' }}
</option>
</select> </select>
</div> </div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
@@ -2144,26 +2277,16 @@ watch(() => config.value.hid_backend, async () => {
<p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p> <p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="otg-profile">{{ t('settings.profile') }}</Label> <Label for="otg-endpoint-budget">{{ t('settings.otgEndpointBudget') }}</Label>
<select id="otg-profile" v-model="config.hid_otg_profile" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"> <select id="otg-endpoint-budget" v-model="config.hid_otg_endpoint_budget" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="full">{{ t('settings.otgProfileFull') }}</option> <option value="five">5</option>
<option value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</option> <option value="six">6</option>
<option value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</option> <option value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</option>
<option value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</option>
<option value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</option>
<option value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</option>
<option value="custom">{{ t('settings.otgProfileCustom') }}</option>
</select> </select>
<p class="text-xs text-muted-foreground">{{ otgEndpointUsageText }}</p>
</div> </div>
<div v-if="config.hid_otg_profile === 'custom'" class="space-y-3 rounded-md border border-border/60 p-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="space-y-3 rounded-md border border-border/60 p-3">
<div>
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.keyboard" />
</div>
<Separator />
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label> <Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
@@ -2179,6 +2302,15 @@ watch(() => config.value.hid_backend, async () => {
</div> </div>
<Switch v-model="config.hid_otg_functions.mouse_absolute" /> <Switch v-model="config.hid_otg_functions.mouse_absolute" />
</div> </div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.keyboard" />
</div>
<Separator /> <Separator />
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -2188,6 +2320,15 @@ watch(() => config.value.hid_backend, async () => {
<Switch v-model="config.hid_otg_functions.consumer" /> <Switch v-model="config.hid_otg_functions.consumer" />
</div> </div>
<Separator /> <Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_keyboard_leds" :disabled="isKeyboardLedToggleDisabled" />
</div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label> <Label>{{ t('settings.otgFunctionMsd') }}</Label>
@@ -2196,11 +2337,15 @@ watch(() => config.value.hid_backend, async () => {
<Switch v-model="config.msd_enabled" /> <Switch v-model="config.msd_enabled" />
</div> </div>
</div> </div>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400"> <p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgProfileWarning') }} {{ t('settings.otgProfileWarning') }}
</p> </p>
<p v-if="showLowEndpointHint" class="text-xs text-amber-600 dark:text-amber-400"> <p v-if="showOtgEndpointBudgetHint" class="text-xs text-muted-foreground">
{{ t('settings.otgLowEndpointHint') }} {{ t('settings.otgEndpointBudgetHint') }}
</p>
<p v-if="!isOtgEndpointBudgetValid" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: describeEndpointBudget(config.hid_otg_endpoint_budget) }) }}
</p> </p>
</div> </div>
<Separator class="my-4" /> <Separator class="my-4" />
@@ -2364,6 +2509,86 @@ watch(() => config.value.hid_backend, async () => {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader class="flex flex-row items-start justify-between space-y-0">
<div class="space-y-1.5">
<CardTitle>{{ t('settings.encoderSelfCheck.title') }}</CardTitle>
<CardDescription>{{ t('settings.encoderSelfCheck.desc') }}</CardDescription>
</div>
<Button
variant="outline"
size="sm"
:disabled="videoEncoderSelfCheckLoading"
:class="[
'transition-all duration-150 active:scale-95 active:brightness-95',
videoEncoderRunButtonPressed ? 'scale-95 brightness-95' : ''
]"
@click="onRunVideoEncoderSelfCheckClick"
>
<RefreshCw class="h-4 w-4 mr-2" :class="{ 'animate-spin': videoEncoderSelfCheckLoading }" />
{{ t('settings.encoderSelfCheck.run') }}
</Button>
</CardHeader>
<CardContent class="space-y-3">
<p v-if="videoEncoderSelfCheckError" class="text-xs text-red-600 dark:text-red-400">
{{ videoEncoderSelfCheckError }}
</p>
<template v-if="videoEncoderSelfCheckResult">
<div class="text-sm">
{{ t('settings.encoderSelfCheck.currentHardwareEncoder') }}{{ currentHardwareEncoderText }}
</div>
<div class="rounded-md border bg-card">
<table class="w-full table-fixed text-sm">
<thead>
<tr>
<th class="px-2 py-3 text-left font-medium w-[18%]">{{ t('settings.encoderSelfCheck.resolution') }}</th>
<th
v-for="codec in videoEncoderSelfCheckResult.codecs"
:key="codec.id"
class="px-2 py-3 text-center font-medium w-[20.5%]"
>
{{ videoEncoderCodecLabel(codec.id, codec.name) }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in videoEncoderSelfCheckResult.rows"
:key="row.resolution_id"
>
<td class="px-2 py-3 align-middle">
<div class="font-medium">{{ row.resolution_label }}</div>
</td>
<td
v-for="codec in videoEncoderSelfCheckResult.codecs"
:key="`${row.resolution_id}-${codec.id}`"
class="px-2 py-3 align-middle"
>
<div
class="flex flex-col items-center justify-center gap-1"
:class="videoEncoderCellClass(videoEncoderCell(row, codec.id)?.ok)"
>
<div class="text-lg leading-none font-semibold">
{{ videoEncoderCellSymbol(videoEncoderCell(row, codec.id)?.ok) }}
</div>
<div class="text-[11px] leading-4 text-foreground/70">
{{ videoEncoderCellTime(videoEncoderCell(row, codec.id)) }}
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<p v-else-if="videoEncoderSelfCheckLoading" class="text-xs text-muted-foreground">
{{ t('common.loading') }}
</p>
</CardContent>
</Card>
</div> </div>
<!-- Access Section --> <!-- Access Section -->

View File

@@ -97,7 +97,12 @@ const ch9329Port = ref('')
const ch9329Baudrate = ref(9600) const ch9329Baudrate = ref(9600)
const otgUdc = ref('') const otgUdc = ref('')
const hidOtgProfile = ref('full') const hidOtgProfile = ref('full')
const otgMsdEnabled = ref(true)
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
const otgKeyboardLeds = ref(true)
const otgProfileTouched = ref(false) const otgProfileTouched = ref(false)
const otgEndpointBudgetTouched = ref(false)
const otgKeyboardLedsTouched = ref(false)
const showAdvancedOtg = ref(false) const showAdvancedOtg = ref(false)
// Extension settings // Extension settings
@@ -203,19 +208,67 @@ const availableFps = computed(() => {
return resolution?.fps || [] return resolution?.fps || []
}) })
const isLowEndpointUdc = computed(() => { function defaultOtgEndpointBudgetForUdc(udc?: string): 'five' | 'six' {
if (otgUdc.value) { return /musb/i.test(udc || '') ? 'five' : 'six'
return /musb/i.test(otgUdc.value)
} }
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
function endpointLimitForBudget(budget: 'five' | 'six' | 'unlimited'): number | null {
if (budget === 'unlimited') return null
return budget === 'five' ? 5 : 6
}
const otgRequiredEndpoints = computed(() => {
if (hidBackend.value !== 'otg') return 0
const functions = {
keyboard: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_keyboard',
mouseRelative: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_mouse_relative',
mouseAbsolute: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer',
consumer: hidOtgProfile.value === 'full',
}
let endpoints = 0
if (functions.keyboard) {
endpoints += 1
if (otgKeyboardLeds.value) endpoints += 1
}
if (functions.mouseRelative) endpoints += 1
if (functions.mouseAbsolute) endpoints += 1
if (functions.consumer) endpoints += 1
if (otgMsdEnabled.value) endpoints += 2
return endpoints
}) })
function applyOtgProfileDefault() { const otgProfileHasKeyboard = computed(() =>
if (otgProfileTouched.value) return hidOtgProfile.value === 'full'
|| hidOtgProfile.value === 'full_no_consumer'
|| hidOtgProfile.value === 'legacy_keyboard'
)
const isOtgEndpointBudgetValid = computed(() => {
const limit = endpointLimitForBudget(otgEndpointBudget.value)
return limit === null || otgRequiredEndpoints.value <= limit
})
const otgEndpointUsageText = computed(() => {
const limit = endpointLimitForBudget(otgEndpointBudget.value)
if (limit === null) {
return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value })
}
return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit })
})
function applyOtgDefaults() {
if (hidBackend.value !== 'otg') return if (hidBackend.value !== 'otg') return
const preferred = isLowEndpointUdc.value ? 'full_no_consumer' : 'full'
if (hidOtgProfile.value === preferred) return const recommendedBudget = defaultOtgEndpointBudgetForUdc(otgUdc.value)
hidOtgProfile.value = preferred if (!otgEndpointBudgetTouched.value) {
otgEndpointBudget.value = recommendedBudget
}
if (!otgProfileTouched.value) {
hidOtgProfile.value = 'full_no_consumer'
}
if (!otgKeyboardLedsTouched.value) {
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
}
} }
function onOtgProfileChange(value: unknown) { function onOtgProfileChange(value: unknown) {
@@ -223,6 +276,20 @@ function onOtgProfileChange(value: unknown) {
otgProfileTouched.value = true otgProfileTouched.value = true
} }
function onOtgEndpointBudgetChange(value: unknown) {
otgEndpointBudget.value =
value === 'five' || value === 'six' || value === 'unlimited' ? value : 'six'
otgEndpointBudgetTouched.value = true
if (!otgKeyboardLedsTouched.value) {
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
}
}
function onOtgKeyboardLedsChange(value: boolean) {
otgKeyboardLeds.value = value
otgKeyboardLedsTouched.value = true
}
// Common baud rates for CH9329 // Common baud rates for CH9329
const baudRates = [9600, 19200, 38400, 57600, 115200] const baudRates = [9600, 19200, 38400, 57600, 115200]
@@ -338,16 +405,16 @@ watch(hidBackend, (newBackend) => {
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) { if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
otgUdc.value = devices.value.udc[0]?.name || '' otgUdc.value = devices.value.udc[0]?.name || ''
} }
applyOtgProfileDefault() applyOtgDefaults()
}) })
watch(otgUdc, () => { watch(otgUdc, () => {
applyOtgProfileDefault() applyOtgDefaults()
}) })
watch(showAdvancedOtg, (open) => { watch(showAdvancedOtg, (open) => {
if (open) { if (open) {
applyOtgProfileDefault() applyOtgDefaults()
} }
}) })
@@ -370,7 +437,7 @@ onMounted(async () => {
if (result.udc.length > 0 && result.udc[0]) { if (result.udc.length > 0 && result.udc[0]) {
otgUdc.value = result.udc[0].name otgUdc.value = result.udc[0].name
} }
applyOtgProfileDefault() applyOtgDefaults()
// Auto-select audio device if available (and no video device to trigger watch) // Auto-select audio device if available (and no video device to trigger watch)
if (result.audio.length > 0 && !audioDevice.value) { if (result.audio.length > 0 && !audioDevice.value) {
@@ -461,6 +528,13 @@ function validateStep3(): boolean {
error.value = t('setup.selectUdc') error.value = t('setup.selectUdc')
return false return false
} }
if (hidBackend.value === 'otg' && !isOtgEndpointBudgetValid.value) {
error.value = t('settings.otgEndpointExceeded', {
used: otgRequiredEndpoints.value,
limit: otgEndpointBudget.value === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget.value === 'five' ? '5' : '6',
})
return false
}
return true return true
} }
@@ -523,6 +597,9 @@ async function handleSetup() {
if (hidBackend.value === 'otg' && otgUdc.value) { if (hidBackend.value === 'otg' && otgUdc.value) {
setupData.hid_otg_udc = otgUdc.value setupData.hid_otg_udc = otgUdc.value
setupData.hid_otg_profile = hidOtgProfile.value setupData.hid_otg_profile = hidOtgProfile.value
setupData.hid_otg_endpoint_budget = otgEndpointBudget.value
setupData.hid_otg_keyboard_leds = otgKeyboardLeds.value
setupData.msd_enabled = otgMsdEnabled.value
} }
// Encoder backend setting // Encoder backend setting
@@ -990,16 +1067,47 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem> <SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem>
<SelectItem value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</SelectItem>
<SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem> <SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem>
<SelectItem value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</SelectItem>
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem> <SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem> <SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<p v-if="isLowEndpointUdc" class="text-xs text-amber-600 dark:text-amber-400"> <div class="space-y-2">
{{ t('setup.otgLowEndpointHint') }} <Label for="otgEndpointBudget">{{ t('settings.otgEndpointBudget') }}</Label>
<Select :model-value="otgEndpointBudget" @update:modelValue="onOtgEndpointBudgetChange">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="five">5</SelectItem>
<SelectItem value="six">6</SelectItem>
<SelectItem value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ otgEndpointUsageText }}
</p>
</div>
<div class="flex items-center justify-between rounded-md border border-border/60 p-3">
<div>
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
</div>
<Switch :model-value="otgKeyboardLeds" :disabled="!otgProfileHasKeyboard" @update:model-value="onOtgKeyboardLedsChange" />
</div>
<div class="flex items-center justify-between rounded-md border border-border/60 p-3">
<div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
</div>
<Switch v-model="otgMsdEnabled" />
</div>
<p class="text-xs text-muted-foreground">
{{ t('settings.otgEndpointBudgetHint') }}
</p>
<p v-if="!isOtgEndpointBudgetValid" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: otgEndpointBudget === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget === 'five' ? '5' : '6' }) }}
</p> </p>
</div> </div>
</div> </div>