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]
name = "one-kvm"
version = "0.1.5"
version = "0.1.7"
edition = "2021"
authors = ["SilentWind"]
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
# All codec libraries (libx264, libx265, libopus) are now statically linked
# 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 \
# Core runtime (all platforms) - no codec libs needed
ca-certificates \
@@ -24,7 +25,8 @@ RUN apt-get update && \
# Platform-specific hardware acceleration
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
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 \
apt-get install -y --no-install-recommends \
libdrm2 libva2; \

View File

@@ -12,7 +12,8 @@ ARG TARGETPLATFORM
# Install runtime dependencies in a single layer
# All codec libraries (libx264, libx265, libopus) are now statically linked
# 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 \
# Core runtime (all platforms) - no codec libs needed
ca-certificates \
@@ -24,7 +25,8 @@ RUN apt-get update && \
# Platform-specific hardware acceleration
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
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 \
apt-get install -y --no-install-recommends \
libdrm2 libva2; \

View File

@@ -4,6 +4,68 @@
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.
# Additional options can be passed via environment variables.

View File

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

View File

@@ -126,7 +126,7 @@ EOF
# Create control file
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"
if [ "$DEB_ARCH" = "amd64" ]; then
DEPS="$DEPS, $AMD64_DEPS"

View File

@@ -271,6 +271,7 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
*out_data = nullptr;
*out_len = 0;
*out_keyframe = 0;
bool encoded = false;
av_packet_unref(ctx->dec_pkt);
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) {
ret = avcodec_receive_frame(ctx->dec_ctx, ctx->dec_frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
return 0;
return encoded ? 1 : 0;
}
if (ret < 0) {
set_last_error(make_err("avcodec_receive_frame failed", ret));
@@ -370,33 +371,40 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
return -1;
}
av_packet_unref(ctx->enc_pkt);
ret = avcodec_receive_packet(ctx->enc_ctx, ctx->enc_pkt);
if (ret == AVERROR(EAGAIN)) {
av_frame_unref(ctx->dec_frame);
return 0;
}
if (ret < 0) {
set_last_error(make_err("avcodec_receive_packet failed", ret));
av_frame_unref(ctx->dec_frame);
return -1;
}
if (ctx->enc_pkt->size > 0) {
uint8_t *buf = (uint8_t*)malloc(ctx->enc_pkt->size);
if (!buf) {
set_last_error("malloc for output packet failed");
while (true) {
av_packet_unref(ctx->enc_pkt);
ret = avcodec_receive_packet(ctx->enc_ctx, ctx->enc_pkt);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
break;
}
if (ret < 0) {
set_last_error(make_err("avcodec_receive_packet failed", ret));
av_packet_unref(ctx->enc_pkt);
av_frame_unref(ctx->dec_frame);
return -1;
}
memcpy(buf, ctx->enc_pkt->data, ctx->enc_pkt->size);
*out_data = buf;
*out_len = ctx->enc_pkt->size;
*out_keyframe = (ctx->enc_pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0;
av_packet_unref(ctx->enc_pkt);
av_frame_unref(ctx->dec_frame);
return 1;
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);
if (!buf) {
set_last_error("malloc for output packet failed");
av_packet_unref(ctx->enc_pkt);
av_frame_unref(ctx->dec_frame);
return -1;
}
memcpy(buf, ctx->enc_pkt->data, ctx->enc_pkt->size);
*out_data = buf;
*out_len = ctx->enc_pkt->size;
*out_keyframe = (ctx->enc_pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0;
encoded = true;
}
}
av_frame_unref(ctx->dec_frame);

View File

@@ -15,12 +15,412 @@ use std::{
slice,
};
use super::Priority;
#[cfg(any(windows, target_os = "linux"))]
use crate::common::Driver;
/// Timeout for encoder test in milliseconds
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)]
pub struct EncodeContext {
@@ -185,305 +585,21 @@ impl Encoder {
if !(cfg!(windows) || cfg!(target_os = "linux")) {
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![];
#[cfg(any(windows, target_os = "linux"))]
let codecs = enumerate_candidate_codecs(&ctx);
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {
for codec in codecs {
// Skip if this format already exists in results
if res
.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);
}
if validate_candidate(&codec, &ctx, &yuv) {
res.push(codec);
}
}
} else {
debug!("Failed to generate dummy YUV data");
}
// Add software encoders as fallback
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);
}
}
add_software_fallback(&mut res);
res
}

View File

@@ -86,6 +86,40 @@ impl Default for 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 {
let mut h264: Option<CodecInfo> = None;
let mut h265: Option<CodecInfo> = None;
@@ -148,34 +182,10 @@ impl CodecInfo {
pub fn soft() -> CodecInfos {
CodecInfos {
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 _,
}),
h264: CodecInfo::software(H264),
h265: CodecInfo::software(H265),
vp8: CodecInfo::software(VP8),
vp9: CodecInfo::software(VP9),
av1: None,
}
}
@@ -191,6 +201,23 @@ pub struct 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, ()> {
match serde_json::to_string_pretty(self) {
Ok(s) => Ok(s),

View File

@@ -93,11 +93,7 @@ mod tests {
#[test]
fn test_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);
let _devices = discover_devices();
}
#[test]

View File

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

View File

@@ -3,16 +3,14 @@
//! This module provides health monitoring for audio capture devices, including:
//! - Device connectivity checks
//! - Automatic reconnection on failure
//! - Error tracking and notification
//! - Error tracking
//! - Log throttling to prevent log flooding
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use tracing::{info, warn};
use crate::events::{EventBus, SystemEvent};
use crate::utils::LogThrottler;
/// Audio health status
@@ -58,19 +56,13 @@ impl Default for AudioMonitorConfig {
/// Audio health monitor
///
/// Monitors audio device health and manages error recovery.
/// Publishes WebSocket events when device status changes.
pub struct AudioHealthMonitor {
/// Current health status
status: RwLock<AudioHealthStatus>,
/// Event bus for notifications
events: RwLock<Option<Arc<EventBus>>>,
/// Log throttler to prevent log flooding
throttler: LogThrottler,
/// Configuration
config: AudioMonitorConfig,
/// 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)
@@ -83,10 +75,8 @@ impl AudioHealthMonitor {
let throttle_secs = config.log_throttle_secs;
Self {
status: RwLock::new(AudioHealthStatus::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),
}
@@ -97,24 +87,19 @@ impl AudioHealthMonitor {
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
///
/// This method is called when an audio operation fails. It:
/// 1. Updates the health status
/// 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
///
/// * `device` - The audio device name (if known)
/// * `reason` - Human-readable error description
/// * `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;
// Check if error code changed
@@ -141,44 +126,17 @@ impl AudioHealthMonitor {
error_code: error_code.to_string(),
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
///
/// 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
///
/// * `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();
// Only report recovery if we were in an error state
@@ -191,13 +149,6 @@ impl AudioHealthMonitor {
self.throttler.clear("audio_");
*self.last_error_code.write().await = None;
*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 {
/// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control)
#[default]
#[serde(alias = "full_no_msd")]
Full,
/// Full HID device set without MSD
FullNoMsd,
/// Full HID device set without consumer control
#[serde(alias = "full_no_consumer_no_msd")]
FullNoConsumer,
/// Full HID device set without consumer control and MSD
FullNoConsumerNoMsd,
/// Legacy profile: only keyboard
LegacyKeyboard,
/// Legacy profile: only relative mouse
@@ -163,9 +161,52 @@ pub enum OtgHidProfile {
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)
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct OtgHidFunctions {
pub keyboard: bool,
@@ -214,6 +255,26 @@ impl OtgHidFunctions {
pub fn is_empty(&self) -> bool {
!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 {
@@ -223,12 +284,21 @@ impl Default for OtgHidFunctions {
}
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 {
match self {
Self::Full => OtgHidFunctions::full(),
Self::FullNoMsd => OtgHidFunctions::full(),
Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(),
Self::FullNoConsumerNoMsd => OtgHidFunctions::full_no_consumer(),
Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(),
Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(),
Self::Custom => custom.clone(),
@@ -243,10 +313,6 @@ impl OtgHidProfile {
pub struct HidConfig {
/// HID backend type
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
pub otg_udc: Option<String>,
/// OTG USB device descriptor configuration
@@ -255,9 +321,15 @@ pub struct HidConfig {
/// OTG HID function profile
#[serde(default)]
pub otg_profile: OtgHidProfile,
/// OTG endpoint budget policy
#[serde(default)]
pub otg_endpoint_budget: OtgEndpointBudget,
/// OTG HID function selection (used when profile is Custom)
#[serde(default)]
pub otg_functions: OtgHidFunctions,
/// Enable keyboard LED/status feedback for OTG keyboard
#[serde(default)]
pub otg_keyboard_leds: bool,
/// CH9329 serial port
pub ch9329_port: String,
/// CH9329 baud rate
@@ -270,12 +342,12 @@ impl Default for HidConfig {
fn default() -> Self {
Self {
backend: HidBackend::None,
otg_keyboard: "/dev/hidg0".to_string(),
otg_mouse: "/dev/hidg1".to_string(),
otg_udc: None,
otg_descriptor: OtgDescriptorConfig::default(),
otg_profile: OtgHidProfile::default(),
otg_endpoint_budget: OtgEndpointBudget::default(),
otg_functions: OtgHidFunctions::default(),
otg_keyboard_leds: false,
ch9329_port: "/dev/ttyUSB0".to_string(),
ch9329_baudrate: 9600,
mouse_absolute: true,
@@ -287,6 +359,62 @@ impl HidConfig {
pub fn effective_otg_functions(&self) -> OtgHidFunctions {
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

View File

@@ -7,7 +7,7 @@ pub mod types;
pub use types::{
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
VideoDeviceInfo,
TtydDeviceInfo, VideoDeviceInfo,
};
use tokio::sync::broadcast;
@@ -15,6 +15,39 @@ use tokio::sync::broadcast;
/// Event channel capacity (ring buffer size)
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
///
/// The event bus uses tokio's broadcast channel to distribute events
@@ -43,13 +76,31 @@ const EVENT_CHANNEL_CAPACITY: usize = 256;
/// ```
pub struct EventBus {
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 {
/// Create a new event bus
pub fn new() -> Self {
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
Self { tx }
let tx = make_sender();
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
@@ -57,6 +108,18 @@ impl EventBus {
/// If there are no active subscribers, the event is silently dropped.
/// This is by design - events are fire-and-forget notifications.
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
let _ = self.tx.send(event);
}
@@ -70,6 +133,35 @@ impl EventBus {
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
///
/// Useful for monitoring and debugging.
@@ -110,17 +202,50 @@ mod tests {
assert_eq!(bus.subscriber_count(), 2);
bus.publish(SystemEvent::SystemError {
module: "test".to_string(),
severity: "info".to_string(),
message: "test message".to_string(),
bus.publish(SystemEvent::StreamStateChanged {
state: "ready".to_string(),
device: Some("/dev/video0".to_string()),
});
let event1 = rx1.recv().await.unwrap();
let event2 = rx2.recv().await.unwrap();
assert!(matches!(event1, SystemEvent::SystemError { .. }));
assert!(matches!(event2, SystemEvent::SystemError { .. }));
assert!(matches!(event1, SystemEvent::StreamStateChanged { .. }));
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]
@@ -129,10 +254,9 @@ mod tests {
assert_eq!(bus.subscriber_count(), 0);
// Should not panic when publishing with no subscribers
bus.publish(SystemEvent::SystemError {
module: "test".to_string(),
severity: "info".to_string(),
message: "test".to_string(),
bus.publish(SystemEvent::StreamStateChanged {
state: "ready".to_string(),
device: None,
});
}
}

View File

@@ -2,12 +2,10 @@
//!
//! Defines all event types that can be broadcast through the event bus.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::atx::PowerStatus;
use crate::msd::MsdMode;
use crate::hid::LedState;
// ============================================================================
// Device Info Structures (for system.device_info event)
@@ -45,12 +43,20 @@ pub struct HidDeviceInfo {
pub backend: String,
/// Whether backend is initialized and ready
pub initialized: bool,
/// Whether 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,
/// Device path (e.g., serial port for CH9329)
pub device: Option<String>,
/// Error message if any, None if OK
pub error: Option<String>,
/// Error code if any, None if OK
pub error_code: Option<String>,
}
/// MSD device information
@@ -100,6 +106,15 @@ pub struct AudioDeviceInfo {
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
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientStats {
@@ -275,89 +290,9 @@ pub enum SystemEvent {
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 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)
#[serde(rename = "msd.upload_progress")]
MsdUploadProgress {
@@ -392,132 +327,6 @@ pub enum SystemEvent {
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)
#[serde(rename = "system.device_info")]
DeviceInfo {
@@ -531,6 +340,8 @@ pub enum SystemEvent {
atx: Option<AtxDeviceInfo>,
/// Audio device information (None if audio not enabled)
audio: Option<AudioDeviceInfo>,
/// ttyd status information
ttyd: TtydDeviceInfo,
},
/// WebSocket error notification (for connection-level errors like lag)
@@ -558,30 +369,8 @@ impl SystemEvent {
Self::StreamModeReady { .. } => "stream.mode_ready",
Self::WebRTCIceCandidate { .. } => "webrtc.ice_candidate",
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::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::Error { .. } => "error",
}
@@ -620,14 +409,6 @@ mod tests {
device: Some("/dev/video0".to_string()),
};
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]

View File

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

View File

@@ -2,7 +2,9 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use super::otg::LedState;
use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent};
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
#[async_trait]
pub trait HidBackend: Send + Sync {
/// Get backend name
fn name(&self) -> &'static str;
/// Initialize the backend
async fn init(&self) -> Result<()>;
@@ -104,22 +126,11 @@ pub trait HidBackend: Send + Sync {
/// Shutdown the backend
async fn shutdown(&self) -> Result<()>;
/// Perform backend health check.
///
/// Default implementation assumes backend is healthy.
fn health_check(&self) -> Result<()> {
Ok(())
}
/// Get the current backend runtime snapshot.
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;
/// Check if backend supports absolute mouse positioning
fn supports_absolute_mouse(&self) -> bool {
false
}
/// Get screen resolution (for absolute mouse)
fn screen_resolution(&self) -> Option<(u32, u32)> {
None
}
/// Subscribe to backend runtime changes.
fn subscribe_runtime(&self) -> watch::Receiver<()>;
/// Set screen resolution (for absolute mouse)
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):
//! - 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
//! - Bit 0: Left Ctrl
//! - Bit 1: Left Shift
@@ -38,7 +38,8 @@ use tracing::warn;
use super::types::ConsumerEvent;
use super::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, MouseEventType,
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
MouseEventType,
};
/// 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 = KeyboardModifiers {
@@ -119,7 +126,6 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
event_type,
key,
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();
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)
@@ -242,10 +253,9 @@ mod tests {
match event {
HidChannelEvent::Keyboard(kb) => {
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_shift);
assert!(kb.is_usb_hid);
}
_ => panic!("Expected keyboard event"),
}
@@ -270,7 +280,7 @@ mod tests {
fn test_encode_keyboard() {
let event = KeyboardEvent {
event_type: KeyEventType::Down,
key: 0x04,
key: CanonicalKey::KeyA,
modifiers: KeyboardModifiers {
left_ctrl: true,
left_shift: false,
@@ -281,7 +291,6 @@ mod tests {
right_alt: false,
right_meta: false,
},
is_usb_hid: true,
};
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 consumer;
pub mod datachannel;
pub mod keymap;
pub mod monitor;
pub mod keyboard;
pub mod otg;
pub mod types;
pub mod websocket;
pub use backend::{HidBackend, HidBackendType};
pub use monitor::{HidHealthMonitor, HidHealthStatus, HidMonitorConfig};
pub use backend::{HidBackend, HidBackendRuntimeSnapshot, HidBackendType};
pub use keyboard::CanonicalKey;
pub use otg::LedState;
pub use types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
@@ -33,7 +32,7 @@ pub use types::{
#[derive(Debug, Clone)]
pub struct HidInfo {
/// Backend name
pub name: &'static str,
pub name: String,
/// Whether backend is initialized
pub initialized: bool,
/// Whether absolute mouse positioning is supported
@@ -42,21 +41,103 @@ pub struct HidInfo {
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::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{info, warn};
use crate::error::{AppError, Result};
use crate::events::EventBus;
use crate::otg::OtgService;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
const HID_EVENT_QUEUE_CAPACITY: usize = 64;
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
const HID_HEALTH_CHECK_INTERVAL_MS: u64 = 1000;
#[derive(Debug)]
enum HidEvent {
@@ -75,9 +156,9 @@ pub struct HidController {
/// Backend type (mutable for reload)
backend_type: Arc<RwLock<HidBackendType>>,
/// Event bus for broadcasting state changes (optional)
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
/// Health monitor for error tracking and recovery
monitor: Arc<HidHealthMonitor>,
events: Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
/// Unified HID runtime state.
runtime_state: Arc<RwLock<HidRuntimeState>>,
/// HID event queue sender (non-blocking)
hid_tx: mpsc::Sender<HidEvent>,
/// HID event queue receiver (moved into worker on first start)
@@ -88,10 +169,10 @@ pub struct HidController {
pending_move_flag: Arc<AtomicBool>,
/// Worker task handle
hid_worker: Mutex<Option<JoinHandle<()>>>,
/// Health check task handle
hid_health_checker: Mutex<Option<JoinHandle<()>>>,
/// Backend availability fast flag
backend_available: AtomicBool,
/// Backend runtime subscription task handle
runtime_worker: Mutex<Option<JoinHandle<()>>>,
/// Backend initialization fast flag
backend_available: Arc<AtomicBool>,
}
impl HidController {
@@ -103,24 +184,24 @@ impl HidController {
Self {
otg_service,
backend: Arc::new(RwLock::new(None)),
backend_type: Arc::new(RwLock::new(backend_type)),
events: tokio::sync::RwLock::new(None),
monitor: Arc::new(HidHealthMonitor::with_defaults()),
backend_type: Arc::new(RwLock::new(backend_type.clone())),
events: Arc::new(tokio::sync::RwLock::new(None)),
runtime_state: Arc::new(RwLock::new(HidRuntimeState::from_backend_type(
&backend_type,
))),
hid_tx,
hid_rx: Mutex::new(Some(hid_rx)),
pending_move: Arc::new(parking_lot::Mutex::new(None)),
pending_move_flag: Arc::new(AtomicBool::new(false)),
hid_worker: Mutex::new(None),
hid_health_checker: Mutex::new(None),
backend_available: AtomicBool::new(false),
runtime_worker: Mutex::new(None),
backend_available: Arc::new(AtomicBool::new(false)),
}
}
/// Set event bus for broadcasting state changes
pub async fn set_event_bus(&self, events: Arc<crate::events::EventBus>) {
*self.events.write().await = Some(events.clone());
// Also set event bus on the monitor for health notifications
self.monitor.set_event_bus(events).await;
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
*self.events.write().await = Some(events);
}
/// Initialize the HID backend
@@ -128,16 +209,15 @@ impl HidController {
let backend_type = self.backend_type.read().await.clone();
let backend: Arc<dyn HidBackend> = match backend_type {
HidBackendType::Otg => {
// Request HID functions from OtgService
let otg_service = self
.otg_service
.as_ref()
.ok_or_else(|| AppError::Internal("OtgService not available".into()))?;
info!("Requesting HID functions from OtgService");
let handles = otg_service.enable_hid().await?;
let handles = otg_service.hid_device_paths().await.ok_or_else(|| {
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");
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_available.store(true, Ordering::Release);
self.sync_runtime_state_from_backend().await;
// Start HID event worker (once)
self.start_event_worker().await;
self.start_health_checker().await;
self.restart_runtime_worker().await;
info!("HID backend initialized: {:?}", backend_type);
Ok(())
@@ -172,20 +267,24 @@ impl HidController {
/// Shutdown the HID backend and release resources
pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down HID controller");
self.stop_health_checker().await;
self.stop_runtime_worker().await;
// Close the backend
*self.backend.write().await = None;
self.backend_available.store(false, Ordering::Release);
// If OTG backend, notify OtgService to disable HID
let backend_type = self.backend_type.read().await.clone();
if matches!(backend_type, HidBackendType::Otg) {
if let Some(ref otg_service) = self.otg_service {
info!("Disabling HID functions in OtgService");
otg_service.disable_hid().await?;
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);
let backend_type = self.backend_type.read().await.clone();
let mut shutdown_state = HidRuntimeState::from_backend_type(&backend_type);
if matches!(backend_type, HidBackendType::None) {
shutdown_state.available = false;
} 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");
Ok(())
@@ -241,7 +340,7 @@ impl HidController {
/// Check if backend is available
pub async fn is_available(&self) -> bool {
self.backend.read().await.is_some()
self.backend_available.load(Ordering::Acquire)
}
/// Get backend type
@@ -251,60 +350,29 @@ impl HidController {
/// Get backend info
pub async fn info(&self) -> Option<HidInfo> {
let backend = self.backend.read().await;
backend.as_ref().map(|b| HidInfo {
name: b.name(),
initialized: true,
supports_absolute_mouse: b.supports_absolute_mouse(),
screen_resolution: b.screen_resolution(),
let state = self.runtime_state.read().await.clone();
if !state.available {
return None;
}
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
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
let backend = self.backend.read().await;
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
/// Get current HID runtime state snapshot.
pub async fn snapshot(&self) -> HidRuntimeState {
self.runtime_state.read().await.clone()
}
/// Reload the HID backend with new type
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
info!("Reloading HID backend: {:?}", new_backend_type);
self.backend_available.store(false, Ordering::Release);
self.stop_health_checker().await;
self.stop_runtime_worker().await;
// Shutdown existing backend first
if let Some(backend) = self.backend.write().await.take() {
@@ -329,9 +397,8 @@ impl HidController {
}
};
// Request HID functions from OtgService
match otg_service.enable_hid().await {
Ok(handles) => {
match otg_service.hid_device_paths().await {
Some(handles) => {
// Create OtgBackend from handles
match otg::OtgBackend::from_handles(handles) {
Ok(backend) => {
@@ -343,29 +410,18 @@ impl HidController {
}
Err(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
}
}
}
Err(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
}
}
}
Err(e) => {
warn!("Failed to enable HID in OtgService: {}", e);
None => {
warn!("OTG HID paths are not available");
None
}
}
@@ -403,27 +459,22 @@ impl HidController {
*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() {
info!("HID backend reloaded successfully: {:?}", new_backend_type);
self.backend_available.store(true, Ordering::Release);
self.start_event_worker().await;
self.start_health_checker().await;
// Update backend_type on success
*self.backend_type.write().await = new_backend_type.clone();
// Reset monitor state on successful reload
self.monitor.reset().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;
self.sync_runtime_state_from_backend().await;
self.restart_runtime_worker().await;
Ok(())
} else {
@@ -433,14 +484,14 @@ impl HidController {
// Update backend_type even on failure (to reflect the attempted change)
*self.backend_type.write().await = new_backend_type.clone();
// Publish event with initialized=false
self.publish_event(crate::events::SystemEvent::HidStateChanged {
backend: new_backend_type.name_str().to_string(),
initialized: false,
error: Some("Failed to initialize HID backend".to_string()),
error_code: Some("init_failed".to_string()),
})
.await;
let current = self.runtime_state.read().await.clone();
let error_state = HidRuntimeState::with_error(
&new_backend_type,
&current,
"Failed to initialize HID backend",
"init_failed",
);
self.apply_runtime_state(error_state).await;
Err(AppError::Internal(
"Failed to reload HID backend".to_string(),
@@ -448,11 +499,20 @@ impl HidController {
}
}
/// Publish event to event bus if available
async fn publish_event(&self, event: crate::events::SystemEvent) {
if let Some(events) = self.events.read().await.as_ref() {
events.publish(event);
}
async fn apply_runtime_state(&self, next: HidRuntimeState) {
apply_runtime_state(&self.runtime_state, &self.events, next).await;
}
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) {
@@ -468,8 +528,6 @@ impl HidController {
};
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_flag = self.pending_move_flag.clone();
@@ -481,19 +539,13 @@ impl HidController {
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
if pending_move_flag.swap(false, Ordering::AcqRel) {
let move_event = { pending_move.lock().take() };
if let Some(move_event) = move_event {
process_hid_event(
HidEvent::Mouse(move_event),
&backend,
&monitor,
&backend_type,
)
.await;
process_hid_event(HidEvent::Mouse(move_event), &backend).await;
}
}
}
@@ -502,84 +554,43 @@ impl HidController {
*worker_guard = Some(handle);
}
async fn start_health_checker(&self) {
let mut checker_guard = self.hid_health_checker.lock().await;
if checker_guard.is_some() {
return;
}
async fn restart_runtime_worker(&self) {
self.stop_runtime_worker().await;
let backend = self.backend.clone();
let backend_opt = self.backend.read().await.clone();
let Some(backend) = backend_opt else {
return;
};
let mut runtime_rx = backend.subscribe_runtime();
let runtime_state = self.runtime_state.clone();
let events = self.events.clone();
let backend_available = self.backend_available.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 result =
tokio::task::spawn_blocking(move || active_backend.health_check()).await;
match result {
Ok(Ok(())) => {
if monitor.is_error().await {
monitor.report_recovered(&backend_name).await;
}
}
Ok(Err(AppError::HidError {
backend,
reason,
error_code,
})) => {
monitor
.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;
}
Err(e) => {
monitor
.report_error(
&backend_name,
None,
&format!("HID health check task failed: {}", e),
"health_check_join_failed",
)
.await;
}
if runtime_rx.changed().await.is_err() {
break;
}
apply_backend_runtime_state(
&backend_type,
&runtime_state,
&events,
backend_available.as_ref(),
Some(backend.as_ref()),
)
.await;
}
});
*checker_guard = Some(handle);
*self.runtime_worker.lock().await = Some(handle);
}
async fn stop_health_checker(&self) {
let handle_opt = {
let mut checker_guard = self.hid_health_checker.lock().await;
checker_guard.take()
};
if let Some(handle) = handle_opt {
async fn stop_runtime_worker(&self) {
if let Some(handle) = self.runtime_worker.lock().await.take() {
handle.abort();
let _ = handle.await;
}
}
@@ -622,25 +633,37 @@ impl HidController {
}
}
async fn process_hid_event(
event: HidEvent,
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
monitor: &Arc<HidHealthMonitor>,
async fn apply_backend_runtime_state(
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 = match backend_opt {
Some(b) => b,
None => return,
};
let backend_for_send = backend.clone();
let result = tokio::task::spawn_blocking(move || {
futures::executor::block_on(async move {
match event {
HidEvent::Keyboard(ev) => backend.send_keyboard(ev).await,
HidEvent::Mouse(ev) => backend.send_mouse(ev).await,
HidEvent::Consumer(ev) => backend.send_consumer(ev).await,
HidEvent::Reset => backend.reset().await,
HidEvent::Keyboard(ev) => backend_for_send.send_keyboard(ev).await,
HidEvent::Mouse(ev) => backend_for_send.send_mouse(ev).await,
HidEvent::Consumer(ev) => backend_for_send.send_consumer(ev).await,
HidEvent::Reset => backend_for_send.reset().await,
}
})
})
@@ -652,25 +675,9 @@ async fn process_hid_event(
};
match result {
Ok(_) => {
if monitor.is_error().await {
let backend_type = backend_type.read().await;
monitor.report_recovered(backend_type.name_str()).await;
}
}
Ok(_) => {}
Err(e) => {
if let AppError::HidError {
ref backend,
ref reason,
ref error_code,
} = e
{
if error_code != "eagain_retry" {
monitor
.report_error(backend, None, reason, error_code)
.await;
}
}
warn!("HID event processing failed: {}", e);
}
}
}
@@ -680,3 +687,34 @@ impl Default for HidController {
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
//!
//! This backend uses Linux USB Gadget API to emulate USB HID devices.
//! It creates and manages three HID devices:
//! - hidg0: Keyboard (8-byte reports, with LED feedback)
//! - hidg1: Relative Mouse (4-byte reports)
//! - hidg2: Absolute Mouse (6-byte reports)
//! It opens the HID gadget device nodes created by `OtgService`.
//! Depending on the configured OTG profile, this may include:
//! - hidg0: Keyboard
//! - hidg1: Relative Mouse
//! - hidg2: Absolute Mouse
//! - hidg3: Consumer Control Keyboard
//!
//! Requirements:
//! - USB OTG/Device controller (UDC)
@@ -20,16 +22,20 @@
use async_trait::async_trait;
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsFd;
use std::path::PathBuf;
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 super::backend::HidBackend;
use super::keymap;
use super::backend::{HidBackend, HidBackendRuntimeSnapshot};
use super::types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
};
@@ -46,7 +52,7 @@ enum DeviceType {
}
/// Keyboard LED state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct LedState {
/// Num Lock LED
pub num_lock: bool,
@@ -124,24 +130,36 @@ pub struct OtgBackend {
mouse_abs_dev: Mutex<Option<File>>,
/// Consumer control device file
consumer_dev: Mutex<Option<File>>,
/// Whether keyboard LED/status feedback is enabled.
keyboard_leds_enabled: bool,
/// Current keyboard state
keyboard_state: Mutex<KeyboardReport>,
/// Current mouse button state
mouse_buttons: AtomicU8,
/// 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: parking_lot::RwLock<Option<(u32, u32)>>,
/// 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)
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: parking_lot::Mutex<std::time::Instant>,
/// Error count since last successful operation (for log throttling)
error_count: AtomicU8,
/// Consecutive EAGAIN count (for offline threshold detection)
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)
@@ -153,6 +171,7 @@ impl OtgBackend {
/// 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.
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(());
Ok(Self {
keyboard_path: paths.keyboard,
mouse_rel_path: paths.mouse_relative,
@@ -162,18 +181,59 @@ impl OtgBackend {
mouse_rel_dev: Mutex::new(None),
mouse_abs_dev: Mutex::new(None),
consumer_dev: Mutex::new(None),
keyboard_leds_enabled: paths.keyboard_leds_enabled,
keyboard_state: Mutex::new(KeyboardReport::default()),
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))),
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),
last_error: parking_lot::RwLock::new(None),
last_error_log: parking_lot::Mutex::new(std::time::Instant::now()),
error_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)
fn log_throttled_error(&self, msg: &str) {
let mut last_log = self.last_error_log.lock();
@@ -237,13 +297,16 @@ impl OtgBackend {
*self.udc_name.write() = Some(udc.to_string());
}
/// 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 {
let udc_name = self.udc_name.read();
if let Some(ref udc) = *udc_name {
fn read_udc_configured(udc_name: &parking_lot::RwLock<Option<String>>) -> bool {
let current_udc = udc_name.read().clone().or_else(Self::find_udc);
if let Some(udc) = current_udc {
{
let mut guard = udc_name.write();
if guard.as_ref() != Some(&udc) {
*guard = Some(udc.clone());
}
}
let state_path = format!("/sys/class/udc/{}/state", udc);
match fs::read_to_string(&state_path) {
Ok(content) => {
@@ -253,26 +316,22 @@ impl OtgBackend {
}
Err(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
}
}
} 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 {
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
fn find_udc() -> Option<String> {
let udc_path = PathBuf::from("/sys/class/udc");
@@ -286,11 +345,6 @@ impl OtgBackend {
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
///
/// This method is based on PiKVM's `__ensure_device()` pattern:
@@ -308,12 +362,13 @@ impl OtgBackend {
let path = match path_opt {
Some(p) => p,
None => {
self.online.store(false, Ordering::Relaxed);
return Err(AppError::HidError {
let err = AppError::HidError {
backend: "otg".to_string(),
reason: "Device 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;
}
self.online.store(false, Ordering::Relaxed);
let reason = format!("Device not found: {}", path.display());
self.record_error(reason.clone(), "enoent");
return Err(AppError::HidError {
backend: "otg".to_string(),
reason: format!("Device not found: {}", path.display()),
reason,
error_code: "enoent".to_string(),
});
}
@@ -346,12 +402,16 @@ impl OtgBackend {
}
Err(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);
}
}
}
self.online.store(true, Ordering::Relaxed);
self.mark_online();
Ok(())
}
@@ -372,8 +432,8 @@ impl OtgBackend {
}
/// Convert I/O error to HidError with appropriate error code
fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError {
let error_code = match e.raw_os_error() {
fn io_error_code(e: &std::io::Error) -> &'static str {
match e.raw_os_error() {
Some(32) => "epipe", // EPIPE - broken pipe
Some(108) => "eshutdown", // ESHUTDOWN - transport endpoint shutdown
Some(11) => "eagain", // EAGAIN - resource temporarily unavailable
@@ -382,7 +442,11 @@ impl OtgBackend {
Some(5) => "eio", // EIO - I/O error
Some(2) => "enoent", // ENOENT - no such file or directory
_ => "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 {
backend: "otg".to_string(),
@@ -438,7 +502,7 @@ impl OtgBackend {
let data = report.to_bytes();
match self.write_with_timeout(file, &data) {
Ok(true) => {
self.online.store(true, Ordering::Relaxed);
self.mark_online();
self.reset_error_count();
debug!("Sent keyboard report: {:02X?}", data);
Ok(())
@@ -454,10 +518,13 @@ impl OtgBackend {
match error_code {
Some(108) => {
// ESHUTDOWN - endpoint closed, need to reopen device
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
debug!("Keyboard ESHUTDOWN, closing for recovery");
*dev = None;
self.record_error(
format!("Failed to write keyboard report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write keyboard report",
@@ -469,9 +536,12 @@ impl OtgBackend {
Ok(())
}
_ => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
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(
e,
"Failed to write keyboard report",
@@ -507,7 +577,7 @@ impl OtgBackend {
let data = [buttons, dx as u8, dy as u8, wheel as u8];
match self.write_with_timeout(file, &data) {
Ok(true) => {
self.online.store(true, Ordering::Relaxed);
self.mark_online();
self.reset_error_count();
trace!("Sent relative mouse report: {:02X?}", data);
Ok(())
@@ -521,10 +591,13 @@ impl OtgBackend {
match error_code {
Some(108) => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
debug!("Relative mouse ESHUTDOWN, closing for recovery");
*dev = None;
self.record_error(
format!("Failed to write mouse report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write mouse report",
@@ -535,9 +608,12 @@ impl OtgBackend {
Ok(())
}
_ => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
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(
e,
"Failed to write mouse report",
@@ -580,7 +656,7 @@ impl OtgBackend {
];
match self.write_with_timeout(file, &data) {
Ok(true) => {
self.online.store(true, Ordering::Relaxed);
self.mark_online();
self.reset_error_count();
Ok(())
}
@@ -593,10 +669,13 @@ impl OtgBackend {
match error_code {
Some(108) => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
debug!("Absolute mouse ESHUTDOWN, closing for recovery");
*dev = None;
self.record_error(
format!("Failed to write mouse report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write mouse report",
@@ -607,9 +686,12 @@ impl OtgBackend {
Ok(())
}
_ => {
self.online.store(false, Ordering::Relaxed);
self.eagain_count.store(0, Ordering::Relaxed);
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(
e,
"Failed to write mouse report",
@@ -648,7 +730,7 @@ impl OtgBackend {
// Send release (0x0000)
let release = [0u8, 0u8];
let _ = self.write_with_timeout(file, &release);
self.online.store(true, Ordering::Relaxed);
self.mark_online();
self.reset_error_count();
Ok(())
}
@@ -660,9 +742,12 @@ impl OtgBackend {
let error_code = e.raw_os_error();
match error_code {
Some(108) => {
self.online.store(false, Ordering::Relaxed);
debug!("Consumer control ESHUTDOWN, closing for recovery");
*dev = None;
self.record_error(
format!("Failed to write consumer report: {}", e),
"eshutdown",
);
Err(Self::io_error_to_hid_error(
e,
"Failed to write consumer report",
@@ -673,8 +758,11 @@ impl OtgBackend {
Ok(())
}
_ => {
self.online.store(false, Ordering::Relaxed);
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(
e,
"Failed to write consumer report",
@@ -697,49 +785,204 @@ impl OtgBackend {
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
pub fn led_state(&self) -> LedState {
*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]
impl HidBackend for OtgBackend {
fn name(&self) -> &'static str {
"OTG USB Gadget"
}
async fn init(&self) -> Result<()> {
info!("Initializing OTG HID backend");
// Auto-detect UDC name for state checking
if let Some(udc) = Self::find_udc() {
info!("Auto-detected UDC: {}", udc);
self.set_udc_name(&udc);
// 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() {
info!("Auto-detected UDC: {}", 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)
@@ -812,24 +1055,22 @@ impl HidBackend for OtgBackend {
}
// 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(())
}
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
// Convert JS keycode to USB HID if needed (skip if already USB HID)
let usb_key = if event.is_usb_hid {
event.key
} else {
keymap::js_to_usb(event.key).unwrap_or(event.key)
};
let usb_key = event.key.to_hid_usage();
// Handle modifier keys separately
if keymap::is_modifier_key(usb_key) {
if event.key.is_modifier() {
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 {
KeyEventType::Down => state.modifiers |= bit,
KeyEventType::Up => state.modifiers &= !bit,
@@ -925,6 +1166,8 @@ impl HidBackend for OtgBackend {
}
async fn shutdown(&self) -> Result<()> {
self.stop_runtime_worker();
// Reset before closing
self.reset().await?;
@@ -935,49 +1178,30 @@ impl HidBackend for OtgBackend {
*self.consumer_dev.lock() = None;
// 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");
Ok(())
}
fn health_check(&self) -> Result<()> {
if !self.check_devices_exist() {
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() {
self.online.store(false, Ordering::Relaxed);
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 runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
self.build_runtime_snapshot()
}
fn supports_absolute_mouse(&self) -> bool {
self.mouse_abs_path.as_ref().is_some_and(|p| p.exists())
fn subscribe_runtime(&self) -> watch::Receiver<()> {
self.runtime_notify_tx.subscribe()
}
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
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) {
*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
impl Drop for OtgBackend {
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
// Note: Gadget cleanup is handled by OtgService, not here
*self.keyboard_dev.lock() = None;

View File

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

View File

@@ -7,7 +7,7 @@ use axum_server::tls_rustls::RustlsConfig;
use clap::{Parser, ValueEnum};
use futures::{stream::FuturesUnordered, StreamExt};
use rustls::crypto::{ring, CryptoProvider};
use tokio::sync::broadcast;
use tokio::sync::{broadcast, mpsc};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use one_kvm::atx::AtxController;
@@ -18,7 +18,7 @@ use one_kvm::events::EventBus;
use one_kvm::extensions::ExtensionManager;
use one_kvm::hid::{HidBackendType, HidController};
use one_kvm::msd::MsdController;
use one_kvm::otg::{configfs, OtgService};
use one_kvm::otg::OtgService;
use one_kvm::rtsp::RtspService;
use one_kvm::rustdesk::RustDeskService;
use one_kvm::state::AppState;
@@ -319,32 +319,9 @@ async fn main() -> anyhow::Result<()> {
let otg_service = Arc::new(OtgService::new());
tracing::info!("OTG Service created");
// Pre-enable OTG functions to avoid gadget recreation (prevents kernel crashes)
let will_use_otg_hid = matches!(config.hid.backend, config::HidBackend::Otg);
let will_use_msd = config.msd.enabled;
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);
}
// Reconcile OTG once from the persisted config so controllers only consume its result.
if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await {
tracing::warn!("Failed to apply OTG config: {}", e);
}
// Create HID controller based on config
@@ -576,6 +553,8 @@ async fn main() -> anyhow::Result<()> {
data_dir.clone(),
);
extensions.set_event_bus(events.clone()).await;
// Start RustDesk service if enabled
if let Some(ref service) = rustdesk {
if let Err(e) = service.start().await {
@@ -646,6 +625,8 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Extension health check task started");
}
state.publish_device_info().await;
// Start device info broadcast task
// This monitors state change events and broadcasts DeviceInfo to all clients
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
/// and broadcasts DeviceInfo to all WebSocket clients with debouncing
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
use one_kvm::events::SystemEvent;
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;
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 {
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
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 remaining =
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 {
Ok(rx.recv().await)
Ok(trigger_rx.recv().await)
};
match recv_result {
Ok(Ok(event)) => {
let should_broadcast = matches!(
event,
SystemEvent::StreamStateChanged { .. }
| SystemEvent::StreamConfigApplied { .. }
| SystemEvent::StreamModeReady { .. }
| SystemEvent::HidStateChanged { .. }
| SystemEvent::MsdStateChanged { .. }
| SystemEvent::AtxStateChanged { .. }
| SystemEvent::AudioStateChanged { .. }
);
if should_broadcast {
pending_broadcast = true;
}
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(n))) => {
tracing::warn!("DeviceInfo broadcaster lagged by {} events", n);
Ok(Some(DeviceInfoTrigger::Event)) => {
pending_broadcast = true;
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
tracing::warn!(
"DeviceInfo broadcaster lagged by {} events on topic {}",
count,
topic
);
pending_broadcast = true;
}
Ok(None) => {
tracing::info!("Event bus closed, stopping DeviceInfo broadcaster");
break;
}

View File

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

View File

@@ -3,15 +3,13 @@
//! This module provides health monitoring for MSD operations, including:
//! - ConfigFS operation error tracking
//! - Image mount/unmount error tracking
//! - Error notification
//! - Error state tracking
//! - Log throttling to prevent log flooding
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use tokio::sync::RwLock;
use tracing::{info, warn};
use crate::events::{EventBus, SystemEvent};
use crate::utils::LogThrottler;
/// MSD health status
@@ -46,21 +44,12 @@ impl Default for MsdMonitorConfig {
/// MSD health monitor
///
/// Monitors MSD operation health and manages error notifications.
/// Publishes WebSocket events when operation status changes.
/// Monitors MSD operation health and manages error state.
pub struct MsdHealthMonitor {
/// Current health status
status: RwLock<MsdHealthStatus>,
/// Event bus for notifications
events: RwLock<Option<Arc<EventBus>>>,
/// Log throttler to prevent log flooding
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: AtomicU32,
/// Last error code (for change detection)
@@ -73,10 +62,7 @@ impl MsdHealthMonitor {
let throttle_secs = config.log_throttle_secs;
Self {
status: RwLock::new(MsdHealthStatus::Healthy),
events: RwLock::new(None),
throttler: LogThrottler::with_secs(throttle_secs),
config,
running: AtomicBool::new(false),
error_count: AtomicU32::new(0),
last_error_code: RwLock::new(None),
}
@@ -87,17 +73,12 @@ impl MsdHealthMonitor {
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
///
/// This method is called when an MSD operation fails. It:
/// 1. Updates the health status
/// 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
///
@@ -129,22 +110,12 @@ impl MsdHealthMonitor {
reason: reason.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
///
/// 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) {
let prev_status = self.status.read().await.clone();
@@ -158,11 +129,6 @@ impl MsdHealthMonitor {
self.throttler.clear_all();
*self.last_error_code.write().await = None;
*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::io::Write;
use std::path::Path;
use std::process::Command;
use crate::error::{AppError, Result};
@@ -29,6 +30,42 @@ pub fn is_configfs_available() -> bool {
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)
pub fn find_udc() -> Option<String> {
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,
};
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;
/// HID function type
#[derive(Debug, Clone)]
pub enum HidFunctionType {
/// Keyboard (no LED feedback)
/// Uses 1 endpoint: IN
/// Keyboard
Keyboard,
/// Relative mouse (traditional mouse movement)
/// Uses 1 endpoint: IN
@@ -28,7 +29,7 @@ pub enum HidFunctionType {
}
impl HidFunctionType {
/// Get endpoints required for this function type
/// Get the base endpoint cost for this function type.
pub fn endpoints(&self) -> u8 {
match self {
HidFunctionType::Keyboard => 1,
@@ -59,7 +60,7 @@ impl HidFunctionType {
}
/// Get report length in bytes
pub fn report_length(&self) -> u8 {
pub fn report_length(&self, _keyboard_leds: bool) -> u8 {
match self {
HidFunctionType::Keyboard => 8,
HidFunctionType::MouseRelative => 4,
@@ -69,9 +70,15 @@ impl HidFunctionType {
}
/// Get report descriptor
pub fn report_desc(&self) -> &'static [u8] {
pub fn report_desc(&self, keyboard_leds: bool) -> &'static [u8] {
match self {
HidFunctionType::Keyboard => KEYBOARD,
HidFunctionType::Keyboard => {
if keyboard_leds {
KEYBOARD_WITH_LED
} else {
KEYBOARD
}
}
HidFunctionType::MouseRelative => MOUSE_RELATIVE,
HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE,
HidFunctionType::ConsumerControl => CONSUMER_CONTROL,
@@ -98,15 +105,18 @@ pub struct HidFunction {
func_type: HidFunctionType,
/// Cached function name (avoids repeated allocation)
name: String,
/// Whether keyboard LED/status feedback is enabled.
keyboard_leds: bool,
}
impl HidFunction {
/// Create a keyboard function
pub fn keyboard(instance: u8) -> Self {
pub fn keyboard(instance: u8, keyboard_leds: bool) -> Self {
Self {
instance,
func_type: HidFunctionType::Keyboard,
name: format!("hid.usb{}", instance),
keyboard_leds,
}
}
@@ -116,6 +126,7 @@ impl HidFunction {
instance,
func_type: HidFunctionType::MouseRelative,
name: format!("hid.usb{}", instance),
keyboard_leds: false,
}
}
@@ -125,6 +136,7 @@ impl HidFunction {
instance,
func_type: HidFunctionType::MouseAbsolute,
name: format!("hid.usb{}", instance),
keyboard_leds: false,
}
}
@@ -134,6 +146,7 @@ impl HidFunction {
instance,
func_type: HidFunctionType::ConsumerControl,
name: format!("hid.usb{}", instance),
keyboard_leds: false,
}
}
@@ -181,11 +194,14 @@ impl GadgetFunction for HidFunction {
)?;
write_file(
&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_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!(
"Created HID function: {} at {}",
@@ -232,14 +248,15 @@ mod tests {
assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1);
assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1);
assert_eq!(HidFunctionType::Keyboard.report_length(), 8);
assert_eq!(HidFunctionType::MouseRelative.report_length(), 4);
assert_eq!(HidFunctionType::MouseAbsolute.report_length(), 6);
assert_eq!(HidFunctionType::Keyboard.report_length(false), 8);
assert_eq!(HidFunctionType::Keyboard.report_length(true), 8);
assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4);
assert_eq!(HidFunctionType::MouseAbsolute.report_length(false), 6);
}
#[test]
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.device_path(), PathBuf::from("/dev/hidg0"));

View File

@@ -19,7 +19,7 @@ use crate::error::{AppError, Result};
const REBIND_DELAY_MS: u64 = 300;
/// USB Gadget device descriptor configuration
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GadgetDescriptor {
pub vendor_id: u16,
pub product_id: u16,
@@ -131,8 +131,8 @@ impl OtgGadgetManager {
/// Add keyboard function
/// Returns the expected device path (e.g., /dev/hidg0)
pub fn add_keyboard(&mut self) -> Result<PathBuf> {
let func = HidFunction::keyboard(self.hid_instance);
pub fn add_keyboard(&mut self, keyboard_leds: bool) -> Result<PathBuf> {
let func = HidFunction::keyboard(self.hid_instance, keyboard_leds);
let device_path = func.device_path();
self.add_function(Box::new(func))?;
self.hid_instance += 1;
@@ -245,12 +245,8 @@ impl OtgGadgetManager {
Ok(())
}
/// Bind gadget to UDC
pub fn bind(&mut self) -> Result<()> {
let udc = Self::find_udc().ok_or_else(|| {
AppError::Internal("No USB Device Controller (UDC) found".to_string())
})?;
/// Bind gadget to a specific UDC
pub fn bind(&mut self, udc: &str) -> Result<()> {
// Recreate config symlinks before binding to avoid kernel gadget issues after rebind
if let Err(e) = self.recreate_config_links() {
warn!("Failed to recreate gadget config links before bind: {}", e);
@@ -258,7 +254,7 @@ impl OtgGadgetManager {
info!("Binding gadget to 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));
Ok(())
@@ -504,7 +500,7 @@ mod tests {
let mut manager = OtgGadgetManager::with_config("test", 8);
// Keyboard uses 1 endpoint
let _ = manager.add_keyboard();
let _ = manager.add_keyboard(false);
assert_eq!(manager.endpoint_allocator.used(), 1);
// 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 msd::{MsdFunction, MsdLunConfig};
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
/// Keyboard HID Report Descriptor (no LED output - saves 1 endpoint)
/// Keyboard HID Report Descriptor (no LED output)
/// Report format (8 bytes input):
/// [0] Modifier keys (8 bits)
/// [1] Reserved
@@ -34,6 +34,53 @@ pub const KEYBOARD: &[u8] = &[
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)
/// Report format:
/// [0] Buttons (5 bits) + padding (3 bits)
@@ -155,6 +202,7 @@ mod tests {
#[test]
fn test_report_descriptor_sizes() {
assert!(!KEYBOARD.is_empty());
assert!(!KEYBOARD_WITH_LED.is_empty());
assert!(!MOUSE_RELATIVE.is_empty());
assert!(!MOUSE_ABSOLUTE.is_empty());
assert!(!CONSUMER_CONTROL.is_empty());

View File

@@ -1,39 +1,18 @@
//! OTG Service - unified gadget lifecycle management
//!
//! This module provides centralized management for USB OTG gadget functions.
//! It solves the ownership problem where both HID and MSD need access to the
//! same USB gadget but should be independently configurable.
//!
//! Architecture:
//! ```text
//! ┌─────────────────────────┐
//! │ OtgService │
//! │ ┌───────────────────┐ │
//! │ │ OtgGadgetManager │ │
//! │ └───────────────────┘ │
//! │ ↓ ↓ │
//! │ ┌─────┐ ┌─────┐ │
//! │ │ HID │ │ MSD │ │
//! │ └─────┘ └─────┘ │
//! └─────────────────────────┘
//! ↑ ↑
//! HidController MsdController
//! ```
//! It is the single owner of the USB gadget desired state and reconciles
//! ConfigFS to match that state.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU8, Ordering};
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn};
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
use super::msd::MsdFunction;
use crate::config::{OtgDescriptorConfig, OtgHidFunctions};
use crate::config::{HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions};
use crate::error::{AppError, Result};
/// Bitflags for requested functions (lock-free)
const FLAG_HID: u8 = 0b01;
const FLAG_MSD: u8 = 0b10;
/// HID device paths
#[derive(Debug, Clone, Default)]
pub struct HidDevicePaths {
@@ -41,6 +20,8 @@ pub struct HidDevicePaths {
pub mouse_relative: Option<PathBuf>,
pub mouse_absolute: Option<PathBuf>,
pub consumer: Option<PathBuf>,
pub udc: Option<String>,
pub keyboard_leds_enabled: bool,
}
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
#[derive(Debug, Clone, Default)]
pub struct OtgServiceState {
@@ -71,19 +105,23 @@ pub struct OtgServiceState {
pub hid_enabled: bool,
/// Whether MSD function is enabled
pub msd_enabled: bool,
/// Bound UDC name
pub configured_udc: Option<String>,
/// HID device paths (set after gadget setup)
pub hid_paths: Option<HidDevicePaths>,
/// HID function selection (set after gadget setup)
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
pub error: Option<String>,
}
/// 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 {
/// The underlying gadget manager
manager: Mutex<Option<OtgGadgetManager>>,
@@ -91,12 +129,8 @@ pub struct OtgService {
state: RwLock<OtgServiceState>,
/// MSD function handle (for runtime LUN configuration)
msd_function: RwLock<Option<MsdFunction>>,
/// Requested functions flags (atomic, lock-free read/write)
requested_flags: AtomicU8,
/// Requested HID function set
hid_functions: RwLock<OtgHidFunctions>,
/// Current descriptor configuration
current_descriptor: RwLock<GadgetDescriptor>,
/// Desired OTG state
desired: RwLock<OtgDesiredState>,
}
impl OtgService {
@@ -106,41 +140,7 @@ impl OtgService {
manager: Mutex::new(None),
state: RwLock::new(OtgServiceState::default()),
msd_function: RwLock::new(None),
requested_flags: AtomicU8::new(0),
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);
desired: RwLock::new(OtgDesiredState::default()),
}
}
@@ -180,258 +180,119 @@ impl OtgService {
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)
pub async fn msd_function(&self) -> Option<MsdFunction> {
self.msd_function.read().await.clone()
}
/// Enable HID functions
///
/// This will create the gadget if not already created, add HID functions,
/// and bind the gadget to UDC.
pub async fn enable_hid(&self) -> Result<HidDevicePaths> {
info!("Enabling HID functions via OtgService");
// Mark HID as requested (lock-free)
self.set_hid_requested(true);
// Check if already enabled and function set unchanged
let requested_functions = self.hid_functions.read().await.clone();
{
let state = self.state.read().await;
if state.hid_enabled && state.hid_functions.as_ref() == Some(&requested_functions) {
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.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()))
/// Apply desired OTG state derived from the current application config.
pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> {
let desired = OtgDesiredState::from_config(hid, msd)?;
self.apply_desired_state(desired).await
}
/// Disable HID functions
///
/// 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
/// Apply a fully materialized desired OTG state.
pub async fn apply_desired_state(&self, desired: OtgDesiredState) -> Result<()> {
{
let state = self.state.read().await;
if !state.hid_enabled {
info!("HID already disabled");
return Ok(());
}
let mut current = self.desired.write().await;
*current = desired;
}
// Recreate gadget without HID (or destroy if MSD also disabled)
self.recreate_gadget().await
self.reconcile_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()
};
async fn reconcile_gadget(&self) -> Result<()> {
let desired = self.desired.read().await.clone();
info!(
"Recreating gadget with: HID={}, MSD={}",
hid_requested, msd_requested
"Reconciling OTG gadget: HID={}, MSD={}, UDC={:?}",
desired.hid_enabled(),
desired.msd_enabled,
desired.udc
);
// Check if gadget already matches requested state
{
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
&& state.hid_enabled == hid_requested
&& state.msd_enabled == msd_requested
&& functions_match
&& state.hid_enabled == desired.hid_enabled()
&& state.msd_enabled == desired.msd_enabled
&& 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(());
}
}
// Cleanup existing gadget
{
let mut manager = self.manager.lock().await;
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() {
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.configured_udc = None;
state.hid_paths = None;
state.hid_functions = None;
state.keyboard_leds_enabled = false;
state.max_endpoints = super::endpoint::DEFAULT_MAX_ENDPOINTS;
state.descriptor = None;
state.error = None;
}
// If nothing requested, we're done
if !hid_requested && !msd_requested {
info!("No functions requested, gadget destroyed");
if !desired.hid_enabled() && !desired.msd_enabled {
info!("OTG desired state is empty, gadget removed");
return Ok(());
}
// Check if OTG is available
if !Self::is_available() {
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 let Err(e) = super::configfs::ensure_libcomposite_loaded() {
warn!("Failed to ensure libcomposite is available: {}", e);
}
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));
}
// Create new gadget manager with current descriptor
let descriptor = self.current_descriptor.read().await.clone();
let udc = desired.udc.clone().ok_or_else(|| {
let error = "OTG not available: no UDC found".to_string();
AppError::Internal(error)
})?;
let mut manager = OtgGadgetManager::with_descriptor(
super::configfs::DEFAULT_GADGET_NAME,
super::endpoint::DEFAULT_MAX_ENDPOINTS,
descriptor,
desired.max_endpoints,
desired.descriptor.clone(),
);
let mut hid_paths = None;
// Add HID functions if requested
if hid_requested {
if hid_functions.is_empty() {
let error = "HID functions set is empty".to_string();
let mut state = self.state.write().await;
state.error = Some(error.clone());
return Err(AppError::BadRequest(error));
}
let mut paths = HidDevicePaths::default();
if let Some(hid_functions) = desired.hid_functions.clone() {
let mut paths = HidDevicePaths {
udc: Some(udc.clone()),
keyboard_leds_enabled: desired.keyboard_leds,
..Default::default()
};
if hid_functions.keyboard {
match manager.add_keyboard() {
match manager.add_keyboard(desired.keyboard_leds) {
Ok(kb) => paths.keyboard = Some(kb),
Err(e) => {
let error = format!("Failed to add keyboard HID function: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
}
@@ -442,8 +303,7 @@ impl OtgService {
Ok(rel) => paths.mouse_relative = Some(rel),
Err(e) => {
let error = format!("Failed to add relative mouse HID function: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
}
@@ -454,8 +314,7 @@ impl OtgService {
Ok(abs) => paths.mouse_absolute = Some(abs),
Err(e) => {
let error = format!("Failed to add absolute mouse HID function: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
}
@@ -466,8 +325,7 @@ impl OtgService {
Ok(consumer) => paths.consumer = Some(consumer),
Err(e) => {
let error = format!("Failed to add consumer HID function: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
}
@@ -477,8 +335,7 @@ impl OtgService {
debug!("HID functions added to gadget");
}
// Add MSD function if requested
let msd_func = if msd_requested {
let msd_func = if desired.msd_enabled {
match manager.add_msd() {
Ok(func) => {
debug!("MSD function added to gadget");
@@ -486,8 +343,7 @@ impl OtgService {
}
Err(e) => {
let error = format!("Failed to add MSD function: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
}
@@ -495,25 +351,19 @@ impl OtgService {
None
};
// Setup gadget
if let Err(e) = manager.setup() {
let error = format!("Failed to setup gadget: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
self.state.write().await.error = Some(error.clone());
return Err(AppError::Internal(error));
}
// Bind to UDC
if let Err(e) = manager.bind() {
let error = format!("Failed to bind gadget to UDC: {}", e);
let mut state = self.state.write().await;
state.error = Some(error.clone());
// Cleanup on failure
if let Err(e) = manager.bind(&udc) {
let error = format!("Failed to bind gadget to UDC {}: {}", udc, e);
self.state.write().await.error = Some(error.clone());
let _ = manager.cleanup();
return Err(AppError::Internal(error));
}
// Wait for HID devices to appear
if let Some(ref paths) = hid_paths {
let device_paths = paths.existing_paths();
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.msd_function.write().await = msd_func;
}
*self.manager.lock().await = Some(manager);
*self.msd_function.write().await = msd_func;
{
let mut state = self.state.write().await;
state.gadget_active = true;
state.hid_enabled = hid_requested;
state.msd_enabled = msd_requested;
state.hid_enabled = desired.hid_enabled();
state.msd_enabled = desired.msd_enabled;
state.configured_udc = Some(udc);
state.hid_paths = hid_paths;
state.hid_functions = if hid_requested {
Some(hid_functions)
} else {
None
};
state.hid_functions = desired.hid_functions;
state.keyboard_leds_enabled = desired.keyboard_leds;
state.max_endpoints = desired.max_endpoints;
state.descriptor = Some(desired.descriptor);
state.error = None;
}
info!("Gadget created successfully");
info!("OTG gadget reconciled successfully");
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
pub async fn shutdown(&self) -> Result<()> {
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;
if let Some(mut m) = manager.take() {
if let Err(e) = m.cleanup() {
@@ -625,7 +408,6 @@ impl OtgService {
}
}
// Clear state
*self.msd_function.write().await = None;
{
let mut state = self.state.write().await;
@@ -645,11 +427,26 @@ impl Default for OtgService {
impl Drop for OtgService {
fn drop(&mut self) {
// Gadget cleanup is handled by OtgGadgetManager's Drop
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)]
mod tests {
use super::*;
@@ -657,8 +454,7 @@ mod tests {
#[test]
fn test_service_creation() {
let _service = OtgService::new();
// Just test that creation doesn't panic
let _ = OtgService::is_available(); // Depends on environment
let _ = OtgService::is_available();
}
#[tokio::test]

View File

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

View File

@@ -5,8 +5,8 @@
use super::protocol::hbb::message::key_event as ke_union;
use super::protocol::{ControlKey, KeyEvent, MouseEvent};
use crate::hid::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent as OneKvmMouseEvent,
MouseEventType,
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
MouseEvent as OneKvmMouseEvent, MouseEventType,
};
use protobuf::Enum;
@@ -217,11 +217,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
// Handle control keys
if let Some(ke_union::Union::ControlKey(ck)) = &event.union {
if let Some(key) = control_key_to_hid(ck.value()) {
let key = CanonicalKey::from_hid_usage(key)?;
return Some(KeyboardEvent {
event_type,
key,
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 {
// chr contains USB HID scancode on Windows, X11 keycode on Linux
if let Some(key) = keycode_to_hid(*chr) {
let key = CanonicalKey::from_hid_usage(key)?;
return Some(KeyboardEvent {
event_type,
key,
modifiers,
is_usb_hid: true, // Already converted to USB HID code
});
}
}
@@ -608,6 +608,6 @@ mod tests {
let kb_event = result.unwrap();
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 tokio::sync::{broadcast, RwLock};
use tokio::sync::{broadcast, watch, RwLock};
use crate::atx::AtxController;
use crate::audio::AudioController;
@@ -7,9 +7,9 @@ use crate::auth::{SessionStore, UserStore};
use crate::config::ConfigStore;
use crate::events::{
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
VideoDeviceInfo,
TtydDeviceInfo, VideoDeviceInfo,
};
use crate::extensions::ExtensionManager;
use crate::extensions::{ExtensionId, ExtensionManager};
use crate::hid::HidController;
use crate::msd::MsdController;
use crate::otg::OtgService;
@@ -58,6 +58,8 @@ pub struct AppState {
pub extensions: Arc<ExtensionManager>,
/// Event bus for real-time notifications
pub events: Arc<EventBus>,
/// Latest device info snapshot for WebSocket clients
device_info_tx: watch::Sender<Option<SystemEvent>>,
/// Online update service
pub update: Arc<UpdateService>,
/// Shutdown signal sender
@@ -89,6 +91,8 @@ impl AppState {
shutdown_tx: broadcast::Sender<()>,
data_dir: std::path::PathBuf,
) -> Arc<Self> {
let (device_info_tx, _device_info_rx) = watch::channel(None);
Arc::new(Self {
config,
sessions,
@@ -103,6 +107,7 @@ impl AppState {
rtsp: Arc::new(RwLock::new(rtsp)),
extensions,
events,
device_info_tx,
update,
shutdown_tx,
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
@@ -120,6 +125,11 @@ impl AppState {
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)
pub async fn remember_revoked_sessions(&self, session_ids: Vec<String>) {
if session_ids.is_empty() {
@@ -147,12 +157,13 @@ impl AppState {
/// Uses tokio::join! to collect all device info in parallel for better performance.
pub async fn get_device_info(&self) -> SystemEvent {
// 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_hid_info(),
self.collect_msd_info(),
self.collect_atx_info(),
self.collect_audio_info(),
self.collect_ttyd_info(),
);
SystemEvent::DeviceInfo {
@@ -161,13 +172,14 @@ impl AppState {
msd,
atx,
audio,
ttyd,
}
}
/// Publish DeviceInfo event to all connected WebSocket clients
pub async fn publish_device_info(&self) {
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
@@ -178,32 +190,19 @@ impl AppState {
/// Collect HID device information
async fn collect_hid_info(&self) -> HidDeviceInfo {
let info = self.hid.info().await;
let backend_type = self.hid.backend_type().await;
let state = self.hid.snapshot().await;
match info {
Some(hid_info) => HidDeviceInfo {
available: true,
backend: hid_info.name.to_string(),
initialized: hid_info.initialized,
supports_absolute_mouse: hid_info.supports_absolute_mouse,
device: match backend_type {
crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()),
_ => None,
},
error: None,
},
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()),
},
HidDeviceInfo {
available: state.available,
backend: state.backend,
initialized: state.initialized,
online: state.online,
supports_absolute_mouse: state.supports_absolute_mouse,
keyboard_leds_enabled: state.keyboard_leds_enabled,
led_state: state.led_state,
device: state.device,
error: state.error,
error_code: state.error_code,
}
}
@@ -213,6 +212,7 @@ impl AppState {
let msd = msd_guard.as_ref()?;
let state = msd.state().await;
let error = msd.monitor().error_message().await;
Some(MsdDeviceInfo {
available: state.available,
mode: match state.mode {
@@ -223,7 +223,7 @@ impl AppState {
.to_string(),
connected: state.connected,
image_id: state.current_image.map(|img| img.id),
error: None,
error,
})
}
@@ -266,4 +266,14 @@ impl AppState {
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
//!
//! - `MjpegStreamer` - High-level MJPEG streaming manager
//! - `MjpegStreamHandler` - HTTP multipart MJPEG video streaming
//! - `WsHidHandler` - WebSocket HID input handler
pub mod mjpeg;
pub mod mjpeg_streamer;
pub mod ws_hid;
pub use mjpeg::{ClientGuard, MjpegStreamHandler};
pub use mjpeg_streamer::{
MjpegStreamer, MjpegStreamerConfig, MjpegStreamerState, MjpegStreamerStats,
};
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::CodecInfo;
use super::detect_best_codec_for_format;
use super::registry::EncoderBackend;
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
use crate::error::{AppError, Result};
use crate::video::format::{PixelFormat, Resolution};
@@ -69,21 +71,17 @@ impl std::fmt::Display for H264EncoderType {
}
/// Map codec name to encoder type
fn codec_name_to_type(name: &str) -> H264EncoderType {
if name.contains("nvenc") {
H264EncoderType::Nvenc
} else if name.contains("qsv") {
H264EncoderType::Qsv
} else if name.contains("amf") {
H264EncoderType::Amf
} else if name.contains("vaapi") {
H264EncoderType::Vaapi
} else if name.contains("rkmpp") {
H264EncoderType::Rkmpp
} else if name.contains("v4l2m2m") {
H264EncoderType::V4l2M2m
} else {
H264EncoderType::Software
impl From<EncoderBackend> for H264EncoderType {
fn from(backend: EncoderBackend) -> Self {
match backend {
EncoderBackend::Nvenc => H264EncoderType::Nvenc,
EncoderBackend::Qsv => H264EncoderType::Qsv,
EncoderBackend::Amf => H264EncoderType::Amf,
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
EncoderBackend::Software => H264EncoderType::Software,
}
}
}
@@ -215,21 +213,15 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
pub fn detect_best_encoder(width: u32, height: u32) -> (H264EncoderType, Option<String>) {
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");
return (H264EncoderType::None, None);
(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)
}
/// Encoded frame from hwcodec (cloned for ownership)
@@ -252,9 +244,6 @@ pub struct H264Encoder {
codec_name: String,
/// Frame counter
frame_count: u64,
/// YUV420P buffer for input (reserved for future use)
#[allow(dead_code)]
yuv_buffer: Vec<u8>,
/// Required YUV buffer length from hwcodec
yuv_length: i32,
}
@@ -321,7 +310,7 @@ impl H264Encoder {
})?;
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!(
"H.264 encoder created: {} (type: {}, buffer_length: {}, input_format: {:?})",
@@ -334,7 +323,6 @@ impl H264Encoder {
encoder_type,
codec_name: codec_name.to_string(),
frame_count: 0,
yuv_buffer: vec![0u8; yuv_length as usize],
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::CodecInfo;
use super::detect_best_codec_for_format;
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
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>) {
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)
// Hardware priority: NVENC > QSV > AMF > VAAPI > RKMPP > V4L2 M2M > Software
let codec = encoders
.iter()
.find(|e| !e.name.contains("libx265"))
.or_else(|| encoders.first())
.unwrap();
let encoder_type = if codec.name.contains("nvenc") {
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
if let Some((encoder_type, codec_name)) =
detect_best_codec_for_format(&encoders, DataFormat::H265, |codec| {
!codec.name.contains("libx265")
})
{
info!("Selected H.265 encoder: {} ({})", codec_name, encoder_type);
(encoder_type, Some(codec_name))
} else {
H265EncoderType::Software // Default to software for unknown
};
info!("Selected H.265 encoder: {} ({})", codec.name, encoder_type);
(encoder_type, Some(codec.name.clone()))
warn!("No H.265 encoders available");
(H265EncoderType::None, None)
}
}
/// Check if H265 hardware encoding is available
pub fn is_h265_available() -> bool {
let registry = EncoderRegistry::global();
registry.is_format_available(VideoEncoderType::H265, true)
registry.is_codec_available(VideoEncoderType::H265)
}
/// Encoded frame from hwcodec (cloned for ownership)
@@ -268,7 +251,7 @@ pub struct HwEncodeFrame {
pub key: i32,
}
/// H.265 encoder using hwcodec (hardware only)
/// H.265 encoder using hwcodec
pub struct H265Encoder {
/// hwcodec encoder instance
inner: HwEncoder,

View File

@@ -3,17 +3,21 @@
//! This module provides video encoding capabilities including:
//! - JPEG encoding for raw frames (YUYV, NV12, etc.)
//! - H264 encoding (hardware + software)
//! - H265 encoding (hardware only)
//! - VP8 encoding (hardware only - VAAPI)
//! - VP9 encoding (hardware only - VAAPI)
//! - H265 encoding (hardware + software)
//! - VP8 encoding (hardware + software)
//! - VP9 encoding (hardware + software)
//! - WebRTC video codec abstraction
//! - Encoder registry for automatic detection
use hwcodec::common::DataFormat;
use hwcodec::ffmpeg_ram::CodecInfo;
pub mod codec;
pub mod h264;
pub mod h265;
pub mod jpeg;
pub mod registry;
pub mod self_check;
pub mod traits;
pub mod vp8;
pub mod vp9;
@@ -28,18 +32,53 @@ pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, Vid
// Encoder registry
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
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
// H265 encoder (hardware only)
// H265 encoder
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
// VP8 encoder (hardware only)
// VP8 encoder
pub use vp8::{VP8Config, VP8Encoder, VP8EncoderType, VP8InputFormat};
// VP9 encoder (hardware only)
// VP9 encoder
pub use vp9::{VP9Config, VP9Encoder, VP9EncoderType, VP9InputFormat};
// JPEG encoder
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::sync::OnceLock;
use std::time::Duration;
use tracing::{debug, info, warn};
use hwcodec::common::{DataFormat, Quality, RateControl};
@@ -28,6 +29,10 @@ pub enum VideoEncoderType {
}
impl VideoEncoderType {
pub const fn ordered() -> [Self; 4] {
[Self::H264, Self::H265, Self::VP8, Self::VP9]
}
/// Convert to hwcodec DataFormat
pub fn to_data_format(&self) -> DataFormat {
match self {
@@ -68,17 +73,6 @@ impl VideoEncoderType {
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 {
@@ -210,14 +204,84 @@ pub struct 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
///
/// 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 {
static INSTANCE: OnceLock<EncoderRegistry> = OnceLock::new();
INSTANCE.get_or_init(|| {
let mut registry = EncoderRegistry::new();
registry.detect_encoders(1920, 1080);
registry.detect_encoders(1280, 720);
registry
})
}
@@ -257,32 +321,11 @@ impl EncoderRegistry {
};
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);
let (tx, rx) = mpsc::channel();
let ctx_clone = ctx.clone();
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!("Encoder detection timeout: {}ms", DETECT_TIMEOUT_MS);
let all_encoders = Self::detect_encoders_with_timeout(
ctx.clone(),
Duration::from_millis(DETECT_TIMEOUT_MS),
);
info!("Found {} encoders from hwcodec", all_encoders.len());
@@ -305,32 +348,7 @@ impl EncoderRegistry {
encoders.sort_by_key(|e| e.priority);
}
// Register software encoders as fallback
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
);
}
self.register_software_fallbacks();
// Log summary
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
pub fn encoders_for_format(&self, format: VideoEncoderType) -> &[AvailableEncoder] {
self.encoders
@@ -405,31 +427,17 @@ impl EncoderRegistry {
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
///
/// 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> {
let mut formats = Vec::new();
// H264 - supports software fallback
if self.is_format_available(VideoEncoderType::H264, false) {
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
VideoEncoderType::ordered()
.into_iter()
.filter(|format| self.is_codec_available(*format))
.collect()
}
/// Get detection resolution
@@ -534,11 +542,16 @@ mod tests {
}
#[test]
fn test_hardware_only_requirement() {
assert!(!VideoEncoderType::H264.hardware_only());
assert!(VideoEncoderType::H265.hardware_only());
assert!(VideoEncoderType::VP8.hardware_only());
assert!(VideoEncoderType::VP9.hardware_only());
fn test_codec_ordering() {
assert_eq!(
VideoEncoderType::ordered(),
[
VideoEncoderType::H264,
VideoEncoderType::H265,
VideoEncoderType::VP8,
VideoEncoderType::VP9,
]
);
}
#[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::CodecInfo;
use super::detect_best_codec_for_format;
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
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>) {
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)
let codec = encoders
.iter()
.find(|e| e.name.contains("vaapi"))
.or_else(|| encoders.first())
.unwrap();
let encoder_type = if codec.name.contains("vaapi") {
VP8EncoderType::Vaapi
if let Some((encoder_type, codec_name)) =
detect_best_codec_for_format(&encoders, DataFormat::VP8, |codec| {
codec.name.contains("vaapi")
})
{
info!("Selected VP8 encoder: {} ({})", codec_name, encoder_type);
(encoder_type, Some(codec_name))
} else {
VP8EncoderType::Software // Default to software for unknown
};
info!("Selected VP8 encoder: {} ({})", codec.name, encoder_type);
(encoder_type, Some(codec.name.clone()))
warn!("No VP8 encoders available");
(VP8EncoderType::None, None)
}
}
/// Check if VP8 hardware encoding is available
pub fn is_vp8_available() -> bool {
let registry = EncoderRegistry::global();
registry.is_format_available(VideoEncoderType::VP8, true)
registry.is_codec_available(VideoEncoderType::VP8)
}
/// Encoded frame from hwcodec (cloned for ownership)
@@ -192,7 +185,7 @@ pub struct HwEncodeFrame {
pub key: i32,
}
/// VP8 encoder using hwcodec (hardware only - VAAPI)
/// VP8 encoder using hwcodec
pub struct VP8Encoder {
/// hwcodec encoder instance
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::CodecInfo;
use super::detect_best_codec_for_format;
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
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>) {
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)
let codec = encoders
.iter()
.find(|e| e.name.contains("vaapi"))
.or_else(|| encoders.first())
.unwrap();
let encoder_type = if codec.name.contains("vaapi") {
VP9EncoderType::Vaapi
if let Some((encoder_type, codec_name)) =
detect_best_codec_for_format(&encoders, DataFormat::VP9, |codec| {
codec.name.contains("vaapi")
})
{
info!("Selected VP9 encoder: {} ({})", codec_name, encoder_type);
(encoder_type, Some(codec_name))
} else {
VP9EncoderType::Software // Default to software for unknown
};
info!("Selected VP9 encoder: {} ({})", codec.name, encoder_type);
(encoder_type, Some(codec.name.clone()))
warn!("No VP9 encoders available");
(VP9EncoderType::None, None)
}
}
/// Check if VP9 hardware encoding is available
pub fn is_vp9_available() -> bool {
let registry = EncoderRegistry::global();
registry.is_format_available(VideoEncoderType::VP9, true)
registry.is_codec_available(VideoEncoderType::VP9)
}
/// Encoded frame from hwcodec (cloned for ownership)
@@ -192,7 +185,7 @@ pub struct HwEncodeFrame {
pub key: i32,
}
/// VP9 encoder using hwcodec (hardware only - VAAPI)
/// VP9 encoder using hwcodec
pub struct VP9Encoder {
/// hwcodec encoder instance
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.
pub mod capture;
pub mod codec_constraints;
pub mod convert;
pub mod decoder;
@@ -10,25 +9,18 @@ pub mod device;
pub mod encoder;
pub mod format;
pub mod frame;
pub mod h264_pipeline;
pub mod shared_video_pipeline;
pub mod stream_manager;
pub mod streamer;
pub mod v4l2r_capture;
pub mod video_session;
pub use capture::VideoCapturer;
pub use convert::{PixelConverter, Yuv420pBuffer};
pub use device::{VideoDevice, VideoDeviceInfo};
pub use encoder::{H264Encoder, H264EncoderType, JpegEncoder};
pub use format::PixelFormat;
pub use frame::VideoFrame;
pub use h264_pipeline::{H264Pipeline, H264PipelineBuilder, H264PipelineConfig};
pub use shared_video_pipeline::{
EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
};
pub use stream_manager::VideoStreamManager;
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
//! │ └── Streamer ──► MjpegStreamHandler
//! │ (Future: MjpegStreamer with WsAudio/WsHid)
//! │
//! └── WebRTC Mode
//! └── WebRtcStreamer ──► H264SessionManager
@@ -211,21 +210,7 @@ impl VideoStreamManager {
}
}
// Configure WebRTC capture source after initialization
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;
}
self.sync_webrtc_capture_source("after init").await;
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) =
self.streamer.current_capture_config().await;
info!(
"Configuring WebRTC capture: {}x{} {:?} @ {}fps",
resolution.width, resolution.height, format, fps
"Syncing WebRTC capture source {}: {}x{} {:?} @ {}fps",
reason, resolution.width, resolution.height, format, fps
);
self.webrtc_streamer
.update_video_config(resolution, format, fps)
@@ -364,9 +355,9 @@ impl VideoStreamManager {
self.webrtc_streamer
.set_capture_device(device_path, jpeg_quality)
.await;
} else {
warn!("No capture device configured while syncing WebRTC capture source");
}
Ok(())
}
/// Internal implementation of mode switching (called with lock held)
@@ -471,22 +462,7 @@ impl VideoStreamManager {
}
}
let (device_path, resolution, format, fps, jpeg_quality) =
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");
}
self.sync_webrtc_capture_source("for WebRTC mode").await;
let codec = self.webrtc_streamer.current_video_codec().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
);
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)
self.streamer
.apply_video_config(device_path, format, resolution, fps)
.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
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) =
self.streamer.current_capture_config().await;
if actual_format != format || actual_resolution != resolution || actual_fps != fps {
@@ -590,19 +579,7 @@ impl VideoStreamManager {
self.streamer.init_auto().await?;
}
// Synchronize WebRTC config with current capture config
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");
}
self.sync_webrtc_capture_source("before start").await;
}
}
@@ -747,24 +724,10 @@ impl VideoStreamManager {
}
// 2. Synchronize WebRTC config with capture config
let (device_path, resolution, format, fps, jpeg_quality) =
self.streamer.current_capture_config().await;
tracing::info!(
"Connecting encoded frame subscription: {}x{} {:?} @ {}fps",
resolution.width,
resolution.height,
format,
fps
);
self.webrtc_streamer
.update_video_config(resolution, format, fps)
let (device_path, _, _, _, _) = self.streamer.current_capture_config().await;
self.sync_webrtc_capture_source("for encoded frame subscription")
.await;
if let Some(device_path) = device_path {
self.webrtc_streamer
.set_capture_device(device_path, jpeg_quality)
.await;
} else {
tracing::warn!("No capture device configured for encoded frames");
if device_path.is_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::error::{AppError, Result};
use crate::events::SystemEvent;
use crate::rtsp::RtspService;
use crate::state::AppState;
use crate::video::codec_constraints::{
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 配置变更
pub async fn apply_video_config(
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);
// Step 1: 更新 WebRTC streamer 配置(停止现有 pipeline 和 sessions
state
.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)
.await
.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");
Ok(())
@@ -188,56 +145,26 @@ pub async fn apply_hid_config(
old_config: &HidConfig,
new_config: &HidConfig,
) -> 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 old_hid_functions = old_config.effective_otg_functions();
let mut new_hid_functions = new_config.effective_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 old_hid_functions = old_config.constrained_otg_functions();
let new_hid_functions = new_config.constrained_otg_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
&& old_config.ch9329_port == new_config.ch9329_port
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
&& old_config.otg_udc == new_config.otg_udc
&& !descriptor_changed
&& !hid_functions_changed
&& !keyboard_leds_changed
&& !endpoint_budget_changed
{
tracing::info!("HID config unchanged, skipping reload");
return Ok(());
@@ -245,30 +172,27 @@ pub async fn apply_hid_config(
tracing::info!("Applying HID config changes...");
if new_config.backend == HidBackend::Otg
&& (hid_functions_changed || old_config.backend != HidBackend::Otg)
{
let new_hid_backend = hid_backend_type(new_config);
let transitioning_away_from_otg =
old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg;
if transitioning_away_from_otg {
state
.otg_service
.update_hid_functions(new_hid_functions.clone())
.hid
.reload(new_hid_backend.clone())
.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 {
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,
};
reconcile_otg_from_store(state).await?;
state
.hid
.reload(new_hid_backend)
.await
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
if !transitioning_away_from_otg {
state
.hid
.reload(new_hid_backend)
.await
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
}
tracing::info!(
"HID backend reloaded successfully: {:?}",
@@ -284,6 +208,12 @@ pub async fn apply_msd_config(
old_config: &MsdConfig,
new_config: &MsdConfig,
) -> Result<()> {
state
.config
.get()
.hid
.validate_otg_endpoint_budget(new_config.enabled)?;
tracing::info!("MSD config sent, checking if reload needed...");
tracing::debug!("Old MSD config: {:?}", old_config);
tracing::debug!("New MSD config: {:?}", new_config);
@@ -323,6 +253,8 @@ pub async fn apply_msd_config(
if new_msd_enabled {
tracing::info!("(Re)initializing MSD...");
reconcile_otg_from_store(state).await?;
// Shutdown existing controller if present
let mut msd_guard = state.msd.write().await;
if let Some(msd) = msd_guard.as_mut() {
@@ -358,6 +290,17 @@ pub async fn apply_msd_config(
}
*msd_guard = None;
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(())

View File

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

View File

@@ -4,7 +4,7 @@ use axum::{
extract::{Path, Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use std::sync::Arc;
use typeshare::typeshare;
@@ -324,27 +324,3 @@ pub async fn update_easytier_config(
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::config::{AppConfig, StreamMode};
use crate::error::{AppError, Result};
use crate::events::SystemEvent;
use crate::state::AppState;
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
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
@@ -596,38 +598,14 @@ pub struct SetupRequest {
pub hid_ch9329_baudrate: Option<u32>,
pub hid_otg_udc: 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
pub ttyd_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(
State(state): State<Arc<AppState>>,
Json(req): Json<SetupRequest>,
@@ -701,32 +679,19 @@ pub async fn setup_init(
config.hid.otg_udc = Some(udc);
}
if let Some(profile) = req.hid_otg_profile.clone() {
config.hid.otg_profile = match profile.as_str() {
"full" => crate::config::OtgHidProfile::Full,
"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(parsed) = crate::config::OtgHidProfile::from_legacy_str(&profile) {
config.hid.otg_profile = parsed;
}
}
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
if let Some(enabled) = req.ttyd_enabled {
@@ -735,29 +700,18 @@ pub async fn setup_init(
if let Some(enabled) = req.rustdesk_enabled {
config.rustdesk.enabled = enabled;
}
normalize_otg_profile_for_low_endpoint(config);
})
.await?;
// Get updated config for HID reload
let new_config = state.config.get();
if matches!(new_config.hid.backend, crate::config::HidBackend::Otg) {
let mut hid_functions = new_config.hid.effective_otg_functions();
if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref())
{
if crate::otg::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) = state.otg_service.update_hid_functions(hid_functions).await {
tracing::warn!("Failed to apply HID functions during setup: {}", e);
}
if let Err(e) = state
.otg_service
.apply_config(&new_config.hid, &new_config.msd)
.await
{
tracing::warn!("Failed to apply OTG config during setup: {}", e);
}
tracing::info!(
@@ -879,8 +833,10 @@ pub async fn update_config(
let new_config: AppConfig = serde_json::from_value(merged)
.map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?;
let mut new_config = new_config;
normalize_otg_profile_for_low_endpoint(&mut new_config);
let new_config = new_config;
new_config
.hid
.validate_otg_endpoint_budget(new_config.msd.enabled)?;
// Apply the validated config
state.config.set(new_config.clone()).await?;
@@ -908,297 +864,76 @@ pub async fn update_config(
// Get new config for device reloading
let new_config = state.config.get();
// Video config processing - always reload if section was sent
if has_video {
tracing::info!("Video config sent, applying settings...");
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
if let Err(e) =
config::apply::apply_video_config(&state, &old_config.video, &new_config.video).await
{
tracing::error!("Failed to apply video config: {}", e);
// Rollback config on failure
state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse {
success: false,
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 {
tracing::info!("Stream config sent, applying encoder settings...");
// Update WebRTC streamer encoder backend
let encoder_backend = new_config.stream.encoder.to_backend();
tracing::info!(
"Updating encoder backend to: {:?} (from config: {:?})",
encoder_backend,
new_config.stream.encoder
);
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
);
if let Err(e) =
config::apply::apply_stream_config(&state, &old_config.stream, &new_config.stream).await
{
tracing::error!("Failed to apply stream config: {}", e);
state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse {
success: false,
message: Some(format!("Stream configuration invalid: {}", e)),
}));
}
}
// HID config processing - always reload if section was sent
if has_hid {
tracing::info!("HID config sent, reloading HID backend...");
// 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 {
if let Err(e) =
config::apply::apply_hid_config(&state, &old_config.hid, &new_config.hid).await
{
tracing::error!("HID reload failed: {}", e);
// Rollback config on failure
state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse {
success: false,
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 {
tracing::info!("Audio config sent, applying settings...");
// 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
if let Err(e) =
config::apply::apply_audio_config(&state, &old_config.audio, &new_config.audio).await
{
tracing::warn!("Failed to update WebRTC audio state: {}", 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;
tracing::warn!("Audio config update failed: {}", e);
}
}
// MSD config processing - reload if enabled state or directory changed
if has_msd {
tracing::info!("MSD config sent, checking if reload needed...");
tracing::debug!("Old MSD config: {:?}", old_config.msd);
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);
if let Err(e) =
config::apply::apply_msd_config(&state, &old_config.msd, &new_config.msd).await
{
tracing::error!("MSD initialization failed: {}", e);
state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse {
success: false,
message: Some(format!("MSD initialization failed: {}", e)),
}));
}
}
// 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);
// Rollback config on failure
state.config.set((*old_config).clone()).await?;
return Ok(Json(LoginResponse {
success: false,
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;
tracing::info!("MSD shutdown complete");
if has_atx {
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)
let h264_encoder = registry.best_encoder(VideoEncoderType::H264, false);
let h264_encoder = registry.best_available_encoder(VideoEncoderType::H264);
codecs.push(VideoCodecInfo {
id: "h264".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)
let h265_encoder = registry.best_encoder(VideoEncoderType::H265, false);
let h265_encoder = registry.best_available_encoder(VideoEncoderType::H265);
codecs.push(VideoCodecInfo {
id: "h265".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)
let vp8_encoder = registry.best_encoder(VideoEncoderType::VP8, false);
let vp8_encoder = registry.best_available_encoder(VideoEncoderType::VP8);
codecs.push(VideoCodecInfo {
id: "vp8".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)
let vp9_encoder = registry.best_encoder(VideoEncoderType::VP9, false);
let vp9_encoder = registry.best_available_encoder(VideoEncoderType::VP9);
codecs.push(VideoCodecInfo {
id: "vp9".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
#[derive(Deserialize, Default)]
pub struct MjpegStreamQuery {
@@ -2299,8 +2043,14 @@ pub struct HidStatus {
pub available: bool,
pub backend: String,
pub initialized: bool,
pub online: bool,
pub supports_absolute_mouse: bool,
pub keyboard_leds_enabled: bool,
pub led_state: crate::hid::LedState,
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)]
@@ -3061,19 +2811,19 @@ pub async fn hid_otg_self_check(State(state): State<Arc<AppState>>) -> Json<OtgS
/// Get HID status
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 {
available: info.is_some(),
backend: info
.as_ref()
.map(|i| i.name.to_string())
.unwrap_or_else(|| "none".to_string()),
initialized: info.as_ref().map(|i| i.initialized).unwrap_or(false),
supports_absolute_mouse: info
.as_ref()
.map(|i| i.supports_absolute_mouse)
.unwrap_or(false),
screen_resolution: info.and_then(|i| i.screen_resolution),
available: hid.available,
backend: hid.backend,
initialized: hid.initialized,
online: hid.online,
supports_absolute_mouse: hid.supports_absolute_mouse,
keyboard_leds_enabled: hid.keyboard_leds_enabled,
led_state: hid.led_state,
screen_resolution: hid.screen_resolution,
device: hid.device,
error: hid.error,
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/codecs", get(handlers::stream_codecs_list))
.route("/stream/constraints", get(handlers::stream_constraints_get))
.route(
"/video/encoder/self-check",
get(handlers::video_encoder_self_check),
)
// WebRTC endpoints
.route("/webrtc/session", post(handlers::webrtc_create_session))
.route("/webrtc/offer", post(handlers::webrtc_offer))
@@ -192,10 +196,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/extensions/ttyd/config",
patch(handlers::extensions::update_ttyd_config),
)
.route(
"/extensions/ttyd/status",
get(handlers::extensions::get_ttyd_status),
)
.route(
"/extensions/gostc/config",
patch(handlers::extensions::update_gostc_config),

View File

@@ -201,7 +201,7 @@ pub fn placeholder_html() -> &'static str {
<h1>One-KVM</h1>
<p>Frontend not built yet.</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>
</body>
</html>"#

View File

@@ -16,12 +16,122 @@ use axum::{
use futures::{SinkExt, StreamExt};
use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::broadcast;
use tokio::{sync::mpsc, task::JoinHandle};
use tracing::{debug, info, warn};
use crate::events::SystemEvent;
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
#[derive(Debug, Deserialize)]
#[serde(tag = "type", content = "payload")]
@@ -50,16 +160,12 @@ pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>
/// Handle WebSocket connection
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
let (mut sender, mut receiver) = socket.split();
// Subscribe to event bus
let mut event_rx = state.events.subscribe();
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let mut event_tasks: Vec<JoinHandle<()>> = Vec::new();
// Track subscribed topics (default: none until client subscribes)
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");
// Heartbeat interval (30 seconds)
@@ -73,18 +179,13 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
Some(Ok(Message::Text(text))) => {
if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await {
warn!("Failed to handle client message: {}", e);
}
// Send device info after first subscribe
if !device_info_sent && !subscribed_topics.is_empty() {
let device_info = state.get_device_info().await;
if let Ok(json) = serialize_event(&device_info) {
if sender.send(Message::Text(json.into())).await.is_err() {
warn!("Failed to send device info to client");
break;
}
}
device_info_sent = true;
} else {
rebuild_event_tasks(
&state,
&subscribed_topics,
&event_tx,
&mut event_tasks,
);
}
}
Some(Ok(Message::Ping(_))) => {
@@ -109,28 +210,29 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
// Receive event from event bus
event = event_rx.recv() => {
match event {
Ok(event) => {
Some(BusMessage::Event(event)) => {
// Filter event based on subscribed topics
if should_send_event(&event, &subscribed_topics) {
if let Ok(json) = serialize_event(&event) {
if sender.send(Message::Text(json.into())).await.is_err() {
warn!("Failed to send event to client, disconnecting");
break;
}
if let Ok(json) = serialize_event(&event) {
if sender.send(Message::Text(json.into())).await.is_err() {
warn!("Failed to send event to client, disconnecting");
break;
}
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("WebSocket client lagged by {} events", n);
Some(BusMessage::Lagged { topic, count }) => {
warn!(
"WebSocket client lagged by {} events on topic {}",
count, topic
);
// Send error notification to client using 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) {
let _ = sender.send(Message::Text(json.into())).await;
}
}
Err(_) => {
None => {
warn!("Event bus closed");
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");
}
@@ -176,21 +282,6 @@ async fn handle_client_message(
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
fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
serde_json::to_string(event)
@@ -199,53 +290,49 @@ fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
#[cfg(test)]
mod tests {
use super::*;
use crate::events::SystemEvent;
#[test]
fn test_should_send_event_wildcard() {
let event = SystemEvent::StreamStateChanged {
state: "streaming".to_string(),
device: None,
};
fn test_normalize_topics_dedupes_and_sorts() {
let topics = vec![
"stream.state_changed".to_string(),
"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]
fn test_should_send_event_prefix() {
let event = SystemEvent::StreamStateChanged {
state: "streaming".to_string(),
device: None,
};
assert!(should_send_event(&event, &["stream.*".to_string()]));
assert!(!should_send_event(&event, &["msd.*".to_string()]));
fn test_normalize_topics_wildcard_wins() {
let topics = vec!["*".to_string(), "stream.state_changed".to_string()];
assert_eq!(normalize_topics(&topics), vec!["*".to_string()]);
}
#[test]
fn test_should_send_event_exact() {
let event = SystemEvent::StreamStateChanged {
state: "streaming".to_string(),
device: None,
};
fn test_normalize_topics_drops_exact_when_prefix_exists() {
let topics = vec![
"stream.*".to_string(),
"stream.state_changed".to_string(),
"system.device_info".to_string(),
];
assert!(should_send_event(
&event,
&["stream.state_changed".to_string()]
));
assert!(!should_send_event(
&event,
&["stream.config_changed".to_string()]
));
assert_eq!(
normalize_topics(&topics),
vec!["stream.*".to_string(), "system.device_info".to_string()]
);
}
#[test]
fn test_should_send_event_empty_topics() {
let event = SystemEvent::StreamStateChanged {
state: "streaming".to_string(),
device: None,
};
assert!(!should_send_event(&event, &[]));
fn test_is_device_info_topic_matches_expected_topics() {
assert!(is_device_info_topic("system.device_info"));
assert!(is_device_info_topic("system.*"));
assert!(is_device_info_topic("*"));
assert!(!is_device_info_topic("stream.*"));
}
}

View File

@@ -37,12 +37,6 @@ const H265_NAL_SPS: u8 = 33;
const H265_NAL_PPS: u8 = 34;
const H265_NAL_AUD: u8 = 35;
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
/// H.265 NAL header size
@@ -51,11 +45,6 @@ const H265_NAL_HEADER_SIZE: usize = 2;
/// FU header size (1 byte after NAL header)
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
/// This matches the rtp crate's AGGR_PAYLOAD_HDR
const AP_PAYLOAD_HDR: [u8; 2] = [0x60, 0x01];

View File

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

View File

@@ -262,8 +262,6 @@ impl Default for AudioTrackConfig {
/// Audio track for WebRTC streaming
pub struct AudioTrack {
#[allow(dead_code)]
config: AudioTrackConfig,
/// RTP track
track: Arc<TrackLocalStaticRTP>,
/// Running flag
@@ -284,7 +282,6 @@ impl AudioTrack {
let (running_tx, _) = watch::channel(false);
Self {
config,
track,
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)
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
#[derive(Debug, Clone)]
pub struct UniversalSessionConfig {
@@ -649,6 +696,13 @@ impl UniversalSession {
if gap_detected {
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();
if now.duration_since(last_keyframe_request)
>= Duration::from_millis(200)
@@ -656,7 +710,9 @@ impl UniversalSession {
request_keyframe();
last_keyframe_request = now;
}
continue;
if !forward_h264_parameter_frame {
continue;
}
}
}

View File

@@ -221,23 +221,11 @@ impl WebRtcStreamer {
use crate::video::encoder::registry::EncoderRegistry;
let registry = EncoderRegistry::global();
let mut codecs = vec![];
// H264 always available (has software fallback)
codecs.push(VideoCodecType::H264);
// 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
VideoEncoderType::ordered()
.into_iter()
.filter(|codec| registry.is_codec_available(*codec))
.map(Self::encoder_type_to_codec_type)
.collect()
}
/// 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 {
session_count == 0 && subscriber_count == 0
}
@@ -577,7 +574,7 @@ impl WebRtcStreamer {
VideoCodecType::VP9 => VideoEncoderType::VP9,
};
EncoderRegistry::global()
.best_encoder(codec_type, false)
.best_available_encoder(codec_type)
.map(|e| e.is_hardware)
.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",
"version": "0.1.5",
"version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "0.1.5",
"version": "0.1.7",
"dependencies": {
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { KeepAlive, onMounted, watch } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSystemStore } from '@/stores/system'
@@ -56,5 +56,10 @@ watch(
</script>
<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>

View File

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

View File

@@ -1,6 +1,7 @@
// API client for One-KVM backend
import { request, ApiError } from './request'
import type { CanonicalKey } from '@/types/generated'
const API_BASE = '/api'
@@ -85,6 +86,9 @@ export const systemApi = {
hid_ch9329_baudrate?: number
hid_otg_udc?: string
hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean
encoder_backend?: string
audio_device?: string
ttyd_enabled?: boolean
@@ -177,6 +181,31 @@ export interface StreamConstraintsResponse {
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 = {
status: () =>
request<{
@@ -217,6 +246,9 @@ export const streamApi = {
getConstraints: () =>
request<StreamConstraintsResponse>('/stream/constraints'),
encoderSelfCheck: () =>
request<VideoEncoderSelfCheckResponse>('/video/encoder/self-check'),
setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) =>
request<{ success: boolean; message?: string }>('/stream/bitrate', {
method: 'POST',
@@ -299,8 +331,20 @@ export const hidApi = {
available: boolean
backend: string
initialized: boolean
online: 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
device: string | null
error: string | null
error_code: string | null
}>('/hid/status'),
otgSelfCheck: () =>
@@ -325,7 +369,7 @@ export const hidApi = {
}>
}>('/hid/otg/self-check'),
keyboard: async (type: 'down' | 'up', key: number, modifier?: number) => {
keyboard: async (type: 'down' | 'up', key: CanonicalKey, modifier?: number) => {
await ensureHidConnection()
const event: HidKeyboardEvent = {
type: type === 'down' ? 'keydown' : 'keyup',

View File

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

View File

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

View File

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

View File

@@ -69,24 +69,24 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
return true
}
const { hidCode, shift } = mapping
const { key, shift } = mapping
const modifier = shift ? 0x02 : 0
try {
// 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
await sleep(5)
if (signal.aborted) {
// Even if aborted, still send keyup to release the key
await hidApi.keyboard('up', hidCode, modifier)
await hidApi.keyboard('up', key, modifier)
return false
}
// Send keyup
await hidApi.keyboard('up', hidCode, modifier)
await hidApi.keyboard('up', key, modifier)
// Additional small delay after keyup to ensure it's processed
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)
// Try to release the key even on error
try {
await hidApi.keyboard('up', hidCode, modifier)
await hidApi.keyboard('up', key, modifier)
} catch {
// Ignore cleanup errors
}

View File

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

View File

@@ -4,12 +4,13 @@ import { useI18n } from 'vue-i18n'
import Keyboard from 'simple-keyboard'
import 'simple-keyboard/build/css/index.css'
import { hidApi } from '@/api'
import { CanonicalKey } from '@/types/generated'
import {
keys,
consumerKeys,
latchingKeys,
modifiers,
updateModifierMaskForHidKey,
updateModifierMaskForKey,
type KeyName,
type ConsumerKeyName,
} from '@/lib/keyboardMappings'
@@ -23,13 +24,16 @@ import {
const props = defineProps<{
visible: boolean
attached?: boolean
capsLock?: boolean
pressedKeys?: CanonicalKey[]
consumerEnabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'update:attached', value: boolean): void
(e: 'keyDown', key: string): void
(e: 'keyUp', key: string): void
(e: 'keyDown', key: CanonicalKey): void
(e: 'keyUp', key: CanonicalKey): void
}>()
const { t } = useI18n()
@@ -45,13 +49,17 @@ const arrowsKeyboard = ref<Keyboard | null>(null)
// Pressed keys tracking
const pressedModifiers = ref<number>(0)
const keysDown = ref<string[]>([])
const keysDown = ref<CanonicalKey[]>([])
// Shift state for display
const isShiftActive = computed(() => {
return (pressedModifiers.value & 0x22) !== 0
})
const areLettersUppercase = computed(() => {
return Boolean(props.capsLock) !== isShiftActive.value
})
const layoutName = computed(() => {
return isShiftActive.value ? 'shift' : 'default'
})
@@ -63,7 +71,12 @@ const keyNamesForDownKeys = computed(() => {
.filter(([_, mask]) => (activeModifierMask & mask) !== 0)
.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)
@@ -88,7 +101,7 @@ const keyboardLayout = {
'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 AltGr MetaRight Menu ControlRight',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
],
shift: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
@@ -97,7 +110,7 @@ const keyboardLayout = {
'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 AltGr MetaRight Menu ControlRight',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
],
},
control: {
@@ -148,11 +161,10 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
AltLeft: 'Alt',
AltRight: 'Alt',
AltGr: 'AltGr',
AltRight: 'AltGr',
MetaLeft: metaLabel,
MetaRight: metaLabel,
Menu: 'Menu',
ContextMenu: 'Menu',
// Special keys
Escape: 'Esc',
@@ -187,20 +199,60 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
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',
KeyA: areLettersUppercase.value ? 'A' : 'a',
KeyB: areLettersUppercase.value ? 'B' : 'b',
KeyC: areLettersUppercase.value ? 'C' : 'c',
KeyD: areLettersUppercase.value ? 'D' : 'd',
KeyE: areLettersUppercase.value ? 'E' : 'e',
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
'(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',
// Letter labels in the shifted layout follow CapsLock xor Shift too
'(KeyA)': areLettersUppercase.value ? 'A' : 'a',
'(KeyB)': areLettersUppercase.value ? 'B' : 'b',
'(KeyC)': areLettersUppercase.value ? 'C' : 'c',
'(KeyD)': areLettersUppercase.value ? 'D' : 'd',
'(KeyE)': areLettersUppercase.value ? 'E' : 'e',
'(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
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]
// Handle latching keys (Caps Lock, etc.)
if ((latchingKeys as readonly string[]).includes(cleanKey)) {
emit('keyDown', cleanKey)
if (latchingKeys.some(latchingKey => latchingKey === keyCode)) {
emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask)
setTimeout(() => {
sendKeyPress(keyCode, false, currentMask)
emit('keyUp', cleanKey)
emit('keyUp', keyCode)
}, 100)
return
}
// Handle modifier keys (toggle)
if (cleanKey in modifiers) {
const mask = modifiers[cleanKey as keyof typeof modifiers]
const mask = modifiers[keyCode] ?? 0
if (mask !== 0) {
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
if (isCurrentlyDown) {
const nextMask = pressedModifiers.value & ~mask
pressedModifiers.value = nextMask
await sendKeyPress(keyCode, false, nextMask)
emit('keyUp', cleanKey)
emit('keyUp', keyCode)
} else {
const nextMask = pressedModifiers.value | mask
pressedModifiers.value = nextMask
await sendKeyPress(keyCode, true, nextMask)
emit('keyDown', cleanKey)
emit('keyDown', keyCode)
}
updateKeyboardButtonTheme()
return
}
// Regular key: press and release
keysDown.value.push(cleanKey)
emit('keyDown', cleanKey)
keysDown.value.push(keyCode)
emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask)
updateKeyboardButtonTheme()
setTimeout(async () => {
keysDown.value = keysDown.value.filter(k => k !== cleanKey)
keysDown.value = keysDown.value.filter(k => k !== keyCode)
await sendKeyPress(keyCode, false, currentMask)
emit('keyUp', cleanKey)
emit('keyUp', keyCode)
updateKeyboardButtonTheme()
}, 50)
}
@@ -352,7 +404,7 @@ async function onKeyUp() {
// 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 {
await hidApi.keyboard(press ? 'down' : 'up', keyCode, modifierMask & 0xff)
} catch (err) {
@@ -372,7 +424,7 @@ async function executeMacro(steps: MacroStep[]) {
for (const mod of step.modifiers) {
if (mod in keys) {
const modHid = keys[mod as KeyName]
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, true)
macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, true)
await sendKeyPress(modHid, true, macroModifierMask)
}
}
@@ -394,7 +446,7 @@ async function executeMacro(steps: MacroStep[]) {
for (const mod of step.modifiers) {
if (mod in keys) {
const modHid = keys[mod as KeyName]
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, false)
macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, false)
await sendKeyPress(modHid, false, macroModifierMask)
}
}
@@ -421,8 +473,12 @@ function updateKeyboardButtonTheme() {
}
// Update layout when shift state changes
watch(layoutName, (name) => {
mainKeyboard.value?.setOptions({ layoutName: name })
watch([layoutName, () => props.capsLock], ([name]) => {
mainKeyboard.value?.setOptions({
layoutName: name,
display: keyDisplayMap.value,
})
updateKeyboardButtonTheme()
})
// Initialize keyboards with unique selectors
@@ -663,7 +719,7 @@ onUnmounted(() => {
<!-- Keyboard body -->
<div class="vkb-body">
<!-- Media keys row -->
<div class="vkb-media-row">
<div v-if="props.consumerEnabled !== false" class="vkb-media-row">
<button
v-for="key in mediaKeys"
:key="key"
@@ -835,12 +891,12 @@ onUnmounted(() => {
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"] {
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"] {
flex-grow: 1.25;
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] {
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
flex-grow: 1.25;
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="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"],
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] {
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 46px;
}

View File

@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system'
import { useWebSocket } from '@/composables/useWebSocket'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
export interface ConsoleEventHandlers {
onStreamConfigChanging?: (data: { reason?: string }) => void
@@ -20,119 +19,13 @@ export interface ConsoleEventHandlers {
onStreamReconnecting?: (data: { device: string; attempt: number }) => void
onStreamRecovered?: (data: { device: string }) => void
onDeviceInfo?: (data: any) => void
onAudioStateChanged?: (data: { streaming: boolean; device: string | null }) => void
}
export function useConsoleEvents(handlers: ConsoleEventHandlers) {
const { t } = useI18n()
const systemStore = useSystemStore()
const { on, off, connect } = useWebSocket()
const unifiedAudio = getUnifiedAudio()
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
function handleStreamDeviceLost(data: { device: string; reason: string }) {
if (systemStore.stream) {
@@ -177,93 +70,8 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
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
function subscribe() {
// HID events
on('hid.state_changed', handleHidStateChanged)
on('hid.device_lost', handleHidDeviceLost)
on('hid.reconnecting', handleHidReconnecting)
on('hid.recovered', handleHidRecovered)
// Stream events
on('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
on('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
@@ -277,19 +85,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
on('stream.reconnecting', handleStreamReconnecting)
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
on('system.device_info', handlers.onDeviceInfo ?? noop)
@@ -299,11 +94,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
// Unsubscribe from all events
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_applied', handlers.onStreamConfigApplied ?? noop)
off('stream.stats_update', handlers.onStreamStatsUpdate ?? noop)
@@ -316,17 +106,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
off('stream.reconnecting', handleStreamReconnecting)
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)
}

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 handlers = new Map<string, EventHandler[]>()
let subscribedTopics: string[] = []
const connected = ref(false)
const reconnectAttempts = ref(0)
const networkError = ref(false)
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() {
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
syncSubscriptions()
return
}
@@ -37,8 +64,7 @@ function connect() {
networkErrorMessage.value = null
reconnectAttempts.value = 0
// Subscribe to all events by default
subscribe(['*'])
syncSubscriptions()
}
wsInstance.onmessage = (e) => {
@@ -78,6 +104,7 @@ function disconnect() {
wsInstance.close()
wsInstance = null
}
subscribedTopics = []
}
function subscribe(topics: string[]) {
@@ -94,6 +121,7 @@ function on(event: string, handler: EventHandler) {
handlers.set(event, [])
}
handlers.get(event)!.push(handler)
syncSubscriptions()
}
function off(event: string, handler: EventHandler) {
@@ -103,7 +131,11 @@ function off(event: string, handler: EventHandler) {
if (index > -1) {
eventHandlers.splice(index, 1)
}
if (eventHandlers.length === 0) {
handlers.delete(event)
}
}
syncSubscriptions()
}
function handleEvent(payload: WsEvent) {

View File

@@ -32,6 +32,7 @@ export default {
menu: 'Menu',
optional: 'Optional',
recommended: 'Recommended',
notSupportedYet: ' (Not Yet Supported)',
create: 'Create',
creating: 'Creating...',
deleting: 'Deleting...',
@@ -61,7 +62,6 @@ export default {
password: 'Password',
enterUsername: 'Enter username',
enterPassword: 'Enter password',
loginPrompt: 'Enter your credentials to login',
loginFailed: 'Login failed',
invalidPassword: 'Invalid username or password',
changePassword: 'Change Password',
@@ -169,6 +169,7 @@ export default {
caps: 'Caps',
num: 'Num',
scroll: 'Scroll',
keyboardLedUnavailable: 'Keyboard LED status is disabled or unsupported',
},
paste: {
title: 'Paste Text',
@@ -270,7 +271,7 @@ export default {
otgAdvanced: 'Advanced: OTG Preset',
otgProfile: 'Initial HID Preset',
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.',
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
// Extensions
@@ -362,15 +363,22 @@ export default {
recovered: 'HID Recovered',
recoveredDesc: '{backend} HID device reconnected successfully',
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',
notOpened: 'HID device is not open, try restarting HID service',
portNotFound: 'Serial port not found, check CH9329 wiring and device path',
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',
healthCheckFailed: 'Background health check failed',
deviceDisconnected: 'HID device disconnected, check cable and host port',
ioError: 'I/O communication error detected',
otgIoError: 'OTG link is unstable, check USB cable and host port',
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: {
@@ -644,28 +652,28 @@ export default {
hidBackend: 'HID Backend',
serialDevice: 'Serial Device',
baudRate: 'Baud Rate',
otgHidProfile: 'OTG HID Profile',
otgHidProfile: 'OTG HID Functions',
otgHidProfileDesc: 'Select which HID functions are exposed to the host',
profile: 'Profile',
otgProfileFull: 'Keyboard + relative mouse + absolute mouse + multimedia + MSD',
otgProfileFullNoMsd: 'Keyboard + relative mouse + absolute mouse + multimedia (no MSD)',
otgProfileFullNoConsumer: 'Keyboard + relative mouse + absolute mouse + MSD (no multimedia)',
otgProfileFullNoConsumerNoMsd: 'Keyboard + relative mouse + absolute mouse (no multimedia, no MSD)',
otgProfileLegacyKeyboard: 'Keyboard only',
otgProfileLegacyMouseRelative: 'Relative mouse only',
otgProfileCustom: 'Custom',
otgEndpointBudget: 'Max Endpoints',
otgEndpointBudgetUnlimited: 'Unlimited',
otgEndpointBudgetHint: 'This is a hardware limit. If the OTG selection exceeds the real hardware endpoint count, OTG will fail.',
otgEndpointUsage: 'Endpoint usage: {used} / {limit}',
otgEndpointUsageUnlimited: 'Endpoint usage: {used} / unlimited',
otgEndpointExceeded: 'The current OTG selection needs {used} endpoints, exceeding the limit {limit}.',
otgFunctionKeyboard: 'Keyboard',
otgFunctionKeyboardDesc: 'Standard HID keyboard device',
otgKeyboardLeds: 'Keyboard LED Status',
otgKeyboardLedsDesc: 'Enable Caps/Num/Scroll LED feedback from the host',
otgFunctionMouseRelative: 'Relative Mouse',
otgFunctionMouseRelativeDesc: 'Traditional mouse movement (HID boot mouse)',
otgFunctionMouseAbsolute: 'Absolute Mouse',
otgFunctionMouseAbsoluteDesc: 'Absolute positioning (touchscreen-like)',
otgFunctionConsumer: 'Consumer Control',
otgFunctionConsumerDesc: 'Media keys like volume/play/pause',
otgFunctionConsumer: 'Consumer Control Keyboard',
otgFunctionConsumerDesc: 'Consumer Control keys such as volume/play/pause',
otgFunctionMsd: 'Mass Storage (MSD)',
otgFunctionMsdDesc: 'Expose USB storage to the host',
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',
// OTG Descriptor
otgDescriptor: 'USB Device Descriptor',
@@ -757,6 +765,15 @@ export default {
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
webrtcSettings: 'WebRTC Settings',
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
@@ -783,7 +800,7 @@ export default {
osWindows: 'Windows',
osMac: 'Mac',
osAndroid: 'Android',
mediaKeys: 'Media Keys',
mediaKeys: 'Consumer Control Keyboard',
},
config: {
applied: 'Configuration applied',

View File

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

View File

@@ -1,129 +1,130 @@
// Character to HID usage mapping for text paste functionality.
// The table follows US QWERTY layout semantics.
import { type CanonicalKey } from '@/types/generated'
import { keys } from '@/lib/keyboardMappings'
export interface CharKeyMapping {
hidCode: number // USB HID usage code
key: CanonicalKey
shift: boolean // Whether Shift modifier is needed
}
const charToKeyMap: Record<string, CharKeyMapping> = {
// Lowercase letters
a: { hidCode: keys.KeyA, shift: false },
b: { hidCode: keys.KeyB, shift: false },
c: { hidCode: keys.KeyC, shift: false },
d: { hidCode: keys.KeyD, shift: false },
e: { hidCode: keys.KeyE, shift: false },
f: { hidCode: keys.KeyF, shift: false },
g: { hidCode: keys.KeyG, shift: false },
h: { hidCode: keys.KeyH, shift: false },
i: { hidCode: keys.KeyI, shift: false },
j: { hidCode: keys.KeyJ, shift: false },
k: { hidCode: keys.KeyK, shift: false },
l: { hidCode: keys.KeyL, shift: false },
m: { hidCode: keys.KeyM, shift: false },
n: { hidCode: keys.KeyN, shift: false },
o: { hidCode: keys.KeyO, shift: false },
p: { hidCode: keys.KeyP, shift: false },
q: { hidCode: keys.KeyQ, shift: false },
r: { hidCode: keys.KeyR, shift: false },
s: { hidCode: keys.KeyS, shift: false },
t: { hidCode: keys.KeyT, shift: false },
u: { hidCode: keys.KeyU, shift: false },
v: { hidCode: keys.KeyV, shift: false },
w: { hidCode: keys.KeyW, shift: false },
x: { hidCode: keys.KeyX, shift: false },
y: { hidCode: keys.KeyY, shift: false },
z: { hidCode: keys.KeyZ, shift: false },
a: { key: keys.KeyA, shift: false },
b: { key: keys.KeyB, shift: false },
c: { key: keys.KeyC, shift: false },
d: { key: keys.KeyD, shift: false },
e: { key: keys.KeyE, shift: false },
f: { key: keys.KeyF, shift: false },
g: { key: keys.KeyG, shift: false },
h: { key: keys.KeyH, shift: false },
i: { key: keys.KeyI, shift: false },
j: { key: keys.KeyJ, shift: false },
k: { key: keys.KeyK, shift: false },
l: { key: keys.KeyL, shift: false },
m: { key: keys.KeyM, shift: false },
n: { key: keys.KeyN, shift: false },
o: { key: keys.KeyO, shift: false },
p: { key: keys.KeyP, shift: false },
q: { key: keys.KeyQ, shift: false },
r: { key: keys.KeyR, shift: false },
s: { key: keys.KeyS, shift: false },
t: { key: keys.KeyT, shift: false },
u: { key: keys.KeyU, shift: false },
v: { key: keys.KeyV, shift: false },
w: { key: keys.KeyW, shift: false },
x: { key: keys.KeyX, shift: false },
y: { key: keys.KeyY, shift: false },
z: { key: keys.KeyZ, shift: false },
// Uppercase letters
A: { hidCode: keys.KeyA, shift: true },
B: { hidCode: keys.KeyB, shift: true },
C: { hidCode: keys.KeyC, shift: true },
D: { hidCode: keys.KeyD, shift: true },
E: { hidCode: keys.KeyE, shift: true },
F: { hidCode: keys.KeyF, shift: true },
G: { hidCode: keys.KeyG, shift: true },
H: { hidCode: keys.KeyH, shift: true },
I: { hidCode: keys.KeyI, shift: true },
J: { hidCode: keys.KeyJ, shift: true },
K: { hidCode: keys.KeyK, shift: true },
L: { hidCode: keys.KeyL, shift: true },
M: { hidCode: keys.KeyM, shift: true },
N: { hidCode: keys.KeyN, shift: true },
O: { hidCode: keys.KeyO, shift: true },
P: { hidCode: keys.KeyP, shift: true },
Q: { hidCode: keys.KeyQ, shift: true },
R: { hidCode: keys.KeyR, shift: true },
S: { hidCode: keys.KeyS, shift: true },
T: { hidCode: keys.KeyT, shift: true },
U: { hidCode: keys.KeyU, shift: true },
V: { hidCode: keys.KeyV, shift: true },
W: { hidCode: keys.KeyW, shift: true },
X: { hidCode: keys.KeyX, shift: true },
Y: { hidCode: keys.KeyY, shift: true },
Z: { hidCode: keys.KeyZ, shift: true },
A: { key: keys.KeyA, shift: true },
B: { key: keys.KeyB, shift: true },
C: { key: keys.KeyC, shift: true },
D: { key: keys.KeyD, shift: true },
E: { key: keys.KeyE, shift: true },
F: { key: keys.KeyF, shift: true },
G: { key: keys.KeyG, shift: true },
H: { key: keys.KeyH, shift: true },
I: { key: keys.KeyI, shift: true },
J: { key: keys.KeyJ, shift: true },
K: { key: keys.KeyK, shift: true },
L: { key: keys.KeyL, shift: true },
M: { key: keys.KeyM, shift: true },
N: { key: keys.KeyN, shift: true },
O: { key: keys.KeyO, shift: true },
P: { key: keys.KeyP, shift: true },
Q: { key: keys.KeyQ, shift: true },
R: { key: keys.KeyR, shift: true },
S: { key: keys.KeyS, shift: true },
T: { key: keys.KeyT, shift: true },
U: { key: keys.KeyU, shift: true },
V: { key: keys.KeyV, shift: true },
W: { key: keys.KeyW, shift: true },
X: { key: keys.KeyX, shift: true },
Y: { key: keys.KeyY, shift: true },
Z: { key: keys.KeyZ, shift: true },
// Number row
'0': { hidCode: keys.Digit0, shift: false },
'1': { hidCode: keys.Digit1, shift: false },
'2': { hidCode: keys.Digit2, shift: false },
'3': { hidCode: keys.Digit3, shift: false },
'4': { hidCode: keys.Digit4, shift: false },
'5': { hidCode: keys.Digit5, shift: false },
'6': { hidCode: keys.Digit6, shift: false },
'7': { hidCode: keys.Digit7, shift: false },
'8': { hidCode: keys.Digit8, shift: false },
'9': { hidCode: keys.Digit9, shift: false },
'0': { key: keys.Digit0, shift: false },
'1': { key: keys.Digit1, shift: false },
'2': { key: keys.Digit2, shift: false },
'3': { key: keys.Digit3, shift: false },
'4': { key: keys.Digit4, shift: false },
'5': { key: keys.Digit5, shift: false },
'6': { key: keys.Digit6, shift: false },
'7': { key: keys.Digit7, shift: false },
'8': { key: keys.Digit8, shift: false },
'9': { key: keys.Digit9, shift: false },
// Shifted number row symbols
')': { hidCode: keys.Digit0, shift: true },
'!': { hidCode: keys.Digit1, shift: true },
'@': { hidCode: keys.Digit2, shift: true },
'#': { hidCode: keys.Digit3, shift: true },
'$': { hidCode: keys.Digit4, shift: true },
'%': { hidCode: keys.Digit5, shift: true },
'^': { hidCode: keys.Digit6, shift: true },
'&': { hidCode: keys.Digit7, shift: true },
'*': { hidCode: keys.Digit8, shift: true },
'(': { hidCode: keys.Digit9, shift: true },
')': { key: keys.Digit0, shift: true },
'!': { key: keys.Digit1, shift: true },
'@': { key: keys.Digit2, shift: true },
'#': { key: keys.Digit3, shift: true },
'$': { key: keys.Digit4, shift: true },
'%': { key: keys.Digit5, shift: true },
'^': { key: keys.Digit6, shift: true },
'&': { key: keys.Digit7, shift: true },
'*': { key: keys.Digit8, shift: true },
'(': { key: keys.Digit9, shift: true },
// Punctuation and symbols
'-': { hidCode: keys.Minus, shift: false },
'=': { hidCode: keys.Equal, shift: false },
'[': { hidCode: keys.BracketLeft, shift: false },
']': { hidCode: keys.BracketRight, shift: false },
'\\': { hidCode: keys.Backslash, shift: false },
';': { hidCode: keys.Semicolon, shift: false },
"'": { hidCode: keys.Quote, shift: false },
'`': { hidCode: keys.Backquote, shift: false },
',': { hidCode: keys.Comma, shift: false },
'.': { hidCode: keys.Period, shift: false },
'/': { hidCode: keys.Slash, shift: false },
'-': { key: keys.Minus, shift: false },
'=': { key: keys.Equal, shift: false },
'[': { key: keys.BracketLeft, shift: false },
']': { key: keys.BracketRight, shift: false },
'\\': { key: keys.Backslash, shift: false },
';': { key: keys.Semicolon, shift: false },
"'": { key: keys.Quote, shift: false },
'`': { key: keys.Backquote, shift: false },
',': { key: keys.Comma, shift: false },
'.': { key: keys.Period, shift: false },
'/': { key: keys.Slash, shift: false },
// Shifted punctuation and symbols
_: { hidCode: keys.Minus, shift: true },
'+': { hidCode: keys.Equal, shift: true },
'{': { hidCode: keys.BracketLeft, shift: true },
'}': { hidCode: keys.BracketRight, shift: true },
'|': { hidCode: keys.Backslash, shift: true },
':': { hidCode: keys.Semicolon, shift: true },
'"': { hidCode: keys.Quote, shift: true },
'~': { hidCode: keys.Backquote, shift: true },
'<': { hidCode: keys.Comma, shift: true },
'>': { hidCode: keys.Period, shift: true },
'?': { hidCode: keys.Slash, shift: true },
_: { key: keys.Minus, shift: true },
'+': { key: keys.Equal, shift: true },
'{': { key: keys.BracketLeft, shift: true },
'}': { key: keys.BracketRight, shift: true },
'|': { key: keys.Backslash, shift: true },
':': { key: keys.Semicolon, shift: true },
'"': { key: keys.Quote, shift: true },
'~': { key: keys.Backquote, shift: true },
'<': { key: keys.Comma, shift: true },
'>': { key: keys.Period, shift: true },
'?': { key: keys.Slash, shift: true },
// Whitespace and control
' ': { hidCode: keys.Space, shift: false },
'\t': { hidCode: keys.Tab, shift: false },
'\n': { hidCode: keys.Enter, shift: false },
'\r': { hidCode: keys.Enter, shift: false },
' ': { key: keys.Space, shift: false },
'\t': { key: keys.Tab, shift: false },
'\n': { key: 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
* @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'
// Bottom row layouts for different OS
export const osBottomRows: Record<KeyboardOsType, string[]> = {
// Windows: Ctrl - Win - Alt - Space - Alt - Win - Menu - Ctrl
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
// Mac: Ctrl - Option - Cmd - Space - Cmd - Option - Ctrl
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'],
mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'],
// Android: simplified layout
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']
// Media key display names
export const mediaKeyLabels: Record<string, string> = {
PlayPause: '⏯',
Stop: '⏹',
@@ -81,158 +19,3 @@ export const mediaKeyLabels: Record<string, string> = {
VolumeUp: '🔊',
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
// [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)
import { CanonicalKey } from '@/types/generated'
export const keys = {
// Letters
KeyA: 0x04,
KeyB: 0x05,
KeyC: 0x06,
KeyD: 0x07,
KeyE: 0x08,
KeyF: 0x09,
KeyG: 0x0a,
KeyH: 0x0b,
KeyI: 0x0c,
KeyJ: 0x0d,
KeyK: 0x0e,
KeyL: 0x0f,
KeyM: 0x10,
KeyN: 0x11,
KeyO: 0x12,
KeyP: 0x13,
KeyQ: 0x14,
KeyR: 0x15,
KeyS: 0x16,
KeyT: 0x17,
KeyU: 0x18,
KeyV: 0x19,
KeyW: 0x1a,
KeyX: 0x1b,
KeyY: 0x1c,
KeyZ: 0x1d,
// Numbers
Digit1: 0x1e,
Digit2: 0x1f,
Digit3: 0x20,
Digit4: 0x21,
Digit5: 0x22,
Digit6: 0x23,
Digit7: 0x24,
Digit8: 0x25,
Digit9: 0x26,
Digit0: 0x27,
// Control keys
Enter: 0x28,
Escape: 0x29,
Backspace: 0x2a,
Tab: 0x2b,
Space: 0x2c,
// Symbols
Minus: 0x2d,
Equal: 0x2e,
BracketLeft: 0x2f,
BracketRight: 0x30,
Backslash: 0x31,
Semicolon: 0x33,
Quote: 0x34,
Backquote: 0x35,
Comma: 0x36,
Period: 0x37,
Slash: 0x38,
// Lock keys
CapsLock: 0x39,
// Function keys
F1: 0x3a,
F2: 0x3b,
F3: 0x3c,
F4: 0x3d,
F5: 0x3e,
F6: 0x3f,
F7: 0x40,
F8: 0x41,
F9: 0x42,
F10: 0x43,
F11: 0x44,
F12: 0x45,
// Control cluster
PrintScreen: 0x46,
ScrollLock: 0x47,
Pause: 0x48,
Insert: 0x49,
Home: 0x4a,
PageUp: 0x4b,
Delete: 0x4c,
End: 0x4d,
PageDown: 0x4e,
// Arrow keys
ArrowRight: 0x4f,
ArrowLeft: 0x50,
ArrowDown: 0x51,
ArrowUp: 0x52,
// Numpad
NumLock: 0x53,
NumpadDivide: 0x54,
NumpadMultiply: 0x55,
NumpadSubtract: 0x56,
NumpadAdd: 0x57,
NumpadEnter: 0x58,
Numpad1: 0x59,
Numpad2: 0x5a,
Numpad3: 0x5b,
Numpad4: 0x5c,
Numpad5: 0x5d,
Numpad6: 0x5e,
Numpad7: 0x5f,
Numpad8: 0x60,
Numpad9: 0x61,
Numpad0: 0x62,
NumpadDecimal: 0x63,
// Non-US keys
IntlBackslash: 0x64,
ContextMenu: 0x65,
Menu: 0x65,
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,
KeyA: CanonicalKey.KeyA,
KeyB: CanonicalKey.KeyB,
KeyC: CanonicalKey.KeyC,
KeyD: CanonicalKey.KeyD,
KeyE: CanonicalKey.KeyE,
KeyF: CanonicalKey.KeyF,
KeyG: CanonicalKey.KeyG,
KeyH: CanonicalKey.KeyH,
KeyI: CanonicalKey.KeyI,
KeyJ: CanonicalKey.KeyJ,
KeyK: CanonicalKey.KeyK,
KeyL: CanonicalKey.KeyL,
KeyM: CanonicalKey.KeyM,
KeyN: CanonicalKey.KeyN,
KeyO: CanonicalKey.KeyO,
KeyP: CanonicalKey.KeyP,
KeyQ: CanonicalKey.KeyQ,
KeyR: CanonicalKey.KeyR,
KeyS: CanonicalKey.KeyS,
KeyT: CanonicalKey.KeyT,
KeyU: CanonicalKey.KeyU,
KeyV: CanonicalKey.KeyV,
KeyW: CanonicalKey.KeyW,
KeyX: CanonicalKey.KeyX,
KeyY: CanonicalKey.KeyY,
KeyZ: CanonicalKey.KeyZ,
Digit1: CanonicalKey.Digit1,
Digit2: CanonicalKey.Digit2,
Digit3: CanonicalKey.Digit3,
Digit4: CanonicalKey.Digit4,
Digit5: CanonicalKey.Digit5,
Digit6: CanonicalKey.Digit6,
Digit7: CanonicalKey.Digit7,
Digit8: CanonicalKey.Digit8,
Digit9: CanonicalKey.Digit9,
Digit0: CanonicalKey.Digit0,
Enter: CanonicalKey.Enter,
Escape: CanonicalKey.Escape,
Backspace: CanonicalKey.Backspace,
Tab: CanonicalKey.Tab,
Space: CanonicalKey.Space,
Minus: CanonicalKey.Minus,
Equal: CanonicalKey.Equal,
BracketLeft: CanonicalKey.BracketLeft,
BracketRight: CanonicalKey.BracketRight,
Backslash: CanonicalKey.Backslash,
Semicolon: CanonicalKey.Semicolon,
Quote: CanonicalKey.Quote,
Backquote: CanonicalKey.Backquote,
Comma: CanonicalKey.Comma,
Period: CanonicalKey.Period,
Slash: CanonicalKey.Slash,
CapsLock: CanonicalKey.CapsLock,
F1: CanonicalKey.F1,
F2: CanonicalKey.F2,
F3: CanonicalKey.F3,
F4: CanonicalKey.F4,
F5: CanonicalKey.F5,
F6: CanonicalKey.F6,
F7: CanonicalKey.F7,
F8: CanonicalKey.F8,
F9: CanonicalKey.F9,
F10: CanonicalKey.F10,
F11: CanonicalKey.F11,
F12: CanonicalKey.F12,
PrintScreen: CanonicalKey.PrintScreen,
ScrollLock: CanonicalKey.ScrollLock,
Pause: CanonicalKey.Pause,
Insert: CanonicalKey.Insert,
Home: CanonicalKey.Home,
PageUp: CanonicalKey.PageUp,
Delete: CanonicalKey.Delete,
End: CanonicalKey.End,
PageDown: CanonicalKey.PageDown,
ArrowRight: CanonicalKey.ArrowRight,
ArrowLeft: CanonicalKey.ArrowLeft,
ArrowDown: CanonicalKey.ArrowDown,
ArrowUp: CanonicalKey.ArrowUp,
NumLock: CanonicalKey.NumLock,
NumpadDivide: CanonicalKey.NumpadDivide,
NumpadMultiply: CanonicalKey.NumpadMultiply,
NumpadSubtract: CanonicalKey.NumpadSubtract,
NumpadAdd: CanonicalKey.NumpadAdd,
NumpadEnter: CanonicalKey.NumpadEnter,
Numpad1: CanonicalKey.Numpad1,
Numpad2: CanonicalKey.Numpad2,
Numpad3: CanonicalKey.Numpad3,
Numpad4: CanonicalKey.Numpad4,
Numpad5: CanonicalKey.Numpad5,
Numpad6: CanonicalKey.Numpad6,
Numpad7: CanonicalKey.Numpad7,
Numpad8: CanonicalKey.Numpad8,
Numpad9: CanonicalKey.Numpad9,
Numpad0: CanonicalKey.Numpad0,
NumpadDecimal: CanonicalKey.NumpadDecimal,
IntlBackslash: CanonicalKey.IntlBackslash,
ContextMenu: CanonicalKey.ContextMenu,
F13: CanonicalKey.F13,
F14: CanonicalKey.F14,
F15: CanonicalKey.F15,
F16: CanonicalKey.F16,
F17: CanonicalKey.F17,
F18: CanonicalKey.F18,
F19: CanonicalKey.F19,
F20: CanonicalKey.F20,
F21: CanonicalKey.F21,
F22: CanonicalKey.F22,
F23: CanonicalKey.F23,
F24: CanonicalKey.F24,
ControlLeft: CanonicalKey.ControlLeft,
ShiftLeft: CanonicalKey.ShiftLeft,
AltLeft: CanonicalKey.AltLeft,
MetaLeft: CanonicalKey.MetaLeft,
ControlRight: CanonicalKey.ControlRight,
ShiftRight: CanonicalKey.ShiftRight,
AltRight: CanonicalKey.AltRight,
MetaRight: CanonicalKey.MetaRight,
} as const
export type KeyName = keyof typeof keys
// Consumer Control Usage codes (for multimedia keys)
// These are sent via a separate Consumer Control HID report
export const consumerKeys = {
PlayPause: 0x00cd,
Stop: 0x00b7,
@@ -164,69 +135,153 @@ export const consumerKeys = {
export type ConsumerKeyName = keyof typeof consumerKeys
// Modifier bitmasks for HID report byte 0
export const modifiers = {
ControlLeft: 0x01,
ShiftLeft: 0x02,
AltLeft: 0x04,
MetaLeft: 0x08,
ControlRight: 0x10,
ShiftRight: 0x20,
AltRight: 0x40,
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
export const modifiers: Partial<Record<CanonicalKey, number>> = {
[CanonicalKey.ControlLeft]: 0x01,
[CanonicalKey.ShiftLeft]: 0x02,
[CanonicalKey.AltLeft]: 0x04,
[CanonicalKey.MetaLeft]: 0x08,
[CanonicalKey.ControlRight]: 0x10,
[CanonicalKey.ShiftRight]: 0x20,
[CanonicalKey.AltRight]: 0x40,
[CanonicalKey.MetaRight]: 0x80,
}
// Update modifier mask when a HID modifier key is pressed/released.
export function updateModifierMaskForHidKey(mask: number, hidKey: number, press: boolean): number {
const bit = hidKeyToModifierMask[hidKey] ?? 0
export const keyToHidUsage = {
[CanonicalKey.KeyA]: 0x04,
[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
return press ? (mask | bit) : (mask & ~bit)
}
// Keys that latch (toggle state) instead of being held
export const latchingKeys = ['CapsLock', 'ScrollLock', 'NumLock'] as const
// Modifier key names
export const modifierKeyNames = [
'ControlLeft',
'ControlRight',
'ShiftLeft',
'ShiftRight',
'AltLeft',
'AltRight',
'AltGr',
'MetaLeft',
'MetaRight',
export const latchingKeys = [
CanonicalKey.CapsLock,
CanonicalKey.ScrollLock,
CanonicalKey.NumLock,
] 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.
export function normalizeKeyboardCode(code: string, key: string): string {
if (code === 'IntlBackslash' && (key === '`' || key === '~')) return 'Backquote'
@@ -238,18 +293,10 @@ export function normalizeKeyboardCode(code: string, key: string): string {
return code
}
// Convert KeyboardEvent.code/key to USB HID usage code.
export function keyboardEventToHidCode(code: string, key: string): number | undefined {
export function keyboardEventToCanonicalKey(code: string, key: string): CanonicalKey | undefined {
const normalizedCode = normalizeKeyboardCode(code, key)
return keys[normalizedCode as KeyName]
}
// 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
if (normalizedCode in keys) {
return keys[normalizedCode as KeyName]
}
return undefined
}

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_otg_udc?: string
hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean
encoder_backend?: string
audio_device?: string
ttyd_enabled?: boolean

View File

@@ -32,7 +32,14 @@ interface HidState {
available: boolean
backend: string
initialized: boolean
online: boolean
supportsAbsoluteMouse: boolean
keyboardLedsEnabled: boolean
ledState: {
numLock: boolean
capsLock: boolean
scrollLock: boolean
}
device: string | null
error: string | null
errorCode: string | null
@@ -86,9 +93,19 @@ export interface HidDeviceInfo {
available: boolean
backend: string
initialized: boolean
online: 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
error: string | null
error_code?: string | null
}
export interface MsdDeviceInfo {
@@ -115,12 +132,18 @@ export interface AudioDeviceInfo {
error: string | null
}
export interface TtydDeviceInfo {
available: boolean
running: boolean
}
export interface DeviceInfoEvent {
video: VideoDeviceInfo
hid: HidDeviceInfo
msd: MsdDeviceInfo | null
atx: AtxDeviceInfo | null
audio: AudioDeviceInfo | null
ttyd: TtydDeviceInfo
}
export const useSystemStore = defineStore('system', () => {
@@ -183,10 +206,17 @@ export const useSystemStore = defineStore('system', () => {
available: state.available,
backend: state.backend,
initialized: state.initialized,
online: state.online,
supportsAbsoluteMouse: state.supports_absolute_mouse,
device: null,
error: null,
errorCode: null,
keyboardLedsEnabled: state.keyboard_leds_enabled,
ledState: {
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
} catch (e) {
@@ -286,11 +316,17 @@ export const useSystemStore = defineStore('system', () => {
available: data.hid.available,
backend: data.hid.backend,
initialized: data.hid.initialized,
online: data.hid.online,
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,
error: data.hid.error,
// system.device_info does not include HID error_code, keep latest one when error still exists.
errorCode: data.hid.error ? (hid.value?.errorCode ?? null) : null,
errorCode: data.hid.error_code ?? null,
}
// 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 {
version,
buildDate,
@@ -406,7 +420,6 @@ export const useSystemStore = defineStore('system', () => {
updateWsConnection,
updateHidWsConnection,
updateFromDeviceInfo,
updateHidStateFromEvent,
updateStreamClients,
setStreamOnline,
}

View File

@@ -58,12 +58,8 @@ export interface OtgDescriptorConfig {
export enum OtgHidProfile {
/** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */
Full = "full",
/** Full HID device set without MSD */
FullNoMsd = "full_no_msd",
/** Full HID device set without consumer control */
FullNoConsumer = "full_no_consumer",
/** Full HID device set without consumer control and MSD */
FullNoConsumerNoMsd = "full_no_consumer_no_msd",
/** Legacy profile: only keyboard */
LegacyKeyboard = "legacy_keyboard",
/** Legacy profile: only relative mouse */
@@ -72,6 +68,18 @@ export enum OtgHidProfile {
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) */
export interface OtgHidFunctions {
keyboard: boolean;
@@ -84,18 +92,18 @@ export interface OtgHidFunctions {
export interface HidConfig {
/** HID backend type */
backend: HidBackend;
/** OTG keyboard device path */
otg_keyboard: string;
/** OTG mouse device path */
otg_mouse: string;
/** OTG UDC (USB Device Controller) name */
otg_udc?: string;
/** OTG USB device descriptor configuration */
otg_descriptor?: OtgDescriptorConfig;
/** OTG HID function profile */
otg_profile?: OtgHidProfile;
/** OTG endpoint budget policy */
otg_endpoint_budget?: OtgEndpointBudget;
/** OTG HID function selection (used when profile is Custom) */
otg_functions?: OtgHidFunctions;
/** Enable keyboard LED/status feedback for OTG keyboard */
otg_keyboard_leds?: boolean;
/** CH9329 serial port */
ch9329_port: string;
/** CH9329 baud rate */
@@ -118,7 +126,7 @@ export enum AtxDriverType {
Gpio = "gpio",
/** USB HID relay module */
UsbRelay = "usbrelay",
/** Serial/COM port relay (LCUS type) */
/** Serial/COM port relay (taobao LCUS type) */
Serial = "serial",
/** Disabled / Not configured */
None = "none",
@@ -149,6 +157,7 @@ export interface AtxKeyConfig {
* Pin or channel number:
* - For GPIO: GPIO pin number
* - For USB Relay: relay channel (0-based)
* - For Serial Relay (LCUS): relay channel (1-based)
*/
pin: number;
/** Active level (only applicable to GPIO, ignored for USB Relay) */
@@ -444,11 +453,11 @@ export interface AtxConfigUpdate {
/** Available ATX devices for discovery */
export interface AtxDevices {
/** Available GPIO chips (/dev/gpiochip*) */
/** Available Serial ports (/dev/ttyUSB*) */
serial_ports: string[];
gpio_chips: string[];
/** Available USB HID relay devices (/dev/hidraw*) */
usb_relays: string[];
/** Available Serial ports (/dev/ttyUSB*) */
serial_ports: string[];
}
export interface AudioConfigUpdate {
@@ -579,7 +588,9 @@ export interface HidConfigUpdate {
otg_udc?: string;
otg_descriptor?: OtgDescriptorConfigUpdate;
otg_profile?: OtgHidProfile;
otg_endpoint_budget?: OtgEndpointBudget;
otg_functions?: OtgHidFunctionsUpdate;
otg_keyboard_leds?: boolean;
mouse_absolute?: boolean;
}
@@ -623,19 +634,19 @@ export interface RustDeskConfigUpdate {
device_password?: string;
}
/** Stream 配置响应(包含 has_turn_password 字段) */
/** Stream configuration response (includes has_turn_password) */
export interface StreamConfigResponse {
mode: StreamMode;
encoder: EncoderType;
bitrate_preset: BitratePreset;
/** 是否有公共 ICE 服务器可用(编译时确定) */
/** Whether public ICE servers are available (compile-time decision) */
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;
stun_server?: string;
turn_server?: string;
turn_username?: string;
/** 指示是否已设置 TURN 密码(实际密码不返回) */
/** Indicates whether TURN password has been configured (password is not returned) */
has_turn_password: boolean;
}
@@ -666,12 +677,6 @@ export interface TtydConfigUpdate {
shell?: string;
}
/** Simple ttyd status for console view */
export interface TtydStatus {
available: boolean;
running: boolean;
}
export interface VideoConfigUpdate {
device?: string;
format?: string;
@@ -688,3 +693,130 @@ export interface WebConfigUpdate {
bind_address?: string;
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
// Shared between WebRTC DataChannel and WebSocket HID channels
import { type CanonicalKey } from '@/types/generated'
import { canonicalKeyToHidUsage } from '@/lib/keyboardMappings'
/** Keyboard event for HID input */
export interface HidKeyboardEvent {
type: 'keydown' | 'keyup'
key: number
key: CanonicalKey
/** Raw HID modifier byte (bit0..bit7 = LCtrl..RMeta) */
modifier?: number
}
@@ -51,7 +54,7 @@ export function encodeKeyboardEvent(event: HidKeyboardEvent): ArrayBuffer {
view.setUint8(0, MSG_KEYBOARD)
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 File

@@ -1,5 +1,5 @@
<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 { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system'
@@ -11,9 +11,10 @@ import { useHidWebSocket } from '@/composables/useHidWebSocket'
import { useWebRTC } from '@/composables/useWebRTC'
import { useVideoSession } from '@/composables/useVideoSession'
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 { keyboardEventToHidCode, updateModifierMaskForHidKey } from '@/lib/keyboardMappings'
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
import { toast } from 'vue-sonner'
import { generateUUID } from '@/lib/utils'
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
@@ -81,7 +82,6 @@ const consoleEvents = useConsoleEvents({
onStreamDeviceLost: handleStreamDeviceLost,
onStreamRecovered: handleStreamRecovered,
onDeviceInfo: handleDeviceInfo,
onAudioStateChanged: handleAudioStateChanged,
})
// Video mode state
@@ -118,10 +118,13 @@ const myClientId = generateUUID()
// HID state
const mouseMode = ref<'absolute' | 'relative'>('absolute')
const pressedKeys = ref<string[]>([])
const keyboardLed = ref({
capsLock: false,
})
const pressedKeys = ref<CanonicalKey[]>([])
const keyboardLed = computed(() => ({
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 mousePosition = ref({ x: 0, y: 0 })
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)
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
let interactionListenersBound = false
const isConsoleActive = ref(false)
function syncMouseModeFromConfig() {
const mouseAbsolute = configStore.hid?.mouse_absolute
@@ -151,6 +156,12 @@ function syncMouseModeFromConfig() {
const virtualKeyboardVisible = ref(false)
const virtualKeyboardAttached = ref(true)
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
const changePasswordDialogOpen = ref(false)
@@ -162,7 +173,6 @@ const changingPassword = ref(false)
// ttyd (web terminal) state
const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
const showTerminalDialog = ref(false)
let ttydPollInterval: ReturnType<typeof setInterval> | null = null
// Theme
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 (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'
return 'disconnected'
})
@@ -227,6 +241,7 @@ const videoDetails = computed<StatusDetail[]>(() => {
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const hid = systemStore.hid
if (hid?.errorCode === 'udc_not_configured') return 'disconnected'
if (hid?.error) return 'error'
// In WebRTC mode, check DataChannel status first
@@ -251,8 +266,8 @@ const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'
if (hidWs.hidUnavailable.value) return 'disconnected'
// Normal status based on system state
if (hid?.available && hid.initialized) return 'connected'
if (hid?.available && !hid.initialized) return 'connecting'
if (hid?.available && hid.online) return 'connected'
if (hid?.available && hid.initialized) return 'connecting'
return 'disconnected'
})
@@ -264,29 +279,54 @@ const hidQuickInfo = computed(() => {
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) {
case 'udc_not_configured':
return t('hid.errorHints.udcNotConfigured')
case 'disabled':
return t('hid.errorHints.disabled')
case 'enoent':
return t('hid.errorHints.hidDeviceMissing')
case 'not_opened':
return t('hid.errorHints.notOpened')
case 'port_not_found':
case 'port_not_opened':
return t('hid.errorHints.portNotFound')
case 'invalid_config':
return t('hid.errorHints.invalidConfig')
case 'no_response':
return t('hid.errorHints.noResponse')
return t(ch9329Command ? 'hid.errorHints.noResponseWithCmd' : 'hid.errorHints.noResponse', {
cmd: ch9329Command ?? '',
})
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 'enxio':
case 'enodev':
return t('hid.errorHints.deviceDisconnected')
case 'eio':
case 'epipe':
case 'eshutdown':
case 'io_error':
case 'write_failed':
case 'read_failed':
if (backend === 'otg') return t('hid.errorHints.otgIoError')
if (backend === 'ch9329') return t('hid.errorHints.ch9329IoError')
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:
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 {
if (!reason && !errorCode) return ''
const hint = hidErrorHint(errorCode, backend)
if (reason && hint) return `${reason} (${hint})`
const hint = hidErrorHint(errorCode, backend, reason)
if (hint) return hint
if (reason) return reason
return hint || t('common.error')
}
@@ -309,19 +349,29 @@ const hidDetails = computed<StatusDetail[]>(() => {
const hid = systemStore.hid
if (!hid) return []
const errorMessage = buildHidErrorMessage(hid.error, hid.errorCode, hid.backend)
const hidErrorStatus: StatusDetail['status'] =
hid.errorCode === 'udc_not_configured' ? 'warning' : 'error'
const details: StatusDetail[] = [
{ label: t('statusCard.device'), value: hid.device || '-' },
{ 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('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) {
details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: 'error' })
details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: hidErrorStatus })
}
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
@@ -581,6 +631,7 @@ function waitForVideoFirstFrame(el: HTMLVideoElement, timeoutMs = 2000): Promise
function shouldSuppressAutoReconnect(): boolean {
return videoMode.value === 'mjpeg'
|| !isConsoleActive.value
|| videoSession.localSwitching.value
|| videoSession.backendSwitching.value
|| videoRestarting.value
@@ -932,7 +983,22 @@ async function restoreInitialMode(serverMode: VideoMode) {
}
function handleDeviceInfo(data: any) {
const prevAudioStreaming = systemStore.audio?.streaming ?? false
const prevAudioDevice = systemStore.audio?.device ?? null
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
// This prevents false-positive mode changes during config switching
@@ -1440,14 +1506,6 @@ async function handleChangePassword() {
}
// ttyd (web terminal) functions
async function fetchTtydStatus() {
try {
ttydStatus.value = await extensionsApi.getTtydStatus()
} catch {
ttydStatus.value = null
}
}
function openTerminal() {
if (!ttydStatus.value?.running) return
showTerminalDialog.value = true
@@ -1500,7 +1558,7 @@ function handleHidError(_error: any, _operation: string) {
}
// 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
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
const event: HidKeyboardEvent = {
@@ -1579,22 +1637,19 @@ function handleKeyDown(e: KeyboardEvent) {
})
}
const keyName = e.key === ' ' ? 'Space' : e.key
if (!pressedKeys.value.includes(keyName)) {
pressedKeys.value = [...pressedKeys.value, keyName]
}
keyboardLed.value.capsLock = e.getModifierState('CapsLock')
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
if (canonicalKey === undefined) {
console.warn(`[HID] Unmapped key down: code=${e.code}, key=${e.key}`)
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
sendKeyboardEvent('down', hidKey, modifierMask)
sendKeyboardEvent('down', canonicalKey, modifierMask)
}
function handleKeyUp(e: KeyboardEvent) {
@@ -1610,30 +1665,99 @@ function handleKeyUp(e: KeyboardEvent) {
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) {
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
if (canonicalKey === undefined) {
console.warn(`[HID] Unmapped key up: code=${e.code}, key=${e.key}`)
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
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) {
// Use the appropriate video element based on current mode (WebRTC for h264/h265/vp8/vp9, MJPEG for mjpeg)
const videoElement = videoMode.value !== 'mjpeg' ? webrtcVideoRef.value : videoRef.value
const videoElement = getActiveVideoElement()
if (!videoElement) return
if (mouseMode.value === 'absolute') {
// Absolute mode: send absolute coordinates (0-32767 range)
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)
const absolutePosition = getAbsoluteMousePosition(e)
if (!absolutePosition) return
const { x, y } = absolutePosition
mousePosition.value = { x, y }
// Queue for throttled sending (absolute mode: just update pending position)
@@ -1758,6 +1882,15 @@ function handleMouseDown(e: MouseEvent) {
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'
pressedMouseButton.value = button
sendMouseEvent({ type: 'down', button })
@@ -1838,6 +1971,10 @@ function handlePointerLockError() {
isPointerLocked.value = false
}
function handleFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}
function handleBlur() {
pressedKeys.value = []
activeModifierMask.value = 0
@@ -1890,6 +2027,71 @@ function handleMouseSendIntervalStorage(e: StorageEvent) {
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
// (MSD and Settings are now handled by ActionBar component directly)
@@ -1898,18 +2100,14 @@ function handleToggleVirtualKeyboard() {
}
// Virtual keyboard key event handlers
function handleVirtualKeyDown(key: string) {
function handleVirtualKeyDown(key: CanonicalKey) {
// Add to pressedKeys for InfoBar display
if (!pressedKeys.value.includes(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
pressedKeys.value = pressedKeys.value.filter(k => k !== key)
}
@@ -1961,40 +2159,18 @@ onMounted(async () => {
syncMouseModeFromConfig()
}).catch(() => {})
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', handleBlur)
window.addEventListener('mouseup', handleWindowMouseUp)
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, () => {
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')
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
isDark.value = true
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
// The handleDeviceInfo function will automatically switch to the server's mode
// localStorage preference is only used when server mode matches
@@ -2009,7 +2185,17 @@ onMounted(async () => {
}
})
onActivated(() => {
void activateConsoleView()
})
onDeactivated(() => {
deactivateConsoleView()
})
onUnmounted(() => {
deactivateConsoleView()
// Reset initial device info flag
initialDeviceInfoReceived = false
initialModeRestoreDone = false
@@ -2021,12 +2207,6 @@ onUnmounted(() => {
mouseFlushTimer = null
}
// Clear ttyd poll interval
if (ttydPollInterval) {
clearInterval(ttydPollInterval)
ttydPollInterval = null
}
// Clear all timers
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
@@ -2051,18 +2231,6 @@ onUnmounted(() => {
// Exit pointer lock if active
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>
@@ -2367,6 +2535,9 @@ onUnmounted(() => {
v-if="virtualKeyboardVisible"
v-model:visible="virtualKeyboardVisible"
v-model:attached="virtualKeyboardAttached"
:caps-lock="keyboardLed.capsLock"
:pressed-keys="pressedKeys"
:consumer-enabled="virtualKeyboardConsumerEnabled"
@key-down="handleVirtualKeyDown"
@key-up="handleVirtualKeyUp"
/>
@@ -2379,6 +2550,9 @@ onUnmounted(() => {
<InfoBar
:pressed-keys="pressedKeys"
:caps-lock="keyboardLed.capsLock"
:num-lock="keyboardLed.numLock"
:scroll-lock="keyboardLed.scrollLock"
:keyboard-led-enabled="keyboardLedEnabled"
:mouse-position="mousePosition"
:debug-mode="false"
/>

View File

@@ -3,6 +3,15 @@ import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
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'
const { t } = useI18n()
@@ -10,12 +19,18 @@ const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const currentLanguage = ref<SupportedLocale>(getCurrentLanguage())
const username = ref('')
const password = ref('')
const showPassword = ref(false)
const loading = ref(false)
const error = ref('')
function handleLanguageChange(lang: SupportedLocale) {
currentLanguage.value = lang
setLanguage(lang)
}
async function handleLogin() {
if (!username.value) {
error.value = t('auth.enterUsername')
@@ -40,76 +55,83 @@ async function handleLogin() {
loading.value = false
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleLogin()
}
}
</script>
<template>
<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">
<!-- Logo and Title -->
<div class="text-center space-y-2">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10">
<Card class="relative w-full max-w-sm">
<div class="absolute top-4 right-4 flex gap-2">
<Button
:variant="currentLanguage === 'zh-CN' ? 'default' : 'outline'"
size="sm"
@click="handleLanguageChange('zh-CN')"
>
中文
</Button>
<Button
:variant="currentLanguage === 'en-US' ? 'default' : 'outline'"
size="sm"
@click="handleLanguageChange('en-US')"
>
English
</Button>
</div>
<CardHeader class="space-y-2 pt-10 text-center sm:pt-12">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
<Monitor class="w-8 h-8 text-primary" />
</div>
<h1 class="text-2xl font-bold text-foreground">One-KVM</h1>
<p class="text-sm text-muted-foreground">{{ t('auth.loginPrompt') }}</p>
</div>
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
<CardDescription>{{ t('auth.login') }}</CardDescription>
</CardHeader>
<!-- Login Form -->
<div class="space-y-4">
<!-- Username Input -->
<div class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
<User class="w-4 h-4" />
<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"
type="text"
:placeholder="t('auth.username')"
class="pl-10"
/>
</div>
</div>
<input
v-model="username"
type="text"
: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"
@keydown="handleKeydown"
/>
</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 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"
:type="showPassword ? 'text' : 'password'"
:placeholder="t('auth.password')"
class="pl-10 pr-10"
/>
<button
type="button"
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')"
@click="showPassword = !showPassword"
>
<Eye v-if="!showPassword" class="w-4 h-4" />
<EyeOff v-else class="w-4 h-4" />
</button>
</div>
</div>
<input
v-model="password"
:type="showPassword ? 'text' : '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"
@keydown="handleKeydown"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
:aria-label="showPassword ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')"
@click="showPassword = !showPassword"
>
<Eye v-if="!showPassword" class="w-4 h-4" />
<EyeOff v-else class="w-4 h-4" />
</button>
</div>
<button
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"
@click="handleLogin"
>
<span v-if="loading">{{ t('common.loading') }}</span>
<span v-else>{{ t('auth.login') }}</span>
</button>
<p v-if="error" class="text-center text-sm text-destructive">{{ error }}</p>
<p v-if="error" class="text-sm text-destructive text-center">{{ error }}</p>
</div>
</div>
<Button type="submit" class="w-full" :disabled="loading">
<span v-if="loading">{{ t('common.loading') }}</span>
<span v-else>{{ t('auth.login') }}</span>
</Button>
</form>
</CardContent>
</Card>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth'
@@ -24,6 +25,7 @@ import {
type UpdateOverviewResponse,
type UpdateStatusResponse,
type UpdateChannel,
type VideoEncoderSelfCheckResponse,
} from '@/api'
import type {
ExtensionsStatus,
@@ -31,11 +33,13 @@ import type {
AtxDriverType,
ActiveLevel,
AtxDevices,
OtgEndpointBudget,
OtgHidProfile,
OtgHidFunctions,
} from '@/types/generated'
import { setLanguage } from '@/i18n'
import { useClipboard } from '@/composables/useClipboard'
import { getVideoFormatState } from '@/lib/video-format-support'
import AppLayout from '@/components/AppLayout.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -82,6 +86,7 @@ import {
} from 'lucide-vue-next'
const { t, te, locale } = useI18n()
const route = useRoute()
const systemStore = useSystemStore()
const configStore = useConfigStore()
const authStore = useAuthStore()
@@ -91,6 +96,21 @@ const activeSection = ref('appearance')
const mobileMenuOpen = ref(false)
const loading = 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
const navGroups = computed(() => [
@@ -134,6 +154,10 @@ function selectSection(id: string) {
mobileMenuOpen.value = false
}
function normalizeSettingsSection(value: unknown): string | null {
return typeof value === 'string' && SETTINGS_SECTION_IDS.has(value) ? value : null
}
// Theme
const theme = ref<'light' | 'dark' | 'system'>('system')
@@ -304,13 +328,15 @@ const config = ref({
hid_serial_device: '',
hid_serial_baudrate: 9600,
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: {
keyboard: true,
mouse_relative: true,
mouse_absolute: true,
consumer: true,
} as OtgHidFunctions,
hid_otg_keyboard_leds: false,
msd_enabled: false,
msd_dir: '',
encoder_backend: 'auto',
@@ -323,20 +349,6 @@ const config = ref({
// Tracks whether TURN password is configured on the server
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 OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped'
@@ -539,28 +551,138 @@ async function onRunOtgSelfCheckClick() {
await runOtgSelfCheck()
}
function alignHidProfileForLowEndpoint() {
if (hidProfileAligned.value) return
if (!configLoaded.value || !devicesLoaded.value) return
if (config.value.hid_backend !== 'otg') {
hidProfileAligned.value = true
return
type VideoEncoderSelfCheckCell = VideoEncoderSelfCheckResponse['rows'][number]['cells'][number]
type VideoEncoderSelfCheckRow = VideoEncoderSelfCheckResponse['rows'][number]
const videoEncoderSelfCheckLoading = ref(false)
const videoEncoderSelfCheckResult = ref<VideoEncoderSelfCheckResponse | null>(null)
const videoEncoderSelfCheckError = ref('')
const videoEncoderRunButtonPressed = ref(false)
function videoEncoderCell(row: VideoEncoderSelfCheckRow, codecId: string): VideoEncoderSelfCheckCell | undefined {
return row.cells.find(cell => cell.codec_id === codecId)
}
const currentHardwareEncoderText = computed(() =>
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
}
function videoEncoderCellClass(ok: boolean | undefined): string {
return ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
}
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
}
if (!isLowEndpointUdc.value) {
hidProfileAligned.value = true
return
}
async function onRunVideoEncoderSelfCheckClick() {
if (!videoEncoderSelfCheckLoading.value) {
videoEncoderRunButtonPressed.value = true
window.setTimeout(() => {
videoEncoderRunButtonPressed.value = false
}, 160)
}
if (config.value.hid_otg_profile === 'full') {
config.value.hid_otg_profile = 'full_no_consumer' as OtgHidProfile
} else if (config.value.hid_otg_profile === 'full_no_msd') {
config.value.hid_otg_profile = 'full_no_consumer_no_msd' as OtgHidProfile
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(() => {
if (config.value.hid_backend !== 'otg') return true
if (config.value.hid_otg_profile !== 'custom') return true
const f = config.value.hid_otg_functions
return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer)
})
@@ -659,6 +781,21 @@ const availableFormats = computed(() => {
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(() => {
if (!selectedDevice.value || !config.value.video_format) return null
return selectedDevice.value.formats.find(f => f.format === config.value.video_format)
@@ -689,17 +826,22 @@ const availableFps = computed(() => {
return currentRes ? currentRes.fps : []
})
// Watch for device change to set default format
watch(() => config.value.video_device, () => {
if (availableFormats.value.length > 0) {
const isValid = availableFormats.value.some(f => f.format === config.value.video_format)
if (!isValid) {
config.value.video_format = availableFormats.value[0]?.format || ''
// Keep the selected format aligned with currently selectable formats.
watch(
selectableFormats,
() => {
if (selectableFormats.value.length === 0) {
config.value.video_format = ''
return
}
} else {
config.value.video_format = ''
}
})
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(() => config.value.video_format, () => {
@@ -866,26 +1008,9 @@ async function saveConfig() {
// HID config
if (activeSection.value === 'hid') {
if (!isHidFunctionSelectionValid.value) {
if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
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 = {
backend: config.value.hid_backend as any,
ch9329_port: config.value.hid_serial_device || undefined,
@@ -900,16 +1025,15 @@ async function saveConfig() {
product: otgProduct.value || 'One-KVM USB Device',
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_keyboard_leds = config.value.hid_otg_keyboard_leds
}
savePromises.push(configStore.updateHid(hidUpdate))
if (config.value.msd_enabled !== desiredMsdEnabled) {
config.value.msd_enabled = desiredMsdEnabled
}
savePromises.push(
configStore.updateMsd({
enabled: desiredMsdEnabled,
enabled: config.value.msd_enabled,
})
)
}
@@ -954,13 +1078,15 @@ async function loadConfig() {
hid_serial_device: hid.ch9329_port || '',
hid_serial_baudrate: hid.ch9329_baudrate || 9600,
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: {
keyboard: hid.otg_functions?.keyboard ?? true,
mouse_relative: hid.otg_functions?.mouse_relative ?? true,
mouse_absolute: hid.otg_functions?.mouse_absolute ?? true,
consumer: hid.otg_functions?.consumer ?? true,
} as OtgHidFunctions,
hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false,
msd_enabled: msd.enabled || false,
msd_dir: msd.msd_dir || '',
encoder_backend: stream.encoder || 'auto',
@@ -985,9 +1111,6 @@ async function loadConfig() {
} catch (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()
} catch (e) {
console.error('Failed to load devices:', e)
} finally {
devicesLoaded.value = true
alignHidProfileForLowEndpoint()
}
}
@@ -1781,17 +1901,23 @@ onMounted(async () => {
if (updateRunning.value) {
startUpdatePolling()
}
await runOtgSelfCheck()
})
watch(updateChannel, async () => {
await loadUpdateOverview()
})
watch(() => config.value.hid_backend, async () => {
await runOtgSelfCheck()
watch(() => config.value.hid_backend, () => {
otgSelfCheckResult.value = null
otgSelfCheckError.value = ''
})
watch(() => route.query.tab, (tab) => {
const section = normalizeSettingsSection(tab)
if (section && activeSection.value !== section) {
selectSection(section)
}
}, { immediate: true })
</script>
<template>
@@ -1986,7 +2112,14 @@ watch(() => config.value.hid_backend, async () => {
<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">
<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>
</div>
<div class="grid gap-4 sm:grid-cols-2">
@@ -2144,63 +2277,75 @@ watch(() => config.value.hid_backend, async () => {
<p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p>
</div>
<div class="space-y-2">
<Label for="otg-profile">{{ t('settings.profile') }}</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">
<option value="full">{{ t('settings.otgProfileFull') }}</option>
<option value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</option>
<option value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</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>
<Label for="otg-endpoint-budget">{{ t('settings.otgEndpointBudget') }}</Label>
<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="five">5</option>
<option value="six">6</option>
<option value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</option>
</select>
<p class="text-xs text-muted-foreground">{{ otgEndpointUsageText }}</p>
</div>
<div v-if="config.hid_otg_profile === 'custom'" 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 class="space-y-3">
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseRelativeDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_relative" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMouseAbsolute') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseAbsoluteDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
</div>
<Switch v-model="config.hid_otg_functions.keyboard" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseRelativeDesc') }}</p>
<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 />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionConsumer') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionConsumerDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.consumer" />
</div>
<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>
<Switch v-model="config.hid_otg_functions.mouse_relative" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMouseAbsolute') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseAbsoluteDesc') }}</p>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
</div>
<Switch v-model="config.msd_enabled" />
</div>
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionConsumer') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionConsumerDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.consumer" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
</div>
<Switch v-model="config.msd_enabled" />
</div>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgProfileWarning') }}
</p>
<p v-if="showLowEndpointHint" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgLowEndpointHint') }}
<p v-if="showOtgEndpointBudgetHint" 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: describeEndpointBudget(config.hid_otg_endpoint_budget) }) }}
</p>
</div>
<Separator class="my-4" />
@@ -2364,6 +2509,86 @@ watch(() => config.value.hid_backend, async () => {
</p>
</CardContent>
</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>
<!-- Access Section -->

View File

@@ -97,7 +97,12 @@ const ch9329Port = ref('')
const ch9329Baudrate = ref(9600)
const otgUdc = ref('')
const hidOtgProfile = ref('full')
const otgMsdEnabled = ref(true)
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
const otgKeyboardLeds = ref(true)
const otgProfileTouched = ref(false)
const otgEndpointBudgetTouched = ref(false)
const otgKeyboardLedsTouched = ref(false)
const showAdvancedOtg = ref(false)
// Extension settings
@@ -203,19 +208,67 @@ const availableFps = computed(() => {
return resolution?.fps || []
})
const isLowEndpointUdc = computed(() => {
if (otgUdc.value) {
return /musb/i.test(otgUdc.value)
function defaultOtgEndpointBudgetForUdc(udc?: string): 'five' | 'six' {
return /musb/i.test(udc || '') ? 'five' : 'six'
}
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',
}
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
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() {
if (otgProfileTouched.value) return
const otgProfileHasKeyboard = computed(() =>
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
const preferred = isLowEndpointUdc.value ? 'full_no_consumer' : 'full'
if (hidOtgProfile.value === preferred) return
hidOtgProfile.value = preferred
const recommendedBudget = defaultOtgEndpointBudgetForUdc(otgUdc.value)
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) {
@@ -223,6 +276,20 @@ function onOtgProfileChange(value: unknown) {
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
const baudRates = [9600, 19200, 38400, 57600, 115200]
@@ -338,16 +405,16 @@ watch(hidBackend, (newBackend) => {
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
otgUdc.value = devices.value.udc[0]?.name || ''
}
applyOtgProfileDefault()
applyOtgDefaults()
})
watch(otgUdc, () => {
applyOtgProfileDefault()
applyOtgDefaults()
})
watch(showAdvancedOtg, (open) => {
if (open) {
applyOtgProfileDefault()
applyOtgDefaults()
}
})
@@ -370,7 +437,7 @@ onMounted(async () => {
if (result.udc.length > 0 && result.udc[0]) {
otgUdc.value = result.udc[0].name
}
applyOtgProfileDefault()
applyOtgDefaults()
// Auto-select audio device if available (and no video device to trigger watch)
if (result.audio.length > 0 && !audioDevice.value) {
@@ -461,6 +528,13 @@ function validateStep3(): boolean {
error.value = t('setup.selectUdc')
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
}
@@ -523,6 +597,9 @@ async function handleSetup() {
if (hidBackend.value === 'otg' && otgUdc.value) {
setupData.hid_otg_udc = otgUdc.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
@@ -990,16 +1067,47 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</SelectTrigger>
<SelectContent>
<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_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</SelectItem>
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
</SelectContent>
</Select>
</div>
<p v-if="isLowEndpointUdc" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('setup.otgLowEndpointHint') }}
<div class="space-y-2">
<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>
</div>
</div>