mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-29 22:56:45 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d7d8b8be | ||
|
|
f95714d9f0 | ||
|
|
7d52b2e2ea | ||
|
|
a2a8b3802d | ||
|
|
f4283f45a4 | ||
|
|
4784cb75e4 | ||
|
|
abc6bd1677 | ||
|
|
1c5288d783 | ||
|
|
6bcb54bd22 | ||
|
|
e20136a5ab | ||
|
|
c8fd3648ad | ||
|
|
6ef2d394d9 | ||
|
|
762a3b037d | ||
|
|
e09a906f93 | ||
|
|
95bf1a852e | ||
|
|
200f947b5d | ||
|
|
46ae0c81e2 | ||
|
|
779aa180ad | ||
|
|
ae26e3c863 | ||
|
|
eeb41159b7 | ||
|
|
24a10aa222 | ||
|
|
c119db4908 | ||
|
|
0db287bf55 | ||
|
|
e229f35777 | ||
|
|
df647b45cd | ||
|
|
b74659dcd4 | ||
|
|
4f2fb534a4 | ||
|
|
bd17f8d0f8 | ||
|
|
cee43795f8 |
@@ -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"
|
||||
|
||||
@@ -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; \
|
||||
|
||||
@@ -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; \
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
1079
src/hid/ch9329.rs
1079
src/hid/ch9329.rs
File diff suppressed because it is too large
Load Diff
@@ -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
409
src/hid/keyboard.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
500
src/hid/mod.rs
500
src/hid/mod.rs
@@ -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,
|
||||
¤t,
|
||||
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,
|
||||
¤t,
|
||||
"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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
480
src/hid/otg.rs
480
src/hid/otg.rs
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
147
src/main.rs
147
src/main.rs
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
72
src/state.rs
72
src/state.rs
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
335
src/video/encoder/self_check.rs
Normal file
335
src/video/encoder/self_check.rs
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
651
src/video/shared_video_pipeline/encoder_state.rs
Normal file
651
src/video/shared_video_pipeline/encoder_state.rs
Normal 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
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>"#
|
||||
|
||||
247
src/web/ws.rs
247
src/web/ws.rs
@@ -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.*"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! Architecture:
|
||||
//! ```text
|
||||
//! VideoCapturer (MJPEG/YUYV)
|
||||
//! V4L2 capture
|
||||
//! |
|
||||
//! v
|
||||
//! SharedVideoPipeline (decode -> convert -> encode)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
4
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 配置
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '配置已应用',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
97
web/src/lib/video-format-support.ts
Normal file
97
web/src/lib/video-format-support.ts
Normal 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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user