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]
|
[package]
|
||||||
name = "one-kvm"
|
name = "one-kvm"
|
||||||
version = "0.1.5"
|
version = "0.1.7"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["SilentWind"]
|
authors = ["SilentWind"]
|
||||||
description = "A open and lightweight IP-KVM solution written in Rust"
|
description = "A open and lightweight IP-KVM solution written in Rust"
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ ARG TARGETPLATFORM
|
|||||||
# Install runtime dependencies in a single layer
|
# Install runtime dependencies in a single layer
|
||||||
# All codec libraries (libx264, libx265, libopus) are now statically linked
|
# All codec libraries (libx264, libx265, libopus) are now statically linked
|
||||||
# Only hardware acceleration drivers and core system libraries remain dynamic
|
# Only hardware acceleration drivers and core system libraries remain dynamic
|
||||||
RUN apt-get update && \
|
RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
|
||||||
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
# Core runtime (all platforms) - no codec libs needed
|
# Core runtime (all platforms) - no codec libs needed
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
@@ -24,7 +25,8 @@ RUN apt-get update && \
|
|||||||
# Platform-specific hardware acceleration
|
# Platform-specific hardware acceleration
|
||||||
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1; \
|
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1 \
|
||||||
|
i965-va-driver-shaders intel-media-va-driver-non-free vainfo; \
|
||||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
libdrm2 libva2; \
|
libdrm2 libva2; \
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ ARG TARGETPLATFORM
|
|||||||
# Install runtime dependencies in a single layer
|
# Install runtime dependencies in a single layer
|
||||||
# All codec libraries (libx264, libx265, libopus) are now statically linked
|
# All codec libraries (libx264, libx265, libopus) are now statically linked
|
||||||
# Only hardware acceleration drivers and core system libraries remain dynamic
|
# Only hardware acceleration drivers and core system libraries remain dynamic
|
||||||
RUN apt-get update && \
|
RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
|
||||||
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
# Core runtime (all platforms) - no codec libs needed
|
# Core runtime (all platforms) - no codec libs needed
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
@@ -24,7 +25,8 @@ RUN apt-get update && \
|
|||||||
# Platform-specific hardware acceleration
|
# Platform-specific hardware acceleration
|
||||||
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1; \
|
libva2 libva-drm2 libva-x11-2 libx11-6 libxcb1 libxau6 libxdmcp6 libmfx1 \
|
||||||
|
i965-va-driver-shaders intel-media-va-driver-non-free vainfo; \
|
||||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
libdrm2 libva2; \
|
libdrm2 libva2; \
|
||||||
|
|||||||
@@ -4,6 +4,68 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
detect_intel_libva_driver() {
|
||||||
|
if [ -n "${LIBVA_DRIVER_NAME:-}" ]; then
|
||||||
|
echo "[INFO] Using preconfigured LIBVA_DRIVER_NAME=$LIBVA_DRIVER_NAME"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(uname -m)" != "x86_64" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local devices=()
|
||||||
|
if [ -n "${LIBVA_DEVICE:-}" ]; then
|
||||||
|
devices=("$LIBVA_DEVICE")
|
||||||
|
else
|
||||||
|
shopt -s nullglob
|
||||||
|
devices=(/dev/dri/renderD*)
|
||||||
|
shopt -u nullglob
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#devices[@]} -eq 0 ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local device=""
|
||||||
|
local node=""
|
||||||
|
local vendor=""
|
||||||
|
local driver=""
|
||||||
|
|
||||||
|
for device in "${devices[@]}"; do
|
||||||
|
if [ ! -e "$device" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
node="$(basename "$device")"
|
||||||
|
vendor=""
|
||||||
|
if [ -r "/sys/class/drm/$node/device/vendor" ]; then
|
||||||
|
vendor="$(cat "/sys/class/drm/$node/device/vendor")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$vendor" ] && [ "$vendor" != "0x8086" ]; then
|
||||||
|
echo "[INFO] Skipping VA-API probe for $device (vendor=$vendor)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
for driver in iHD i965; do
|
||||||
|
if LIBVA_DRIVER_NAME="$driver" vainfo --display drm --device "$device" >/dev/null 2>&1; then
|
||||||
|
export LIBVA_DRIVER_NAME="$driver"
|
||||||
|
if [ -n "$vendor" ]; then
|
||||||
|
echo "[INFO] Detected Intel VA-API driver '$driver' on $device (vendor=$vendor)"
|
||||||
|
else
|
||||||
|
echo "[INFO] Detected Intel VA-API driver '$driver' on $device"
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[WARN] Unable to auto-detect an Intel VA-API driver; leaving LIBVA_DRIVER_NAME unset"
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_intel_libva_driver
|
||||||
|
|
||||||
# Start one-kvm with default options.
|
# Start one-kvm with default options.
|
||||||
# Additional options can be passed via environment variables.
|
# Additional options can be passed via environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ Wants=network-online.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
|
# Example for older Intel GPUs:
|
||||||
|
# Environment=LIBVA_DRIVER_NAME=i965
|
||||||
ExecStart=/usr/bin/one-kvm
|
ExecStart=/usr/bin/one-kvm
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ EOF
|
|||||||
|
|
||||||
# Create control file
|
# Create control file
|
||||||
BASE_DEPS="libc6 (>= 2.31), libgcc-s1, libstdc++6, libasound2 (>= 1.1), libdrm2 (>= 2.4)"
|
BASE_DEPS="libc6 (>= 2.31), libgcc-s1, libstdc++6, libasound2 (>= 1.1), libdrm2 (>= 2.4)"
|
||||||
AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14)"
|
AMD64_DEPS="libva2 (>= 2.0), libva-drm2 (>= 2.10), libva-x11-2 (>= 2.10), libmfx1 (>= 21.1), libx11-6 (>= 1.6), libxcb1 (>= 1.14), i965-va-driver-shaders (>= 2.4), intel-media-va-driver-non-free (>= 21.1)"
|
||||||
DEPS="$BASE_DEPS"
|
DEPS="$BASE_DEPS"
|
||||||
if [ "$DEB_ARCH" = "amd64" ]; then
|
if [ "$DEB_ARCH" = "amd64" ]; then
|
||||||
DEPS="$DEPS, $AMD64_DEPS"
|
DEPS="$DEPS, $AMD64_DEPS"
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
|
|||||||
*out_data = nullptr;
|
*out_data = nullptr;
|
||||||
*out_len = 0;
|
*out_len = 0;
|
||||||
*out_keyframe = 0;
|
*out_keyframe = 0;
|
||||||
|
bool encoded = false;
|
||||||
|
|
||||||
av_packet_unref(ctx->dec_pkt);
|
av_packet_unref(ctx->dec_pkt);
|
||||||
int ret = av_new_packet(ctx->dec_pkt, len);
|
int ret = av_new_packet(ctx->dec_pkt, len);
|
||||||
@@ -290,7 +291,7 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
|
|||||||
while (true) {
|
while (true) {
|
||||||
ret = avcodec_receive_frame(ctx->dec_ctx, ctx->dec_frame);
|
ret = avcodec_receive_frame(ctx->dec_ctx, ctx->dec_frame);
|
||||||
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
|
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
|
||||||
return 0;
|
return encoded ? 1 : 0;
|
||||||
}
|
}
|
||||||
if (ret < 0) {
|
if (ret < 0) {
|
||||||
set_last_error(make_err("avcodec_receive_frame failed", ret));
|
set_last_error(make_err("avcodec_receive_frame failed", ret));
|
||||||
@@ -370,19 +371,27 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
av_packet_unref(ctx->enc_pkt);
|
av_packet_unref(ctx->enc_pkt);
|
||||||
ret = avcodec_receive_packet(ctx->enc_ctx, ctx->enc_pkt);
|
ret = avcodec_receive_packet(ctx->enc_ctx, ctx->enc_pkt);
|
||||||
if (ret == AVERROR(EAGAIN)) {
|
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
|
||||||
av_frame_unref(ctx->dec_frame);
|
break;
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
if (ret < 0) {
|
if (ret < 0) {
|
||||||
set_last_error(make_err("avcodec_receive_packet failed", ret));
|
set_last_error(make_err("avcodec_receive_packet failed", ret));
|
||||||
|
av_packet_unref(ctx->enc_pkt);
|
||||||
av_frame_unref(ctx->dec_frame);
|
av_frame_unref(ctx->dec_frame);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx->enc_pkt->size > 0) {
|
if (ctx->enc_pkt->size <= 0) {
|
||||||
|
set_last_error("avcodec_receive_packet failed, pkt size is 0");
|
||||||
|
av_packet_unref(ctx->enc_pkt);
|
||||||
|
av_frame_unref(ctx->dec_frame);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!encoded) {
|
||||||
uint8_t *buf = (uint8_t*)malloc(ctx->enc_pkt->size);
|
uint8_t *buf = (uint8_t*)malloc(ctx->enc_pkt->size);
|
||||||
if (!buf) {
|
if (!buf) {
|
||||||
set_last_error("malloc for output packet failed");
|
set_last_error("malloc for output packet failed");
|
||||||
@@ -394,9 +403,8 @@ extern "C" int ffmpeg_hw_mjpeg_h26x_encode(FfmpegHwMjpegH26x* handle,
|
|||||||
*out_data = buf;
|
*out_data = buf;
|
||||||
*out_len = ctx->enc_pkt->size;
|
*out_len = ctx->enc_pkt->size;
|
||||||
*out_keyframe = (ctx->enc_pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0;
|
*out_keyframe = (ctx->enc_pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0;
|
||||||
av_packet_unref(ctx->enc_pkt);
|
encoded = true;
|
||||||
av_frame_unref(ctx->dec_frame);
|
}
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
av_frame_unref(ctx->dec_frame);
|
av_frame_unref(ctx->dec_frame);
|
||||||
|
|||||||
@@ -15,12 +15,412 @@ use std::{
|
|||||||
slice,
|
slice,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::Priority;
|
|
||||||
#[cfg(any(windows, target_os = "linux"))]
|
#[cfg(any(windows, target_os = "linux"))]
|
||||||
use crate::common::Driver;
|
use crate::common::Driver;
|
||||||
|
|
||||||
/// Timeout for encoder test in milliseconds
|
/// Timeout for encoder test in milliseconds
|
||||||
const TEST_TIMEOUT_MS: u64 = 3000;
|
const TEST_TIMEOUT_MS: u64 = 3000;
|
||||||
|
const PRIORITY_NVENC: i32 = 0;
|
||||||
|
const PRIORITY_QSV: i32 = 1;
|
||||||
|
const PRIORITY_AMF: i32 = 2;
|
||||||
|
const PRIORITY_RKMPP: i32 = 3;
|
||||||
|
const PRIORITY_VAAPI: i32 = 4;
|
||||||
|
const PRIORITY_V4L2M2M: i32 = 5;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct CandidateCodecSpec {
|
||||||
|
name: &'static str,
|
||||||
|
format: DataFormat,
|
||||||
|
priority: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_candidate(codecs: &mut Vec<CodecInfo>, candidate: CandidateCodecSpec) {
|
||||||
|
codecs.push(CodecInfo {
|
||||||
|
name: candidate.name.to_owned(),
|
||||||
|
format: candidate.format,
|
||||||
|
priority: candidate.priority,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_support_vaapi() -> bool {
|
||||||
|
let entries = match std::fs::read_dir("/dev/dri") {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.flatten().any(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.map(|name| name.starts_with("renderD"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn linux_support_vaapi() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_support_rkmpp() -> bool {
|
||||||
|
extern "C" {
|
||||||
|
fn linux_support_rkmpp() -> c_int;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe { linux_support_rkmpp() == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn linux_support_rkmpp() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_support_v4l2m2m() -> bool {
|
||||||
|
extern "C" {
|
||||||
|
fn linux_support_v4l2m2m() -> c_int;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe { linux_support_v4l2m2m() == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn linux_support_v4l2m2m() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, target_os = "linux"))]
|
||||||
|
fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
|
||||||
|
use log::debug;
|
||||||
|
|
||||||
|
let mut codecs = Vec::new();
|
||||||
|
let contains = |_vendor: Driver, _format: DataFormat| {
|
||||||
|
// Without VRAM feature, we can't check SDK availability.
|
||||||
|
// Keep the prefilter coarse and let FFmpeg validation do the real check.
|
||||||
|
true
|
||||||
|
};
|
||||||
|
let (nv, amf, intel) = crate::common::supported_gpu(true);
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"GPU support detected - NV: {}, AMF: {}, Intel: {}",
|
||||||
|
nv, amf, intel
|
||||||
|
);
|
||||||
|
|
||||||
|
if nv && contains(Driver::NV, H264) {
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "h264_nvenc",
|
||||||
|
format: H264,
|
||||||
|
priority: PRIORITY_NVENC,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if nv && contains(Driver::NV, H265) {
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "hevc_nvenc",
|
||||||
|
format: H265,
|
||||||
|
priority: PRIORITY_NVENC,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if intel && contains(Driver::MFX, H264) {
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "h264_qsv",
|
||||||
|
format: H264,
|
||||||
|
priority: PRIORITY_QSV,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if intel && contains(Driver::MFX, H265) {
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "hevc_qsv",
|
||||||
|
format: H265,
|
||||||
|
priority: PRIORITY_QSV,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if amf && contains(Driver::AMF, H264) {
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "h264_amf",
|
||||||
|
format: H264,
|
||||||
|
priority: PRIORITY_AMF,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if amf && contains(Driver::AMF, H265) {
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "hevc_amf",
|
||||||
|
format: H265,
|
||||||
|
priority: PRIORITY_AMF,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if linux_support_rkmpp() {
|
||||||
|
debug!("RKMPP hardware detected, adding Rockchip encoders");
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "h264_rkmpp",
|
||||||
|
format: H264,
|
||||||
|
priority: PRIORITY_RKMPP,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "hevc_rkmpp",
|
||||||
|
format: H265,
|
||||||
|
priority: PRIORITY_RKMPP,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if cfg!(target_os = "linux") && linux_support_vaapi() {
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "h264_vaapi",
|
||||||
|
format: H264,
|
||||||
|
priority: PRIORITY_VAAPI,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "hevc_vaapi",
|
||||||
|
format: H265,
|
||||||
|
priority: PRIORITY_VAAPI,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "vp8_vaapi",
|
||||||
|
format: VP8,
|
||||||
|
priority: PRIORITY_VAAPI,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "vp9_vaapi",
|
||||||
|
format: VP9,
|
||||||
|
priority: PRIORITY_VAAPI,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if linux_support_v4l2m2m() {
|
||||||
|
debug!("V4L2 M2M hardware detected, adding V4L2 encoders");
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "h264_v4l2m2m",
|
||||||
|
format: H264,
|
||||||
|
priority: PRIORITY_V4L2M2M,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "hevc_v4l2m2m",
|
||||||
|
format: H265,
|
||||||
|
priority: PRIORITY_V4L2M2M,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
codecs.retain(|codec| {
|
||||||
|
!(ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P && codec.name.contains("qsv"))
|
||||||
|
});
|
||||||
|
codecs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct ProbePolicy {
|
||||||
|
max_attempts: usize,
|
||||||
|
request_keyframe: bool,
|
||||||
|
accept_any_output: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbePolicy {
|
||||||
|
fn for_codec(codec_name: &str) -> Self {
|
||||||
|
if codec_name.contains("v4l2m2m") {
|
||||||
|
Self {
|
||||||
|
max_attempts: 5,
|
||||||
|
request_keyframe: true,
|
||||||
|
accept_any_output: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self {
|
||||||
|
max_attempts: 1,
|
||||||
|
request_keyframe: false,
|
||||||
|
accept_any_output: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_attempt(&self, encoder: &mut Encoder) {
|
||||||
|
if self.request_keyframe {
|
||||||
|
encoder.request_keyframe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn passed(&self, frames: &[EncodeFrame], elapsed_ms: u128) -> bool {
|
||||||
|
if elapsed_ms >= TEST_TIMEOUT_MS as u128 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.accept_any_output {
|
||||||
|
!frames.is_empty()
|
||||||
|
} else {
|
||||||
|
frames.len() == 1 && frames[0].key == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_failed_probe_attempt(
|
||||||
|
codec_name: &str,
|
||||||
|
policy: ProbePolicy,
|
||||||
|
attempt: usize,
|
||||||
|
frames: &[EncodeFrame],
|
||||||
|
elapsed_ms: u128,
|
||||||
|
) {
|
||||||
|
use log::debug;
|
||||||
|
|
||||||
|
if policy.accept_any_output {
|
||||||
|
if frames.is_empty() {
|
||||||
|
debug!(
|
||||||
|
"Encoder {} test produced no output on attempt {}",
|
||||||
|
codec_name, attempt
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"Encoder {} test failed on attempt {} - frames: {}, timeout: {}ms",
|
||||||
|
codec_name,
|
||||||
|
attempt,
|
||||||
|
frames.len(),
|
||||||
|
elapsed_ms
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if frames.len() == 1 {
|
||||||
|
debug!(
|
||||||
|
"Encoder {} test failed on attempt {} - key: {}, timeout: {}ms",
|
||||||
|
codec_name, attempt, frames[0].key, elapsed_ms
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"Encoder {} test failed on attempt {} - wrong frame count: {}",
|
||||||
|
codec_name,
|
||||||
|
attempt,
|
||||||
|
frames.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool {
|
||||||
|
use log::debug;
|
||||||
|
|
||||||
|
debug!("Testing encoder: {}", codec.name);
|
||||||
|
|
||||||
|
let test_ctx = EncodeContext {
|
||||||
|
name: codec.name.clone(),
|
||||||
|
mc_name: codec.mc_name.clone(),
|
||||||
|
..ctx.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
match Encoder::new(test_ctx) {
|
||||||
|
Ok(mut encoder) => {
|
||||||
|
debug!("Encoder {} created successfully", codec.name);
|
||||||
|
let policy = ProbePolicy::for_codec(&codec.name);
|
||||||
|
let mut last_err: Option<i32> = None;
|
||||||
|
|
||||||
|
for attempt in 0..policy.max_attempts {
|
||||||
|
let attempt_no = attempt + 1;
|
||||||
|
policy.prepare_attempt(&mut encoder);
|
||||||
|
|
||||||
|
let pts = (attempt as i64) * 33;
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
match encoder.encode(yuv, pts) {
|
||||||
|
Ok(frames) => {
|
||||||
|
let elapsed = start.elapsed().as_millis();
|
||||||
|
|
||||||
|
if policy.passed(frames, elapsed) {
|
||||||
|
if policy.accept_any_output {
|
||||||
|
debug!(
|
||||||
|
"Encoder {} test passed on attempt {} (frames: {})",
|
||||||
|
codec.name,
|
||||||
|
attempt_no,
|
||||||
|
frames.len()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"Encoder {} test passed on attempt {}",
|
||||||
|
codec.name, attempt_no
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
log_failed_probe_attempt(
|
||||||
|
&codec.name,
|
||||||
|
policy,
|
||||||
|
attempt_no,
|
||||||
|
frames,
|
||||||
|
elapsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
last_err = Some(err);
|
||||||
|
debug!(
|
||||||
|
"Encoder {} test attempt {} returned error: {}",
|
||||||
|
codec.name, attempt_no, err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Encoder {} test failed after retries{}",
|
||||||
|
codec.name,
|
||||||
|
last_err
|
||||||
|
.map(|e| format!(" (last err: {})", e))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
debug!("Failed to create encoder {}", codec.name);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
|
||||||
|
use log::debug;
|
||||||
|
|
||||||
|
for fallback in CodecInfo::soft().into_vec() {
|
||||||
|
if !codecs.iter().any(|codec| codec.format == fallback.format) {
|
||||||
|
debug!(
|
||||||
|
"Adding software {:?} encoder: {}",
|
||||||
|
fallback.format, fallback.name
|
||||||
|
);
|
||||||
|
codecs.push(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct EncodeContext {
|
pub struct EncodeContext {
|
||||||
@@ -185,305 +585,21 @@ impl Encoder {
|
|||||||
if !(cfg!(windows) || cfg!(target_os = "linux")) {
|
if !(cfg!(windows) || cfg!(target_os = "linux")) {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
let mut codecs: Vec<CodecInfo> = vec![];
|
|
||||||
#[cfg(any(windows, target_os = "linux"))]
|
|
||||||
{
|
|
||||||
let contains = |_vendor: Driver, _format: DataFormat| {
|
|
||||||
// Without VRAM feature, we can't check SDK availability
|
|
||||||
// Just return true and let FFmpeg handle the actual detection
|
|
||||||
true
|
|
||||||
};
|
|
||||||
let (_nv, amf, _intel) = crate::common::supported_gpu(true);
|
|
||||||
debug!(
|
|
||||||
"GPU support detected - NV: {}, AMF: {}, Intel: {}",
|
|
||||||
_nv, amf, _intel
|
|
||||||
);
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
if _intel && contains(Driver::MFX, H264) {
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "h264_qsv".to_owned(),
|
|
||||||
format: H264,
|
|
||||||
priority: Priority::Best as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
if _intel && contains(Driver::MFX, H265) {
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "hevc_qsv".to_owned(),
|
|
||||||
format: H265,
|
|
||||||
priority: Priority::Best as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if _nv && contains(Driver::NV, H264) {
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "h264_nvenc".to_owned(),
|
|
||||||
format: H264,
|
|
||||||
priority: Priority::Best as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if _nv && contains(Driver::NV, H265) {
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "hevc_nvenc".to_owned(),
|
|
||||||
format: H265,
|
|
||||||
priority: Priority::Best as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if amf && contains(Driver::AMF, H264) {
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "h264_amf".to_owned(),
|
|
||||||
format: H264,
|
|
||||||
priority: Priority::Best as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if amf {
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "hevc_amf".to_owned(),
|
|
||||||
format: H265,
|
|
||||||
priority: Priority::Best as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "h264_vaapi".to_owned(),
|
|
||||||
format: H264,
|
|
||||||
priority: Priority::Good as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "hevc_vaapi".to_owned(),
|
|
||||||
format: H265,
|
|
||||||
priority: Priority::Good as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "vp8_vaapi".to_owned(),
|
|
||||||
format: VP8,
|
|
||||||
priority: Priority::Good as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "vp9_vaapi".to_owned(),
|
|
||||||
format: VP9,
|
|
||||||
priority: Priority::Good as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rockchip MPP hardware encoder support
|
|
||||||
use std::ffi::c_int;
|
|
||||||
extern "C" {
|
|
||||||
fn linux_support_rkmpp() -> c_int;
|
|
||||||
fn linux_support_v4l2m2m() -> c_int;
|
|
||||||
}
|
|
||||||
|
|
||||||
if unsafe { linux_support_rkmpp() } == 0 {
|
|
||||||
debug!("RKMPP hardware detected, adding Rockchip encoders");
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "h264_rkmpp".to_owned(),
|
|
||||||
format: H264,
|
|
||||||
priority: Priority::Best as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "hevc_rkmpp".to_owned(),
|
|
||||||
format: H265,
|
|
||||||
priority: Priority::Best as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// V4L2 Memory-to-Memory hardware encoder support (generic ARM)
|
|
||||||
if unsafe { linux_support_v4l2m2m() } == 0 {
|
|
||||||
debug!("V4L2 M2M hardware detected, adding V4L2 encoders");
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "h264_v4l2m2m".to_owned(),
|
|
||||||
format: H264,
|
|
||||||
priority: Priority::Good as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
codecs.push(CodecInfo {
|
|
||||||
name: "hevc_v4l2m2m".to_owned(),
|
|
||||||
format: H265,
|
|
||||||
priority: Priority::Good as _,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// qsv doesn't support yuv420p
|
|
||||||
codecs.retain(|c| {
|
|
||||||
let ctx = ctx.clone();
|
|
||||||
if ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P && c.name.contains("qsv") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut res = vec![];
|
let mut res = vec![];
|
||||||
|
#[cfg(any(windows, target_os = "linux"))]
|
||||||
|
let codecs = enumerate_candidate_codecs(&ctx);
|
||||||
|
|
||||||
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {
|
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {
|
||||||
for codec in codecs {
|
for codec in codecs {
|
||||||
// Skip if this format already exists in results
|
if validate_candidate(&codec, &ctx, &yuv) {
|
||||||
if res
|
res.push(codec);
|
||||||
.iter()
|
|
||||||
.any(|existing: &CodecInfo| existing.format == codec.format)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Testing encoder: {}", codec.name);
|
|
||||||
|
|
||||||
let c = EncodeContext {
|
|
||||||
name: codec.name.clone(),
|
|
||||||
mc_name: codec.mc_name.clone(),
|
|
||||||
..ctx
|
|
||||||
};
|
|
||||||
|
|
||||||
match Encoder::new(c) {
|
|
||||||
Ok(mut encoder) => {
|
|
||||||
debug!("Encoder {} created successfully", codec.name);
|
|
||||||
let mut passed = false;
|
|
||||||
let mut last_err: Option<i32> = None;
|
|
||||||
let is_v4l2m2m = codec.name.contains("v4l2m2m");
|
|
||||||
|
|
||||||
let max_attempts = if is_v4l2m2m { 5 } else { 1 };
|
|
||||||
for attempt in 0..max_attempts {
|
|
||||||
if is_v4l2m2m {
|
|
||||||
encoder.request_keyframe();
|
|
||||||
}
|
|
||||||
let pts = (attempt as i64) * 33; // 33ms is an approximation for 30 FPS (1000 / 30)
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
match encoder.encode(&yuv, pts) {
|
|
||||||
Ok(frames) => {
|
|
||||||
let elapsed = start.elapsed().as_millis();
|
|
||||||
|
|
||||||
if is_v4l2m2m {
|
|
||||||
if !frames.is_empty() && elapsed < TEST_TIMEOUT_MS as _ {
|
|
||||||
debug!(
|
|
||||||
"Encoder {} test passed on attempt {} (frames: {})",
|
|
||||||
codec.name,
|
|
||||||
attempt + 1,
|
|
||||||
frames.len()
|
|
||||||
);
|
|
||||||
res.push(codec.clone());
|
|
||||||
passed = true;
|
|
||||||
break;
|
|
||||||
} else if frames.is_empty() {
|
|
||||||
debug!(
|
|
||||||
"Encoder {} test produced no output on attempt {}",
|
|
||||||
codec.name,
|
|
||||||
attempt + 1
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
debug!(
|
|
||||||
"Encoder {} test failed on attempt {} - frames: {}, timeout: {}ms",
|
|
||||||
codec.name,
|
|
||||||
attempt + 1,
|
|
||||||
frames.len(),
|
|
||||||
elapsed
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if frames.len() == 1 {
|
|
||||||
if frames[0].key == 1 && elapsed < TEST_TIMEOUT_MS as _ {
|
|
||||||
debug!(
|
|
||||||
"Encoder {} test passed on attempt {}",
|
|
||||||
codec.name,
|
|
||||||
attempt + 1
|
|
||||||
);
|
|
||||||
res.push(codec.clone());
|
|
||||||
passed = true;
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
debug!(
|
|
||||||
"Encoder {} test failed on attempt {} - key: {}, timeout: {}ms",
|
|
||||||
codec.name,
|
|
||||||
attempt + 1,
|
|
||||||
frames[0].key,
|
|
||||||
elapsed
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debug!(
|
|
||||||
"Encoder {} test failed on attempt {} - wrong frame count: {}",
|
|
||||||
codec.name,
|
|
||||||
attempt + 1,
|
|
||||||
frames.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
last_err = Some(err);
|
|
||||||
debug!(
|
|
||||||
"Encoder {} test attempt {} returned error: {}",
|
|
||||||
codec.name,
|
|
||||||
attempt + 1,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !passed {
|
|
||||||
debug!(
|
|
||||||
"Encoder {} test failed after retries{}",
|
|
||||||
codec.name,
|
|
||||||
last_err
|
|
||||||
.map(|e| format!(" (last err: {})", e))
|
|
||||||
.unwrap_or_default()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
debug!("Failed to create encoder {}", codec.name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debug!("Failed to generate dummy YUV data");
|
debug!("Failed to generate dummy YUV data");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add software encoders as fallback
|
add_software_fallback(&mut res);
|
||||||
let soft_codecs = CodecInfo::soft();
|
|
||||||
|
|
||||||
// Add H264 software encoder if not already present
|
|
||||||
if !res.iter().any(|c| c.format == H264) {
|
|
||||||
if let Some(h264_soft) = soft_codecs.h264 {
|
|
||||||
debug!("Adding software H264 encoder: {}", h264_soft.name);
|
|
||||||
res.push(h264_soft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add H265 software encoder if not already present
|
|
||||||
if !res.iter().any(|c| c.format == H265) {
|
|
||||||
if let Some(h265_soft) = soft_codecs.h265 {
|
|
||||||
debug!("Adding software H265 encoder: {}", h265_soft.name);
|
|
||||||
res.push(h265_soft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add VP8 software encoder if not already present
|
|
||||||
if !res.iter().any(|c| c.format == VP8) {
|
|
||||||
if let Some(vp8_soft) = soft_codecs.vp8 {
|
|
||||||
debug!("Adding software VP8 encoder: {}", vp8_soft.name);
|
|
||||||
res.push(vp8_soft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add VP9 software encoder if not already present
|
|
||||||
if !res.iter().any(|c| c.format == VP9) {
|
|
||||||
if let Some(vp9_soft) = soft_codecs.vp9 {
|
|
||||||
debug!("Adding software VP9 encoder: {}", vp9_soft.name);
|
|
||||||
res.push(vp9_soft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,40 @@ impl Default for CodecInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CodecInfo {
|
impl CodecInfo {
|
||||||
|
pub fn software(format: DataFormat) -> Option<Self> {
|
||||||
|
match format {
|
||||||
|
H264 => Some(CodecInfo {
|
||||||
|
name: "libx264".to_owned(),
|
||||||
|
mc_name: Default::default(),
|
||||||
|
format: H264,
|
||||||
|
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||||
|
priority: Priority::Soft as _,
|
||||||
|
}),
|
||||||
|
H265 => Some(CodecInfo {
|
||||||
|
name: "libx265".to_owned(),
|
||||||
|
mc_name: Default::default(),
|
||||||
|
format: H265,
|
||||||
|
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||||
|
priority: Priority::Soft as _,
|
||||||
|
}),
|
||||||
|
VP8 => Some(CodecInfo {
|
||||||
|
name: "libvpx".to_owned(),
|
||||||
|
mc_name: Default::default(),
|
||||||
|
format: VP8,
|
||||||
|
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||||
|
priority: Priority::Soft as _,
|
||||||
|
}),
|
||||||
|
VP9 => Some(CodecInfo {
|
||||||
|
name: "libvpx-vp9".to_owned(),
|
||||||
|
mc_name: Default::default(),
|
||||||
|
format: VP9,
|
||||||
|
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
||||||
|
priority: Priority::Soft as _,
|
||||||
|
}),
|
||||||
|
AV1 => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn prioritized(coders: Vec<CodecInfo>) -> CodecInfos {
|
pub fn prioritized(coders: Vec<CodecInfo>) -> CodecInfos {
|
||||||
let mut h264: Option<CodecInfo> = None;
|
let mut h264: Option<CodecInfo> = None;
|
||||||
let mut h265: Option<CodecInfo> = None;
|
let mut h265: Option<CodecInfo> = None;
|
||||||
@@ -148,34 +182,10 @@ impl CodecInfo {
|
|||||||
|
|
||||||
pub fn soft() -> CodecInfos {
|
pub fn soft() -> CodecInfos {
|
||||||
CodecInfos {
|
CodecInfos {
|
||||||
h264: Some(CodecInfo {
|
h264: CodecInfo::software(H264),
|
||||||
name: "libx264".to_owned(),
|
h265: CodecInfo::software(H265),
|
||||||
mc_name: Default::default(),
|
vp8: CodecInfo::software(VP8),
|
||||||
format: H264,
|
vp9: CodecInfo::software(VP9),
|
||||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
|
||||||
priority: Priority::Soft as _,
|
|
||||||
}),
|
|
||||||
h265: Some(CodecInfo {
|
|
||||||
name: "libx265".to_owned(),
|
|
||||||
mc_name: Default::default(),
|
|
||||||
format: H265,
|
|
||||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
|
||||||
priority: Priority::Soft as _,
|
|
||||||
}),
|
|
||||||
vp8: Some(CodecInfo {
|
|
||||||
name: "libvpx".to_owned(),
|
|
||||||
mc_name: Default::default(),
|
|
||||||
format: VP8,
|
|
||||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
|
||||||
priority: Priority::Soft as _,
|
|
||||||
}),
|
|
||||||
vp9: Some(CodecInfo {
|
|
||||||
name: "libvpx-vp9".to_owned(),
|
|
||||||
mc_name: Default::default(),
|
|
||||||
format: VP9,
|
|
||||||
hwdevice: AV_HWDEVICE_TYPE_NONE,
|
|
||||||
priority: Priority::Soft as _,
|
|
||||||
}),
|
|
||||||
av1: None,
|
av1: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,6 +201,23 @@ pub struct CodecInfos {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CodecInfos {
|
impl CodecInfos {
|
||||||
|
pub fn into_vec(self) -> Vec<CodecInfo> {
|
||||||
|
let mut codecs = Vec::new();
|
||||||
|
if let Some(codec) = self.h264 {
|
||||||
|
codecs.push(codec);
|
||||||
|
}
|
||||||
|
if let Some(codec) = self.h265 {
|
||||||
|
codecs.push(codec);
|
||||||
|
}
|
||||||
|
if let Some(codec) = self.vp8 {
|
||||||
|
codecs.push(codec);
|
||||||
|
}
|
||||||
|
if let Some(codec) = self.vp9 {
|
||||||
|
codecs.push(codec);
|
||||||
|
}
|
||||||
|
codecs
|
||||||
|
}
|
||||||
|
|
||||||
pub fn serialize(&self) -> Result<String, ()> {
|
pub fn serialize(&self) -> Result<String, ()> {
|
||||||
match serde_json::to_string_pretty(self) {
|
match serde_json::to_string_pretty(self) {
|
||||||
Ok(s) => Ok(s),
|
Ok(s) => Ok(s),
|
||||||
|
|||||||
@@ -93,11 +93,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_discover_devices() {
|
fn test_discover_devices() {
|
||||||
let devices = discover_devices();
|
let _devices = discover_devices();
|
||||||
// Just verify the function runs without error
|
|
||||||
assert!(devices.gpio_chips.len() >= 0);
|
|
||||||
assert!(devices.usb_relays.len() >= 0);
|
|
||||||
assert!(devices.serial_ports.len() >= 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use super::encoder::{OpusConfig, OpusFrame};
|
|||||||
use super::monitor::{AudioHealthMonitor, AudioHealthStatus};
|
use super::monitor::{AudioHealthMonitor, AudioHealthStatus};
|
||||||
use super::streamer::{AudioStreamer, AudioStreamerConfig};
|
use super::streamer::{AudioStreamer, AudioStreamerConfig};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::{EventBus, SystemEvent};
|
use crate::events::EventBus;
|
||||||
|
|
||||||
/// Audio quality presets
|
/// Audio quality presets
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
@@ -139,17 +139,15 @@ impl AudioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set event bus for publishing audio events
|
/// Set event bus for internal state notifications.
|
||||||
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
|
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
|
||||||
*self.event_bus.write().await = Some(event_bus.clone());
|
*self.event_bus.write().await = Some(event_bus);
|
||||||
// Also set event bus on the monitor for health notifications
|
|
||||||
self.monitor.set_event_bus(event_bus).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish an event to the event bus
|
/// Mark the device-info snapshot as stale.
|
||||||
async fn publish_event(&self, event: SystemEvent) {
|
async fn mark_device_info_dirty(&self) {
|
||||||
if let Some(ref bus) = *self.event_bus.read().await {
|
if let Some(ref bus) = *self.event_bus.read().await {
|
||||||
bus.publish(event);
|
bus.mark_device_info_dirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,12 +205,6 @@ impl AudioController {
|
|||||||
config.device = device.to_string();
|
config.device = device.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish event
|
|
||||||
self.publish_event(SystemEvent::AudioDeviceSelected {
|
|
||||||
device: device.to_string(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
info!("Audio device selected: {}", device);
|
info!("Audio device selected: {}", device);
|
||||||
|
|
||||||
// If streaming, restart with new device
|
// If streaming, restart with new device
|
||||||
@@ -237,12 +229,6 @@ impl AudioController {
|
|||||||
streamer.set_bitrate(quality.bitrate()).await?;
|
streamer.set_bitrate(quality.bitrate()).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish event
|
|
||||||
self.publish_event(SystemEvent::AudioQualityChanged {
|
|
||||||
quality: quality.to_string(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Audio quality set to: {:?} ({}bps)",
|
"Audio quality set to: {:?} ({}bps)",
|
||||||
quality,
|
quality,
|
||||||
@@ -290,11 +276,7 @@ impl AudioController {
|
|||||||
.report_error(Some(&config.device), &error_msg, "start_failed")
|
.report_error(Some(&config.device), &error_msg, "start_failed")
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
self.publish_event(SystemEvent::AudioStateChanged {
|
self.mark_device_info_dirty().await;
|
||||||
streaming: false,
|
|
||||||
device: None,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
return Err(AppError::AudioError(error_msg));
|
return Err(AppError::AudioError(error_msg));
|
||||||
}
|
}
|
||||||
@@ -306,12 +288,7 @@ impl AudioController {
|
|||||||
self.monitor.report_recovered(Some(&config.device)).await;
|
self.monitor.report_recovered(Some(&config.device)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish event
|
self.mark_device_info_dirty().await;
|
||||||
self.publish_event(SystemEvent::AudioStateChanged {
|
|
||||||
streaming: true,
|
|
||||||
device: Some(config.device),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
info!("Audio streaming started");
|
info!("Audio streaming started");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -323,12 +300,7 @@ impl AudioController {
|
|||||||
streamer.stop().await?;
|
streamer.stop().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish event
|
self.mark_device_info_dirty().await;
|
||||||
self.publish_event(SystemEvent::AudioStateChanged {
|
|
||||||
streaming: false,
|
|
||||||
device: None,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
info!("Audio streaming stopped");
|
info!("Audio streaming stopped");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -408,7 +380,6 @@ impl AudioController {
|
|||||||
/// Update full configuration
|
/// Update full configuration
|
||||||
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
|
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
|
||||||
let was_streaming = self.is_streaming().await;
|
let was_streaming = self.is_streaming().await;
|
||||||
let old_config = self.config.read().await.clone();
|
|
||||||
|
|
||||||
// Stop streaming if running
|
// Stop streaming if running
|
||||||
if was_streaming {
|
if was_streaming {
|
||||||
@@ -423,21 +394,6 @@ impl AudioController {
|
|||||||
self.start_streaming().await?;
|
self.start_streaming().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish events for changes
|
|
||||||
if old_config.device != new_config.device {
|
|
||||||
self.publish_event(SystemEvent::AudioDeviceSelected {
|
|
||||||
device: new_config.device.clone(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if old_config.quality != new_config.quality {
|
|
||||||
self.publish_event(SystemEvent::AudioQualityChanged {
|
|
||||||
quality: new_config.quality.to_string(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,14 @@
|
|||||||
//! This module provides health monitoring for audio capture devices, including:
|
//! This module provides health monitoring for audio capture devices, including:
|
||||||
//! - Device connectivity checks
|
//! - Device connectivity checks
|
||||||
//! - Automatic reconnection on failure
|
//! - Automatic reconnection on failure
|
||||||
//! - Error tracking and notification
|
//! - Error tracking
|
||||||
//! - Log throttling to prevent log flooding
|
//! - Log throttling to prevent log flooding
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::events::{EventBus, SystemEvent};
|
|
||||||
use crate::utils::LogThrottler;
|
use crate::utils::LogThrottler;
|
||||||
|
|
||||||
/// Audio health status
|
/// Audio health status
|
||||||
@@ -58,19 +56,13 @@ impl Default for AudioMonitorConfig {
|
|||||||
/// Audio health monitor
|
/// Audio health monitor
|
||||||
///
|
///
|
||||||
/// Monitors audio device health and manages error recovery.
|
/// Monitors audio device health and manages error recovery.
|
||||||
/// Publishes WebSocket events when device status changes.
|
|
||||||
pub struct AudioHealthMonitor {
|
pub struct AudioHealthMonitor {
|
||||||
/// Current health status
|
/// Current health status
|
||||||
status: RwLock<AudioHealthStatus>,
|
status: RwLock<AudioHealthStatus>,
|
||||||
/// Event bus for notifications
|
|
||||||
events: RwLock<Option<Arc<EventBus>>>,
|
|
||||||
/// Log throttler to prevent log flooding
|
/// Log throttler to prevent log flooding
|
||||||
throttler: LogThrottler,
|
throttler: LogThrottler,
|
||||||
/// Configuration
|
/// Configuration
|
||||||
config: AudioMonitorConfig,
|
config: AudioMonitorConfig,
|
||||||
/// Whether monitoring is active (reserved for future use)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
running: AtomicBool,
|
|
||||||
/// Current retry count
|
/// Current retry count
|
||||||
retry_count: AtomicU32,
|
retry_count: AtomicU32,
|
||||||
/// Last error code (for change detection)
|
/// Last error code (for change detection)
|
||||||
@@ -83,10 +75,8 @@ impl AudioHealthMonitor {
|
|||||||
let throttle_secs = config.log_throttle_secs;
|
let throttle_secs = config.log_throttle_secs;
|
||||||
Self {
|
Self {
|
||||||
status: RwLock::new(AudioHealthStatus::Healthy),
|
status: RwLock::new(AudioHealthStatus::Healthy),
|
||||||
events: RwLock::new(None),
|
|
||||||
throttler: LogThrottler::with_secs(throttle_secs),
|
throttler: LogThrottler::with_secs(throttle_secs),
|
||||||
config,
|
config,
|
||||||
running: AtomicBool::new(false),
|
|
||||||
retry_count: AtomicU32::new(0),
|
retry_count: AtomicU32::new(0),
|
||||||
last_error_code: RwLock::new(None),
|
last_error_code: RwLock::new(None),
|
||||||
}
|
}
|
||||||
@@ -97,24 +87,19 @@ impl AudioHealthMonitor {
|
|||||||
Self::new(AudioMonitorConfig::default())
|
Self::new(AudioMonitorConfig::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the event bus for broadcasting state changes
|
|
||||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
|
||||||
*self.events.write().await = Some(events);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Report an error from audio operations
|
/// Report an error from audio operations
|
||||||
///
|
///
|
||||||
/// This method is called when an audio operation fails. It:
|
/// This method is called when an audio operation fails. It:
|
||||||
/// 1. Updates the health status
|
/// 1. Updates the health status
|
||||||
/// 2. Logs the error (with throttling)
|
/// 2. Logs the error (with throttling)
|
||||||
/// 3. Publishes a WebSocket event if the error is new or changed
|
/// 3. Updates in-memory error state
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `device` - The audio device name (if known)
|
/// * `device` - The audio device name (if known)
|
||||||
/// * `reason` - Human-readable error description
|
/// * `reason` - Human-readable error description
|
||||||
/// * `error_code` - Error code for programmatic handling
|
/// * `error_code` - Error code for programmatic handling
|
||||||
pub async fn report_error(&self, device: Option<&str>, reason: &str, error_code: &str) {
|
pub async fn report_error(&self, _device: Option<&str>, reason: &str, error_code: &str) {
|
||||||
let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1;
|
let count = self.retry_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
|
||||||
// Check if error code changed
|
// Check if error code changed
|
||||||
@@ -141,44 +126,17 @@ impl AudioHealthMonitor {
|
|||||||
error_code: error_code.to_string(),
|
error_code: error_code.to_string(),
|
||||||
retry_count: count,
|
retry_count: count,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Publish event (only if error changed or first occurrence)
|
|
||||||
if error_changed || count == 1 {
|
|
||||||
if let Some(ref events) = *self.events.read().await {
|
|
||||||
events.publish(SystemEvent::AudioDeviceLost {
|
|
||||||
device: device.map(|s| s.to_string()),
|
|
||||||
reason: reason.to_string(),
|
|
||||||
error_code: error_code.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Report that a reconnection attempt is starting
|
|
||||||
///
|
|
||||||
/// Publishes a reconnecting event to notify clients.
|
|
||||||
pub async fn report_reconnecting(&self) {
|
|
||||||
let attempt = self.retry_count.load(Ordering::Relaxed);
|
|
||||||
|
|
||||||
// Only publish every 5 attempts to avoid event spam
|
|
||||||
if attempt == 1 || attempt.is_multiple_of(5) {
|
|
||||||
debug!("Audio reconnecting, attempt {}", attempt);
|
|
||||||
|
|
||||||
if let Some(ref events) = *self.events.read().await {
|
|
||||||
events.publish(SystemEvent::AudioReconnecting { attempt });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Report that the device has recovered
|
/// Report that the device has recovered
|
||||||
///
|
///
|
||||||
/// This method is called when the audio device successfully reconnects.
|
/// This method is called when the audio device successfully reconnects.
|
||||||
/// It resets the error state and publishes a recovery event.
|
/// It resets the error state.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `device` - The audio device name
|
/// * `device` - The audio device name
|
||||||
pub async fn report_recovered(&self, device: Option<&str>) {
|
pub async fn report_recovered(&self, _device: Option<&str>) {
|
||||||
let prev_status = self.status.read().await.clone();
|
let prev_status = self.status.read().await.clone();
|
||||||
|
|
||||||
// Only report recovery if we were in an error state
|
// Only report recovery if we were in an error state
|
||||||
@@ -191,13 +149,6 @@ impl AudioHealthMonitor {
|
|||||||
self.throttler.clear("audio_");
|
self.throttler.clear("audio_");
|
||||||
*self.last_error_code.write().await = None;
|
*self.last_error_code.write().await = None;
|
||||||
*self.status.write().await = AudioHealthStatus::Healthy;
|
*self.status.write().await = AudioHealthStatus::Healthy;
|
||||||
|
|
||||||
// Publish recovery event
|
|
||||||
if let Some(ref events) = *self.events.read().await {
|
|
||||||
events.publish(SystemEvent::AudioRecovered {
|
|
||||||
device: device.map(|s| s.to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,13 +148,11 @@ impl Default for OtgDescriptorConfig {
|
|||||||
pub enum OtgHidProfile {
|
pub enum OtgHidProfile {
|
||||||
/// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control)
|
/// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control)
|
||||||
#[default]
|
#[default]
|
||||||
|
#[serde(alias = "full_no_msd")]
|
||||||
Full,
|
Full,
|
||||||
/// Full HID device set without MSD
|
|
||||||
FullNoMsd,
|
|
||||||
/// Full HID device set without consumer control
|
/// Full HID device set without consumer control
|
||||||
|
#[serde(alias = "full_no_consumer_no_msd")]
|
||||||
FullNoConsumer,
|
FullNoConsumer,
|
||||||
/// Full HID device set without consumer control and MSD
|
|
||||||
FullNoConsumerNoMsd,
|
|
||||||
/// Legacy profile: only keyboard
|
/// Legacy profile: only keyboard
|
||||||
LegacyKeyboard,
|
LegacyKeyboard,
|
||||||
/// Legacy profile: only relative mouse
|
/// Legacy profile: only relative mouse
|
||||||
@@ -163,9 +161,52 @@ pub enum OtgHidProfile {
|
|||||||
Custom,
|
Custom,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OTG endpoint budget policy.
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub enum OtgEndpointBudget {
|
||||||
|
/// Derive a safe default from the selected UDC.
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// Limit OTG gadget functions to 5 endpoints.
|
||||||
|
Five,
|
||||||
|
/// Limit OTG gadget functions to 6 endpoints.
|
||||||
|
Six,
|
||||||
|
/// Do not impose a software endpoint budget.
|
||||||
|
Unlimited,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OtgEndpointBudget {
|
||||||
|
pub fn default_for_udc_name(udc: Option<&str>) -> Self {
|
||||||
|
if udc.is_some_and(crate::otg::configfs::is_low_endpoint_udc) {
|
||||||
|
Self::Five
|
||||||
|
} else {
|
||||||
|
Self::Six
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolved(self, udc: Option<&str>) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Auto => Self::default_for_udc_name(udc),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn endpoint_limit(self, udc: Option<&str>) -> Option<u8> {
|
||||||
|
match self.resolved(udc) {
|
||||||
|
Self::Five => Some(5),
|
||||||
|
Self::Six => Some(6),
|
||||||
|
Self::Unlimited => None,
|
||||||
|
Self::Auto => unreachable!("auto budget must be resolved before use"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// OTG HID function selection (used when profile is Custom)
|
/// OTG HID function selection (used when profile is Custom)
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct OtgHidFunctions {
|
pub struct OtgHidFunctions {
|
||||||
pub keyboard: bool,
|
pub keyboard: bool,
|
||||||
@@ -214,6 +255,26 @@ impl OtgHidFunctions {
|
|||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
!self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer
|
!self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn endpoint_cost(&self, keyboard_leds: bool) -> u8 {
|
||||||
|
let mut endpoints = 0;
|
||||||
|
if self.keyboard {
|
||||||
|
endpoints += 1;
|
||||||
|
if keyboard_leds {
|
||||||
|
endpoints += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.mouse_relative {
|
||||||
|
endpoints += 1;
|
||||||
|
}
|
||||||
|
if self.mouse_absolute {
|
||||||
|
endpoints += 1;
|
||||||
|
}
|
||||||
|
if self.consumer {
|
||||||
|
endpoints += 1;
|
||||||
|
}
|
||||||
|
endpoints
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OtgHidFunctions {
|
impl Default for OtgHidFunctions {
|
||||||
@@ -223,12 +284,21 @@ impl Default for OtgHidFunctions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OtgHidProfile {
|
impl OtgHidProfile {
|
||||||
|
pub fn from_legacy_str(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"full" | "full_no_msd" => Some(Self::Full),
|
||||||
|
"full_no_consumer" | "full_no_consumer_no_msd" => Some(Self::FullNoConsumer),
|
||||||
|
"legacy_keyboard" => Some(Self::LegacyKeyboard),
|
||||||
|
"legacy_mouse_relative" => Some(Self::LegacyMouseRelative),
|
||||||
|
"custom" => Some(Self::Custom),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions {
|
pub fn resolve_functions(&self, custom: &OtgHidFunctions) -> OtgHidFunctions {
|
||||||
match self {
|
match self {
|
||||||
Self::Full => OtgHidFunctions::full(),
|
Self::Full => OtgHidFunctions::full(),
|
||||||
Self::FullNoMsd => OtgHidFunctions::full(),
|
|
||||||
Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(),
|
Self::FullNoConsumer => OtgHidFunctions::full_no_consumer(),
|
||||||
Self::FullNoConsumerNoMsd => OtgHidFunctions::full_no_consumer(),
|
|
||||||
Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(),
|
Self::LegacyKeyboard => OtgHidFunctions::legacy_keyboard(),
|
||||||
Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(),
|
Self::LegacyMouseRelative => OtgHidFunctions::legacy_mouse_relative(),
|
||||||
Self::Custom => custom.clone(),
|
Self::Custom => custom.clone(),
|
||||||
@@ -243,10 +313,6 @@ impl OtgHidProfile {
|
|||||||
pub struct HidConfig {
|
pub struct HidConfig {
|
||||||
/// HID backend type
|
/// HID backend type
|
||||||
pub backend: HidBackend,
|
pub backend: HidBackend,
|
||||||
/// OTG keyboard device path
|
|
||||||
pub otg_keyboard: String,
|
|
||||||
/// OTG mouse device path
|
|
||||||
pub otg_mouse: String,
|
|
||||||
/// OTG UDC (USB Device Controller) name
|
/// OTG UDC (USB Device Controller) name
|
||||||
pub otg_udc: Option<String>,
|
pub otg_udc: Option<String>,
|
||||||
/// OTG USB device descriptor configuration
|
/// OTG USB device descriptor configuration
|
||||||
@@ -255,9 +321,15 @@ pub struct HidConfig {
|
|||||||
/// OTG HID function profile
|
/// OTG HID function profile
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub otg_profile: OtgHidProfile,
|
pub otg_profile: OtgHidProfile,
|
||||||
|
/// OTG endpoint budget policy
|
||||||
|
#[serde(default)]
|
||||||
|
pub otg_endpoint_budget: OtgEndpointBudget,
|
||||||
/// OTG HID function selection (used when profile is Custom)
|
/// OTG HID function selection (used when profile is Custom)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub otg_functions: OtgHidFunctions,
|
pub otg_functions: OtgHidFunctions,
|
||||||
|
/// Enable keyboard LED/status feedback for OTG keyboard
|
||||||
|
#[serde(default)]
|
||||||
|
pub otg_keyboard_leds: bool,
|
||||||
/// CH9329 serial port
|
/// CH9329 serial port
|
||||||
pub ch9329_port: String,
|
pub ch9329_port: String,
|
||||||
/// CH9329 baud rate
|
/// CH9329 baud rate
|
||||||
@@ -270,12 +342,12 @@ impl Default for HidConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
backend: HidBackend::None,
|
backend: HidBackend::None,
|
||||||
otg_keyboard: "/dev/hidg0".to_string(),
|
|
||||||
otg_mouse: "/dev/hidg1".to_string(),
|
|
||||||
otg_udc: None,
|
otg_udc: None,
|
||||||
otg_descriptor: OtgDescriptorConfig::default(),
|
otg_descriptor: OtgDescriptorConfig::default(),
|
||||||
otg_profile: OtgHidProfile::default(),
|
otg_profile: OtgHidProfile::default(),
|
||||||
|
otg_endpoint_budget: OtgEndpointBudget::default(),
|
||||||
otg_functions: OtgHidFunctions::default(),
|
otg_functions: OtgHidFunctions::default(),
|
||||||
|
otg_keyboard_leds: false,
|
||||||
ch9329_port: "/dev/ttyUSB0".to_string(),
|
ch9329_port: "/dev/ttyUSB0".to_string(),
|
||||||
ch9329_baudrate: 9600,
|
ch9329_baudrate: 9600,
|
||||||
mouse_absolute: true,
|
mouse_absolute: true,
|
||||||
@@ -287,6 +359,62 @@ impl HidConfig {
|
|||||||
pub fn effective_otg_functions(&self) -> OtgHidFunctions {
|
pub fn effective_otg_functions(&self) -> OtgHidFunctions {
|
||||||
self.otg_profile.resolve_functions(&self.otg_functions)
|
self.otg_profile.resolve_functions(&self.otg_functions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolved_otg_udc(&self) -> Option<String> {
|
||||||
|
crate::otg::configfs::resolve_udc_name(self.otg_udc.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolved_otg_endpoint_budget(&self) -> OtgEndpointBudget {
|
||||||
|
self.otg_endpoint_budget
|
||||||
|
.resolved(self.resolved_otg_udc().as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolved_otg_endpoint_limit(&self) -> Option<u8> {
|
||||||
|
self.otg_endpoint_budget
|
||||||
|
.endpoint_limit(self.resolved_otg_udc().as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_otg_keyboard_leds(&self) -> bool {
|
||||||
|
self.otg_keyboard_leds && self.effective_otg_functions().keyboard
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn constrained_otg_functions(&self) -> OtgHidFunctions {
|
||||||
|
self.effective_otg_functions()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_otg_required_endpoints(&self, msd_enabled: bool) -> u8 {
|
||||||
|
let functions = self.effective_otg_functions();
|
||||||
|
let mut endpoints = functions.endpoint_cost(self.effective_otg_keyboard_leds());
|
||||||
|
if msd_enabled {
|
||||||
|
endpoints += 2;
|
||||||
|
}
|
||||||
|
endpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_otg_endpoint_budget(&self, msd_enabled: bool) -> crate::error::Result<()> {
|
||||||
|
if self.backend != HidBackend::Otg {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let functions = self.effective_otg_functions();
|
||||||
|
if functions.is_empty() {
|
||||||
|
return Err(crate::error::AppError::BadRequest(
|
||||||
|
"OTG HID functions cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let required = self.effective_otg_required_endpoints(msd_enabled);
|
||||||
|
if let Some(limit) = self.resolved_otg_endpoint_limit() {
|
||||||
|
if required > limit {
|
||||||
|
return Err(crate::error::AppError::BadRequest(format!(
|
||||||
|
"OTG selection requires {} endpoints, but the configured limit is {}",
|
||||||
|
required, limit
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MSD configuration
|
/// MSD configuration
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub mod types;
|
|||||||
|
|
||||||
pub use types::{
|
pub use types::{
|
||||||
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
|
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
|
||||||
VideoDeviceInfo,
|
TtydDeviceInfo, VideoDeviceInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
@@ -15,6 +15,39 @@ use tokio::sync::broadcast;
|
|||||||
/// Event channel capacity (ring buffer size)
|
/// Event channel capacity (ring buffer size)
|
||||||
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||||
|
|
||||||
|
const EXACT_TOPICS: &[&str] = &[
|
||||||
|
"stream.mode_switching",
|
||||||
|
"stream.state_changed",
|
||||||
|
"stream.config_changing",
|
||||||
|
"stream.config_applied",
|
||||||
|
"stream.device_lost",
|
||||||
|
"stream.reconnecting",
|
||||||
|
"stream.recovered",
|
||||||
|
"stream.webrtc_ready",
|
||||||
|
"stream.stats_update",
|
||||||
|
"stream.mode_changed",
|
||||||
|
"stream.mode_ready",
|
||||||
|
"webrtc.ice_candidate",
|
||||||
|
"webrtc.ice_complete",
|
||||||
|
"msd.upload_progress",
|
||||||
|
"msd.download_progress",
|
||||||
|
"system.device_info",
|
||||||
|
"error",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PREFIX_TOPICS: &[&str] = &["stream.*", "webrtc.*", "msd.*", "system.*"];
|
||||||
|
|
||||||
|
fn make_sender() -> broadcast::Sender<SystemEvent> {
|
||||||
|
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||||
|
tx
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic_prefix(event_name: &str) -> Option<String> {
|
||||||
|
event_name
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(prefix, _)| format!("{}.*", prefix))
|
||||||
|
}
|
||||||
|
|
||||||
/// Global event bus for broadcasting system events
|
/// Global event bus for broadcasting system events
|
||||||
///
|
///
|
||||||
/// The event bus uses tokio's broadcast channel to distribute events
|
/// The event bus uses tokio's broadcast channel to distribute events
|
||||||
@@ -43,13 +76,31 @@ const EVENT_CHANNEL_CAPACITY: usize = 256;
|
|||||||
/// ```
|
/// ```
|
||||||
pub struct EventBus {
|
pub struct EventBus {
|
||||||
tx: broadcast::Sender<SystemEvent>,
|
tx: broadcast::Sender<SystemEvent>,
|
||||||
|
exact_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
|
||||||
|
prefix_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
|
||||||
|
device_info_dirty_tx: broadcast::Sender<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventBus {
|
impl EventBus {
|
||||||
/// Create a new event bus
|
/// Create a new event bus
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
let tx = make_sender();
|
||||||
Self { tx }
|
let exact_topics = EXACT_TOPICS
|
||||||
|
.iter()
|
||||||
|
.map(|topic| (*topic, make_sender()))
|
||||||
|
.collect();
|
||||||
|
let prefix_topics = PREFIX_TOPICS
|
||||||
|
.iter()
|
||||||
|
.map(|topic| (*topic, make_sender()))
|
||||||
|
.collect();
|
||||||
|
let (device_info_dirty_tx, _dirty_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
tx,
|
||||||
|
exact_topics,
|
||||||
|
prefix_topics,
|
||||||
|
device_info_dirty_tx,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish an event to all subscribers
|
/// Publish an event to all subscribers
|
||||||
@@ -57,6 +108,18 @@ impl EventBus {
|
|||||||
/// If there are no active subscribers, the event is silently dropped.
|
/// If there are no active subscribers, the event is silently dropped.
|
||||||
/// This is by design - events are fire-and-forget notifications.
|
/// This is by design - events are fire-and-forget notifications.
|
||||||
pub fn publish(&self, event: SystemEvent) {
|
pub fn publish(&self, event: SystemEvent) {
|
||||||
|
let event_name = event.event_name();
|
||||||
|
|
||||||
|
if let Some(tx) = self.exact_topics.get(event_name) {
|
||||||
|
let _ = tx.send(event.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(prefix) = topic_prefix(event_name) {
|
||||||
|
if let Some(tx) = self.prefix_topics.get(prefix.as_str()) {
|
||||||
|
let _ = tx.send(event.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If no subscribers, send returns Err which is normal
|
// If no subscribers, send returns Err which is normal
|
||||||
let _ = self.tx.send(event);
|
let _ = self.tx.send(event);
|
||||||
}
|
}
|
||||||
@@ -70,6 +133,35 @@ impl EventBus {
|
|||||||
self.tx.subscribe()
|
self.tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribe to a specific topic.
|
||||||
|
///
|
||||||
|
/// Supports exact event names, namespace wildcards like `stream.*`, and
|
||||||
|
/// `*` for the full event stream.
|
||||||
|
pub fn subscribe_topic(&self, topic: &str) -> Option<broadcast::Receiver<SystemEvent>> {
|
||||||
|
if topic == "*" {
|
||||||
|
return Some(self.tx.subscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
if topic.ends_with(".*") {
|
||||||
|
return self.prefix_topics.get(topic).map(|tx| tx.subscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.exact_topics.get(topic).map(|tx| tx.subscribe())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the device-info snapshot as stale.
|
||||||
|
///
|
||||||
|
/// This is an internal trigger used to refresh the latest `system.device_info`
|
||||||
|
/// snapshot without exposing another public WebSocket event.
|
||||||
|
pub fn mark_device_info_dirty(&self) {
|
||||||
|
let _ = self.device_info_dirty_tx.send(());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to internal device-info refresh triggers.
|
||||||
|
pub fn subscribe_device_info_dirty(&self) -> broadcast::Receiver<()> {
|
||||||
|
self.device_info_dirty_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the current number of active subscribers
|
/// Get the current number of active subscribers
|
||||||
///
|
///
|
||||||
/// Useful for monitoring and debugging.
|
/// Useful for monitoring and debugging.
|
||||||
@@ -110,17 +202,50 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(bus.subscriber_count(), 2);
|
assert_eq!(bus.subscriber_count(), 2);
|
||||||
|
|
||||||
bus.publish(SystemEvent::SystemError {
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
module: "test".to_string(),
|
state: "ready".to_string(),
|
||||||
severity: "info".to_string(),
|
device: Some("/dev/video0".to_string()),
|
||||||
message: "test message".to_string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let event1 = rx1.recv().await.unwrap();
|
let event1 = rx1.recv().await.unwrap();
|
||||||
let event2 = rx2.recv().await.unwrap();
|
let event2 = rx2.recv().await.unwrap();
|
||||||
|
|
||||||
assert!(matches!(event1, SystemEvent::SystemError { .. }));
|
assert!(matches!(event1, SystemEvent::StreamStateChanged { .. }));
|
||||||
assert!(matches!(event2, SystemEvent::SystemError { .. }));
|
assert!(matches!(event2, SystemEvent::StreamStateChanged { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_subscribe_topic_exact() {
|
||||||
|
let bus = EventBus::new();
|
||||||
|
let mut rx = bus.subscribe_topic("stream.state_changed").unwrap();
|
||||||
|
|
||||||
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
state: "ready".to_string(),
|
||||||
|
device: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let event = rx.recv().await.unwrap();
|
||||||
|
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_subscribe_topic_prefix() {
|
||||||
|
let bus = EventBus::new();
|
||||||
|
let mut rx = bus.subscribe_topic("stream.*").unwrap();
|
||||||
|
|
||||||
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
state: "ready".to_string(),
|
||||||
|
device: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let event = rx.recv().await.unwrap();
|
||||||
|
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subscribe_topic_unknown() {
|
||||||
|
let bus = EventBus::new();
|
||||||
|
assert!(bus.subscribe_topic("unknown.topic").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -129,10 +254,9 @@ mod tests {
|
|||||||
assert_eq!(bus.subscriber_count(), 0);
|
assert_eq!(bus.subscriber_count(), 0);
|
||||||
|
|
||||||
// Should not panic when publishing with no subscribers
|
// Should not panic when publishing with no subscribers
|
||||||
bus.publish(SystemEvent::SystemError {
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
module: "test".to_string(),
|
state: "ready".to_string(),
|
||||||
severity: "info".to_string(),
|
device: None,
|
||||||
message: "test".to_string(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! Defines all event types that can be broadcast through the event bus.
|
//! Defines all event types that can be broadcast through the event bus.
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::atx::PowerStatus;
|
use crate::hid::LedState;
|
||||||
use crate::msd::MsdMode;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Device Info Structures (for system.device_info event)
|
// Device Info Structures (for system.device_info event)
|
||||||
@@ -45,12 +43,20 @@ pub struct HidDeviceInfo {
|
|||||||
pub backend: String,
|
pub backend: String,
|
||||||
/// Whether backend is initialized and ready
|
/// Whether backend is initialized and ready
|
||||||
pub initialized: bool,
|
pub initialized: bool,
|
||||||
|
/// Whether backend is currently online
|
||||||
|
pub online: bool,
|
||||||
/// Whether absolute mouse positioning is supported
|
/// Whether absolute mouse positioning is supported
|
||||||
pub supports_absolute_mouse: bool,
|
pub supports_absolute_mouse: bool,
|
||||||
|
/// Whether keyboard LED/status feedback is enabled.
|
||||||
|
pub keyboard_leds_enabled: bool,
|
||||||
|
/// Last known keyboard LED state.
|
||||||
|
pub led_state: LedState,
|
||||||
/// Device path (e.g., serial port for CH9329)
|
/// Device path (e.g., serial port for CH9329)
|
||||||
pub device: Option<String>,
|
pub device: Option<String>,
|
||||||
/// Error message if any, None if OK
|
/// Error message if any, None if OK
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
/// Error code if any, None if OK
|
||||||
|
pub error_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MSD device information
|
/// MSD device information
|
||||||
@@ -100,6 +106,15 @@ pub struct AudioDeviceInfo {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ttyd status information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TtydDeviceInfo {
|
||||||
|
/// Whether ttyd binary is available
|
||||||
|
pub available: bool,
|
||||||
|
/// Whether ttyd is currently running
|
||||||
|
pub running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-client statistics
|
/// Per-client statistics
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ClientStats {
|
pub struct ClientStats {
|
||||||
@@ -275,89 +290,9 @@ pub enum SystemEvent {
|
|||||||
mode: String,
|
mode: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HID Events
|
|
||||||
// ============================================================================
|
|
||||||
/// HID backend state changed
|
|
||||||
#[serde(rename = "hid.state_changed")]
|
|
||||||
HidStateChanged {
|
|
||||||
/// Backend type: "otg", "ch9329", "none"
|
|
||||||
backend: String,
|
|
||||||
/// Whether backend is initialized and ready
|
|
||||||
initialized: bool,
|
|
||||||
/// Error message if any, None if OK
|
|
||||||
error: Option<String>,
|
|
||||||
/// Error code for programmatic handling: "epipe", "eagain", "port_not_found", etc.
|
|
||||||
error_code: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// HID backend is being switched
|
|
||||||
#[serde(rename = "hid.backend_switching")]
|
|
||||||
HidBackendSwitching {
|
|
||||||
/// Current backend
|
|
||||||
from: String,
|
|
||||||
/// New backend
|
|
||||||
to: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// HID device lost (device file missing or I/O error)
|
|
||||||
#[serde(rename = "hid.device_lost")]
|
|
||||||
HidDeviceLost {
|
|
||||||
/// Backend type: "otg", "ch9329"
|
|
||||||
backend: String,
|
|
||||||
/// Device path that was lost (e.g., /dev/hidg0 or /dev/ttyUSB0)
|
|
||||||
device: Option<String>,
|
|
||||||
/// Human-readable reason for loss
|
|
||||||
reason: String,
|
|
||||||
/// Error code: "epipe", "eshutdown", "eagain", "enxio", "port_not_found", "io_error"
|
|
||||||
error_code: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// HID device is reconnecting
|
|
||||||
#[serde(rename = "hid.reconnecting")]
|
|
||||||
HidReconnecting {
|
|
||||||
/// Backend type: "otg", "ch9329"
|
|
||||||
backend: String,
|
|
||||||
/// Current retry attempt number
|
|
||||||
attempt: u32,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// HID device has recovered after error
|
|
||||||
#[serde(rename = "hid.recovered")]
|
|
||||||
HidRecovered {
|
|
||||||
/// Backend type: "otg", "ch9329"
|
|
||||||
backend: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MSD (Mass Storage Device) Events
|
// MSD (Mass Storage Device) Events
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
/// MSD state changed
|
|
||||||
#[serde(rename = "msd.state_changed")]
|
|
||||||
MsdStateChanged {
|
|
||||||
/// Operating mode
|
|
||||||
mode: MsdMode,
|
|
||||||
/// Whether storage is connected to target
|
|
||||||
connected: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Image has been mounted
|
|
||||||
#[serde(rename = "msd.image_mounted")]
|
|
||||||
MsdImageMounted {
|
|
||||||
/// Image ID
|
|
||||||
image_id: String,
|
|
||||||
/// Image filename
|
|
||||||
image_name: String,
|
|
||||||
/// Image size in bytes
|
|
||||||
size: u64,
|
|
||||||
/// Mount as CD-ROM (read-only)
|
|
||||||
cdrom: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Image has been unmounted
|
|
||||||
#[serde(rename = "msd.image_unmounted")]
|
|
||||||
MsdImageUnmounted,
|
|
||||||
|
|
||||||
/// File upload progress (for large file uploads)
|
/// File upload progress (for large file uploads)
|
||||||
#[serde(rename = "msd.upload_progress")]
|
#[serde(rename = "msd.upload_progress")]
|
||||||
MsdUploadProgress {
|
MsdUploadProgress {
|
||||||
@@ -392,132 +327,6 @@ pub enum SystemEvent {
|
|||||||
status: String,
|
status: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// USB gadget connection status changed (host connected/disconnected)
|
|
||||||
#[serde(rename = "msd.usb_status_changed")]
|
|
||||||
MsdUsbStatusChanged {
|
|
||||||
/// Whether host is connected to USB device
|
|
||||||
connected: bool,
|
|
||||||
/// USB device state from kernel (e.g., "configured", "not attached")
|
|
||||||
device_state: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// MSD operation error (configfs, image mount, etc.)
|
|
||||||
#[serde(rename = "msd.error")]
|
|
||||||
MsdError {
|
|
||||||
/// Human-readable reason for error
|
|
||||||
reason: String,
|
|
||||||
/// Error code: "configfs_error", "image_not_found", "mount_failed", "io_error"
|
|
||||||
error_code: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// MSD has recovered after error
|
|
||||||
#[serde(rename = "msd.recovered")]
|
|
||||||
MsdRecovered,
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ATX (Power Control) Events
|
|
||||||
// ============================================================================
|
|
||||||
/// ATX power state changed
|
|
||||||
#[serde(rename = "atx.state_changed")]
|
|
||||||
AtxStateChanged {
|
|
||||||
/// Power status
|
|
||||||
power_status: PowerStatus,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// ATX action was executed
|
|
||||||
#[serde(rename = "atx.action_executed")]
|
|
||||||
AtxActionExecuted {
|
|
||||||
/// Action: "short", "long", "reset"
|
|
||||||
action: String,
|
|
||||||
/// When the action was executed
|
|
||||||
timestamp: DateTime<Utc>,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Audio Events
|
|
||||||
// ============================================================================
|
|
||||||
/// Audio state changed (streaming started/stopped)
|
|
||||||
#[serde(rename = "audio.state_changed")]
|
|
||||||
AudioStateChanged {
|
|
||||||
/// Whether audio is currently streaming
|
|
||||||
streaming: bool,
|
|
||||||
/// Current device (None if stopped)
|
|
||||||
device: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Audio device was selected
|
|
||||||
#[serde(rename = "audio.device_selected")]
|
|
||||||
AudioDeviceSelected {
|
|
||||||
/// Selected device name
|
|
||||||
device: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Audio quality was changed
|
|
||||||
#[serde(rename = "audio.quality_changed")]
|
|
||||||
AudioQualityChanged {
|
|
||||||
/// New quality setting: "voice", "balanced", "high"
|
|
||||||
quality: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Audio device lost (capture error or device disconnected)
|
|
||||||
#[serde(rename = "audio.device_lost")]
|
|
||||||
AudioDeviceLost {
|
|
||||||
/// Audio device name (e.g., "hw:0,0")
|
|
||||||
device: Option<String>,
|
|
||||||
/// Human-readable reason for loss
|
|
||||||
reason: String,
|
|
||||||
/// Error code: "device_busy", "device_disconnected", "capture_error", "io_error"
|
|
||||||
error_code: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Audio device is reconnecting
|
|
||||||
#[serde(rename = "audio.reconnecting")]
|
|
||||||
AudioReconnecting {
|
|
||||||
/// Current retry attempt number
|
|
||||||
attempt: u32,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Audio device has recovered after error
|
|
||||||
#[serde(rename = "audio.recovered")]
|
|
||||||
AudioRecovered {
|
|
||||||
/// Audio device name
|
|
||||||
device: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// System Events
|
|
||||||
// ============================================================================
|
|
||||||
/// A device was added (hot-plug)
|
|
||||||
#[serde(rename = "system.device_added")]
|
|
||||||
SystemDeviceAdded {
|
|
||||||
/// Device type: "video", "audio", "hid", etc.
|
|
||||||
device_type: String,
|
|
||||||
/// Device path
|
|
||||||
device_path: String,
|
|
||||||
/// Device name/description
|
|
||||||
device_name: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// A device was removed (hot-unplug)
|
|
||||||
#[serde(rename = "system.device_removed")]
|
|
||||||
SystemDeviceRemoved {
|
|
||||||
/// Device type
|
|
||||||
device_type: String,
|
|
||||||
/// Device path that was removed
|
|
||||||
device_path: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// System error or warning
|
|
||||||
#[serde(rename = "system.error")]
|
|
||||||
SystemError {
|
|
||||||
/// Module that generated the error: "stream", "hid", "msd", "atx"
|
|
||||||
module: String,
|
|
||||||
/// Severity: "warning", "error", "critical"
|
|
||||||
severity: String,
|
|
||||||
/// Error message
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Complete device information (sent on WebSocket connect and state changes)
|
/// Complete device information (sent on WebSocket connect and state changes)
|
||||||
#[serde(rename = "system.device_info")]
|
#[serde(rename = "system.device_info")]
|
||||||
DeviceInfo {
|
DeviceInfo {
|
||||||
@@ -531,6 +340,8 @@ pub enum SystemEvent {
|
|||||||
atx: Option<AtxDeviceInfo>,
|
atx: Option<AtxDeviceInfo>,
|
||||||
/// Audio device information (None if audio not enabled)
|
/// Audio device information (None if audio not enabled)
|
||||||
audio: Option<AudioDeviceInfo>,
|
audio: Option<AudioDeviceInfo>,
|
||||||
|
/// ttyd status information
|
||||||
|
ttyd: TtydDeviceInfo,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// WebSocket error notification (for connection-level errors like lag)
|
/// WebSocket error notification (for connection-level errors like lag)
|
||||||
@@ -558,30 +369,8 @@ impl SystemEvent {
|
|||||||
Self::StreamModeReady { .. } => "stream.mode_ready",
|
Self::StreamModeReady { .. } => "stream.mode_ready",
|
||||||
Self::WebRTCIceCandidate { .. } => "webrtc.ice_candidate",
|
Self::WebRTCIceCandidate { .. } => "webrtc.ice_candidate",
|
||||||
Self::WebRTCIceComplete { .. } => "webrtc.ice_complete",
|
Self::WebRTCIceComplete { .. } => "webrtc.ice_complete",
|
||||||
Self::HidStateChanged { .. } => "hid.state_changed",
|
|
||||||
Self::HidBackendSwitching { .. } => "hid.backend_switching",
|
|
||||||
Self::HidDeviceLost { .. } => "hid.device_lost",
|
|
||||||
Self::HidReconnecting { .. } => "hid.reconnecting",
|
|
||||||
Self::HidRecovered { .. } => "hid.recovered",
|
|
||||||
Self::MsdStateChanged { .. } => "msd.state_changed",
|
|
||||||
Self::MsdImageMounted { .. } => "msd.image_mounted",
|
|
||||||
Self::MsdImageUnmounted => "msd.image_unmounted",
|
|
||||||
Self::MsdUploadProgress { .. } => "msd.upload_progress",
|
Self::MsdUploadProgress { .. } => "msd.upload_progress",
|
||||||
Self::MsdDownloadProgress { .. } => "msd.download_progress",
|
Self::MsdDownloadProgress { .. } => "msd.download_progress",
|
||||||
Self::MsdUsbStatusChanged { .. } => "msd.usb_status_changed",
|
|
||||||
Self::MsdError { .. } => "msd.error",
|
|
||||||
Self::MsdRecovered => "msd.recovered",
|
|
||||||
Self::AtxStateChanged { .. } => "atx.state_changed",
|
|
||||||
Self::AtxActionExecuted { .. } => "atx.action_executed",
|
|
||||||
Self::AudioStateChanged { .. } => "audio.state_changed",
|
|
||||||
Self::AudioDeviceSelected { .. } => "audio.device_selected",
|
|
||||||
Self::AudioQualityChanged { .. } => "audio.quality_changed",
|
|
||||||
Self::AudioDeviceLost { .. } => "audio.device_lost",
|
|
||||||
Self::AudioReconnecting { .. } => "audio.reconnecting",
|
|
||||||
Self::AudioRecovered { .. } => "audio.recovered",
|
|
||||||
Self::SystemDeviceAdded { .. } => "system.device_added",
|
|
||||||
Self::SystemDeviceRemoved { .. } => "system.device_removed",
|
|
||||||
Self::SystemError { .. } => "system.error",
|
|
||||||
Self::DeviceInfo { .. } => "system.device_info",
|
Self::DeviceInfo { .. } => "system.device_info",
|
||||||
Self::Error { .. } => "error",
|
Self::Error { .. } => "error",
|
||||||
}
|
}
|
||||||
@@ -620,14 +409,6 @@ mod tests {
|
|||||||
device: Some("/dev/video0".to_string()),
|
device: Some("/dev/video0".to_string()),
|
||||||
};
|
};
|
||||||
assert_eq!(event.event_name(), "stream.state_changed");
|
assert_eq!(event.event_name(), "stream.state_changed");
|
||||||
|
|
||||||
let event = SystemEvent::MsdImageMounted {
|
|
||||||
image_id: "123".to_string(),
|
|
||||||
image_name: "ubuntu.iso".to_string(),
|
|
||||||
size: 1024,
|
|
||||||
cdrom: true,
|
|
||||||
};
|
|
||||||
assert_eq!(event.event_name(), "msd.image_mounted");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use tokio::process::{Child, Command};
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use super::types::*;
|
use super::types::*;
|
||||||
|
use crate::events::EventBus;
|
||||||
|
|
||||||
/// Maximum number of log lines to keep per extension
|
/// Maximum number of log lines to keep per extension
|
||||||
const LOG_BUFFER_SIZE: usize = 200;
|
const LOG_BUFFER_SIZE: usize = 200;
|
||||||
@@ -31,6 +32,7 @@ pub struct ExtensionManager {
|
|||||||
processes: RwLock<HashMap<ExtensionId, ExtensionProcess>>,
|
processes: RwLock<HashMap<ExtensionId, ExtensionProcess>>,
|
||||||
/// Cached availability status (checked once at startup)
|
/// Cached availability status (checked once at startup)
|
||||||
availability: HashMap<ExtensionId, bool>,
|
availability: HashMap<ExtensionId, bool>,
|
||||||
|
event_bus: RwLock<Option<Arc<EventBus>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ExtensionManager {
|
impl Default for ExtensionManager {
|
||||||
@@ -51,6 +53,22 @@ impl ExtensionManager {
|
|||||||
Self {
|
Self {
|
||||||
processes: RwLock::new(HashMap::new()),
|
processes: RwLock::new(HashMap::new()),
|
||||||
availability,
|
availability,
|
||||||
|
event_bus: RwLock::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set event bus for ttyd status notifications.
|
||||||
|
pub async fn set_event_bus(&self, event_bus: Arc<EventBus>) {
|
||||||
|
*self.event_bus.write().await = Some(event_bus);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_ttyd_status_dirty(&self, id: ExtensionId) {
|
||||||
|
if id != ExtensionId::Ttyd {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref event_bus) = *self.event_bus.read().await {
|
||||||
|
event_bus.mark_device_info_dirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,17 +83,38 @@ impl ExtensionManager {
|
|||||||
return ExtensionStatus::Unavailable;
|
return ExtensionStatus::Unavailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
let processes = self.processes.read().await;
|
let mut processes = self.processes.write().await;
|
||||||
match processes.get(&id) {
|
let exited = {
|
||||||
Some(proc) => {
|
let Some(proc) = processes.get_mut(&id) else {
|
||||||
if let Some(pid) = proc.child.id() {
|
return ExtensionStatus::Stopped;
|
||||||
ExtensionStatus::Running { pid }
|
};
|
||||||
} else {
|
|
||||||
ExtensionStatus::Stopped
|
match proc.child.try_wait() {
|
||||||
}
|
Ok(Some(status)) => {
|
||||||
|
tracing::info!("Extension {} exited with status {}", id, status);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
return match proc.child.id() {
|
||||||
|
Some(pid) => ExtensionStatus::Running { pid },
|
||||||
None => ExtensionStatus::Stopped,
|
None => ExtensionStatus::Stopped,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to query status for {}: {}", id, e);
|
||||||
|
return match proc.child.id() {
|
||||||
|
Some(pid) => ExtensionStatus::Running { pid },
|
||||||
|
None => ExtensionStatus::Stopped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if exited {
|
||||||
|
processes.remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtensionStatus::Stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start an extension with the given configuration
|
/// Start an extension with the given configuration
|
||||||
@@ -134,6 +173,8 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
let mut processes = self.processes.write().await;
|
let mut processes = self.processes.write().await;
|
||||||
processes.insert(id, ExtensionProcess { child, logs });
|
processes.insert(id, ExtensionProcess { child, logs });
|
||||||
|
drop(processes);
|
||||||
|
self.mark_ttyd_status_dirty(id).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -146,6 +187,8 @@ impl ExtensionManager {
|
|||||||
if let Err(e) = proc.child.kill().await {
|
if let Err(e) = proc.child.kill().await {
|
||||||
tracing::warn!("Failed to kill {}: {}", id, e);
|
tracing::warn!("Failed to kill {}: {}", id, e);
|
||||||
}
|
}
|
||||||
|
drop(processes);
|
||||||
|
self.mark_ttyd_status_dirty(id).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
use super::otg::LedState;
|
||||||
use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent};
|
use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
@@ -75,12 +77,32 @@ impl HidBackendType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current runtime status reported by a HID backend.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct HidBackendRuntimeSnapshot {
|
||||||
|
/// Whether the backend has been initialized and can accept requests.
|
||||||
|
pub initialized: bool,
|
||||||
|
/// Whether the backend is currently online and communicating successfully.
|
||||||
|
pub online: bool,
|
||||||
|
/// Whether absolute mouse positioning is supported.
|
||||||
|
pub supports_absolute_mouse: bool,
|
||||||
|
/// Whether keyboard LED/status feedback is currently enabled.
|
||||||
|
pub keyboard_leds_enabled: bool,
|
||||||
|
/// Last known keyboard LED state.
|
||||||
|
pub led_state: LedState,
|
||||||
|
/// Screen resolution for absolute mouse mode.
|
||||||
|
pub screen_resolution: Option<(u32, u32)>,
|
||||||
|
/// Device identifier associated with the backend, if any.
|
||||||
|
pub device: Option<String>,
|
||||||
|
/// Current user-facing error, if any.
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// Current programmatic error code, if any.
|
||||||
|
pub error_code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// HID backend trait
|
/// HID backend trait
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait HidBackend: Send + Sync {
|
pub trait HidBackend: Send + Sync {
|
||||||
/// Get backend name
|
|
||||||
fn name(&self) -> &'static str;
|
|
||||||
|
|
||||||
/// Initialize the backend
|
/// Initialize the backend
|
||||||
async fn init(&self) -> Result<()>;
|
async fn init(&self) -> Result<()>;
|
||||||
|
|
||||||
@@ -104,22 +126,11 @@ pub trait HidBackend: Send + Sync {
|
|||||||
/// Shutdown the backend
|
/// Shutdown the backend
|
||||||
async fn shutdown(&self) -> Result<()>;
|
async fn shutdown(&self) -> Result<()>;
|
||||||
|
|
||||||
/// Perform backend health check.
|
/// Get the current backend runtime snapshot.
|
||||||
///
|
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;
|
||||||
/// Default implementation assumes backend is healthy.
|
|
||||||
fn health_check(&self) -> Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if backend supports absolute mouse positioning
|
/// Subscribe to backend runtime changes.
|
||||||
fn supports_absolute_mouse(&self) -> bool {
|
fn subscribe_runtime(&self) -> watch::Receiver<()>;
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get screen resolution (for absolute mouse)
|
|
||||||
fn screen_resolution(&self) -> Option<(u32, u32)> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set screen resolution (for absolute mouse)
|
/// Set screen resolution (for absolute mouse)
|
||||||
fn set_screen_resolution(&mut self, _width: u32, _height: u32) {}
|
fn set_screen_resolution(&mut self, _width: u32, _height: u32) {}
|
||||||
|
|||||||
1063
src/hid/ch9329.rs
1063
src/hid/ch9329.rs
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Keyboard event (type 0x01):
|
//! Keyboard event (type 0x01):
|
||||||
//! - Byte 1: Event type (0x00 = down, 0x01 = up)
|
//! - Byte 1: Event type (0x00 = down, 0x01 = up)
|
||||||
//! - Byte 2: Key code (USB HID usage code)
|
//! - Byte 2: Canonical key code (stable One-KVM key id aligned with HID usage)
|
||||||
//! - Byte 3: Modifiers bitmask
|
//! - Byte 3: Modifiers bitmask
|
||||||
//! - Bit 0: Left Ctrl
|
//! - Bit 0: Left Ctrl
|
||||||
//! - Bit 1: Left Shift
|
//! - Bit 1: Left Shift
|
||||||
@@ -38,7 +38,8 @@ use tracing::warn;
|
|||||||
|
|
||||||
use super::types::ConsumerEvent;
|
use super::types::ConsumerEvent;
|
||||||
use super::{
|
use super::{
|
||||||
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, MouseEventType,
|
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
|
||||||
|
MouseEventType,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Message types
|
/// Message types
|
||||||
@@ -101,7 +102,13 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let key = data[1];
|
let key = match CanonicalKey::from_hid_usage(data[1]) {
|
||||||
|
Some(key) => key,
|
||||||
|
None => {
|
||||||
|
warn!("Unknown canonical keyboard key code: 0x{:02X}", data[1]);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
let modifiers_byte = data[2];
|
let modifiers_byte = data[2];
|
||||||
|
|
||||||
let modifiers = KeyboardModifiers {
|
let modifiers = KeyboardModifiers {
|
||||||
@@ -119,7 +126,6 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
|
|||||||
event_type,
|
event_type,
|
||||||
key,
|
key,
|
||||||
modifiers,
|
modifiers,
|
||||||
is_usb_hid: true, // WebRTC/WebSocket HID channel sends USB HID usages
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +199,12 @@ pub fn encode_keyboard_event(event: &KeyboardEvent) -> Vec<u8> {
|
|||||||
|
|
||||||
let modifiers = event.modifiers.to_hid_byte();
|
let modifiers = event.modifiers.to_hid_byte();
|
||||||
|
|
||||||
vec![MSG_KEYBOARD, event_type, event.key, modifiers]
|
vec![
|
||||||
|
MSG_KEYBOARD,
|
||||||
|
event_type,
|
||||||
|
event.key.to_hid_usage(),
|
||||||
|
modifiers,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode a mouse event to binary format (for sending to client if needed)
|
/// Encode a mouse event to binary format (for sending to client if needed)
|
||||||
@@ -242,10 +253,9 @@ mod tests {
|
|||||||
match event {
|
match event {
|
||||||
HidChannelEvent::Keyboard(kb) => {
|
HidChannelEvent::Keyboard(kb) => {
|
||||||
assert!(matches!(kb.event_type, KeyEventType::Down));
|
assert!(matches!(kb.event_type, KeyEventType::Down));
|
||||||
assert_eq!(kb.key, 0x04);
|
assert_eq!(kb.key, CanonicalKey::KeyA);
|
||||||
assert!(kb.modifiers.left_ctrl);
|
assert!(kb.modifiers.left_ctrl);
|
||||||
assert!(!kb.modifiers.left_shift);
|
assert!(!kb.modifiers.left_shift);
|
||||||
assert!(kb.is_usb_hid);
|
|
||||||
}
|
}
|
||||||
_ => panic!("Expected keyboard event"),
|
_ => panic!("Expected keyboard event"),
|
||||||
}
|
}
|
||||||
@@ -270,7 +280,7 @@ mod tests {
|
|||||||
fn test_encode_keyboard() {
|
fn test_encode_keyboard() {
|
||||||
let event = KeyboardEvent {
|
let event = KeyboardEvent {
|
||||||
event_type: KeyEventType::Down,
|
event_type: KeyEventType::Down,
|
||||||
key: 0x04,
|
key: CanonicalKey::KeyA,
|
||||||
modifiers: KeyboardModifiers {
|
modifiers: KeyboardModifiers {
|
||||||
left_ctrl: true,
|
left_ctrl: true,
|
||||||
left_shift: false,
|
left_shift: false,
|
||||||
@@ -281,7 +291,6 @@ mod tests {
|
|||||||
right_alt: false,
|
right_alt: false,
|
||||||
right_meta: false,
|
right_meta: false,
|
||||||
},
|
},
|
||||||
is_usb_hid: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let encoded = encode_keyboard_event(&event);
|
let encoded = encode_keyboard_event(&event);
|
||||||
|
|||||||
409
src/hid/keyboard.rs
Normal file
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
494
src/hid/mod.rs
494
src/hid/mod.rs
@@ -15,14 +15,13 @@ pub mod backend;
|
|||||||
pub mod ch9329;
|
pub mod ch9329;
|
||||||
pub mod consumer;
|
pub mod consumer;
|
||||||
pub mod datachannel;
|
pub mod datachannel;
|
||||||
pub mod keymap;
|
pub mod keyboard;
|
||||||
pub mod monitor;
|
|
||||||
pub mod otg;
|
pub mod otg;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod websocket;
|
pub mod websocket;
|
||||||
|
|
||||||
pub use backend::{HidBackend, HidBackendType};
|
pub use backend::{HidBackend, HidBackendRuntimeSnapshot, HidBackendType};
|
||||||
pub use monitor::{HidHealthMonitor, HidHealthStatus, HidMonitorConfig};
|
pub use keyboard::CanonicalKey;
|
||||||
pub use otg::LedState;
|
pub use otg::LedState;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
|
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
|
||||||
@@ -33,7 +32,7 @@ pub use types::{
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HidInfo {
|
pub struct HidInfo {
|
||||||
/// Backend name
|
/// Backend name
|
||||||
pub name: &'static str,
|
pub name: String,
|
||||||
/// Whether backend is initialized
|
/// Whether backend is initialized
|
||||||
pub initialized: bool,
|
pub initialized: bool,
|
||||||
/// Whether absolute mouse positioning is supported
|
/// Whether absolute mouse positioning is supported
|
||||||
@@ -42,21 +41,103 @@ pub struct HidInfo {
|
|||||||
pub screen_resolution: Option<(u32, u32)>,
|
pub screen_resolution: Option<(u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unified HID runtime state used by snapshots and events.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HidRuntimeState {
|
||||||
|
/// Whether a backend is configured and expected to exist.
|
||||||
|
pub available: bool,
|
||||||
|
/// Stable backend key: "otg", "ch9329", "none".
|
||||||
|
pub backend: String,
|
||||||
|
/// Whether the backend is currently initialized and operational.
|
||||||
|
pub initialized: bool,
|
||||||
|
/// Whether the backend is currently online.
|
||||||
|
pub online: bool,
|
||||||
|
/// Whether absolute mouse positioning is supported.
|
||||||
|
pub supports_absolute_mouse: bool,
|
||||||
|
/// Whether keyboard LED/status feedback is enabled.
|
||||||
|
pub keyboard_leds_enabled: bool,
|
||||||
|
/// Last known keyboard LED state.
|
||||||
|
pub led_state: LedState,
|
||||||
|
/// Screen resolution for absolute mouse mode.
|
||||||
|
pub screen_resolution: Option<(u32, u32)>,
|
||||||
|
/// Device path associated with the backend, if any.
|
||||||
|
pub device: Option<String>,
|
||||||
|
/// Current user-facing error, if any.
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// Current programmatic error code, if any.
|
||||||
|
pub error_code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HidRuntimeState {
|
||||||
|
fn from_backend_type(backend_type: &HidBackendType) -> Self {
|
||||||
|
Self {
|
||||||
|
available: !matches!(backend_type, HidBackendType::None),
|
||||||
|
backend: backend_type.name_str().to_string(),
|
||||||
|
initialized: false,
|
||||||
|
online: false,
|
||||||
|
supports_absolute_mouse: false,
|
||||||
|
keyboard_leds_enabled: false,
|
||||||
|
led_state: LedState::default(),
|
||||||
|
screen_resolution: None,
|
||||||
|
device: device_for_backend_type(backend_type),
|
||||||
|
error: None,
|
||||||
|
error_code: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_backend(backend_type: &HidBackendType, snapshot: HidBackendRuntimeSnapshot) -> Self {
|
||||||
|
Self {
|
||||||
|
available: !matches!(backend_type, HidBackendType::None),
|
||||||
|
backend: backend_type.name_str().to_string(),
|
||||||
|
initialized: snapshot.initialized,
|
||||||
|
online: snapshot.online,
|
||||||
|
supports_absolute_mouse: snapshot.supports_absolute_mouse,
|
||||||
|
keyboard_leds_enabled: snapshot.keyboard_leds_enabled,
|
||||||
|
led_state: snapshot.led_state,
|
||||||
|
screen_resolution: snapshot.screen_resolution,
|
||||||
|
device: snapshot
|
||||||
|
.device
|
||||||
|
.or_else(|| device_for_backend_type(backend_type)),
|
||||||
|
error: snapshot.error,
|
||||||
|
error_code: snapshot.error_code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_error(
|
||||||
|
backend_type: &HidBackendType,
|
||||||
|
current: &Self,
|
||||||
|
reason: impl Into<String>,
|
||||||
|
error_code: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
let mut next = current.clone();
|
||||||
|
next.available = !matches!(backend_type, HidBackendType::None);
|
||||||
|
next.backend = backend_type.name_str().to_string();
|
||||||
|
next.initialized = false;
|
||||||
|
next.online = false;
|
||||||
|
next.keyboard_leds_enabled = false;
|
||||||
|
next.led_state = LedState::default();
|
||||||
|
next.device = device_for_backend_type(backend_type);
|
||||||
|
next.error = Some(reason.into());
|
||||||
|
next.error_code = Some(error_code.into());
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::events::EventBus;
|
||||||
use crate::otg::OtgService;
|
use crate::otg::OtgService;
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
const HID_EVENT_QUEUE_CAPACITY: usize = 64;
|
const HID_EVENT_QUEUE_CAPACITY: usize = 64;
|
||||||
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
|
const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30;
|
||||||
const HID_HEALTH_CHECK_INTERVAL_MS: u64 = 1000;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum HidEvent {
|
enum HidEvent {
|
||||||
@@ -75,9 +156,9 @@ pub struct HidController {
|
|||||||
/// Backend type (mutable for reload)
|
/// Backend type (mutable for reload)
|
||||||
backend_type: Arc<RwLock<HidBackendType>>,
|
backend_type: Arc<RwLock<HidBackendType>>,
|
||||||
/// Event bus for broadcasting state changes (optional)
|
/// Event bus for broadcasting state changes (optional)
|
||||||
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
|
events: Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
|
||||||
/// Health monitor for error tracking and recovery
|
/// Unified HID runtime state.
|
||||||
monitor: Arc<HidHealthMonitor>,
|
runtime_state: Arc<RwLock<HidRuntimeState>>,
|
||||||
/// HID event queue sender (non-blocking)
|
/// HID event queue sender (non-blocking)
|
||||||
hid_tx: mpsc::Sender<HidEvent>,
|
hid_tx: mpsc::Sender<HidEvent>,
|
||||||
/// HID event queue receiver (moved into worker on first start)
|
/// HID event queue receiver (moved into worker on first start)
|
||||||
@@ -88,10 +169,10 @@ pub struct HidController {
|
|||||||
pending_move_flag: Arc<AtomicBool>,
|
pending_move_flag: Arc<AtomicBool>,
|
||||||
/// Worker task handle
|
/// Worker task handle
|
||||||
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
||||||
/// Health check task handle
|
/// Backend runtime subscription task handle
|
||||||
hid_health_checker: Mutex<Option<JoinHandle<()>>>,
|
runtime_worker: Mutex<Option<JoinHandle<()>>>,
|
||||||
/// Backend availability fast flag
|
/// Backend initialization fast flag
|
||||||
backend_available: AtomicBool,
|
backend_available: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HidController {
|
impl HidController {
|
||||||
@@ -103,24 +184,24 @@ impl HidController {
|
|||||||
Self {
|
Self {
|
||||||
otg_service,
|
otg_service,
|
||||||
backend: Arc::new(RwLock::new(None)),
|
backend: Arc::new(RwLock::new(None)),
|
||||||
backend_type: Arc::new(RwLock::new(backend_type)),
|
backend_type: Arc::new(RwLock::new(backend_type.clone())),
|
||||||
events: tokio::sync::RwLock::new(None),
|
events: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
monitor: Arc::new(HidHealthMonitor::with_defaults()),
|
runtime_state: Arc::new(RwLock::new(HidRuntimeState::from_backend_type(
|
||||||
|
&backend_type,
|
||||||
|
))),
|
||||||
hid_tx,
|
hid_tx,
|
||||||
hid_rx: Mutex::new(Some(hid_rx)),
|
hid_rx: Mutex::new(Some(hid_rx)),
|
||||||
pending_move: Arc::new(parking_lot::Mutex::new(None)),
|
pending_move: Arc::new(parking_lot::Mutex::new(None)),
|
||||||
pending_move_flag: Arc::new(AtomicBool::new(false)),
|
pending_move_flag: Arc::new(AtomicBool::new(false)),
|
||||||
hid_worker: Mutex::new(None),
|
hid_worker: Mutex::new(None),
|
||||||
hid_health_checker: Mutex::new(None),
|
runtime_worker: Mutex::new(None),
|
||||||
backend_available: AtomicBool::new(false),
|
backend_available: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set event bus for broadcasting state changes
|
/// Set event bus for broadcasting state changes
|
||||||
pub async fn set_event_bus(&self, events: Arc<crate::events::EventBus>) {
|
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||||
*self.events.write().await = Some(events.clone());
|
*self.events.write().await = Some(events);
|
||||||
// Also set event bus on the monitor for health notifications
|
|
||||||
self.monitor.set_event_bus(events).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the HID backend
|
/// Initialize the HID backend
|
||||||
@@ -128,16 +209,15 @@ impl HidController {
|
|||||||
let backend_type = self.backend_type.read().await.clone();
|
let backend_type = self.backend_type.read().await.clone();
|
||||||
let backend: Arc<dyn HidBackend> = match backend_type {
|
let backend: Arc<dyn HidBackend> = match backend_type {
|
||||||
HidBackendType::Otg => {
|
HidBackendType::Otg => {
|
||||||
// Request HID functions from OtgService
|
|
||||||
let otg_service = self
|
let otg_service = self
|
||||||
.otg_service
|
.otg_service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::Internal("OtgService not available".into()))?;
|
.ok_or_else(|| AppError::Internal("OtgService not available".into()))?;
|
||||||
|
|
||||||
info!("Requesting HID functions from OtgService");
|
let handles = otg_service.hid_device_paths().await.ok_or_else(|| {
|
||||||
let handles = otg_service.enable_hid().await?;
|
AppError::Config("OTG HID paths are not available".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
// Create OtgBackend from handles (no longer manages gadget itself)
|
|
||||||
info!("Creating OTG HID backend from device paths");
|
info!("Creating OTG HID backend from device paths");
|
||||||
Arc::new(otg::OtgBackend::from_handles(handles)?)
|
Arc::new(otg::OtgBackend::from_handles(handles)?)
|
||||||
}
|
}
|
||||||
@@ -157,13 +237,28 @@ impl HidController {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
backend.init().await?;
|
if let Err(e) = backend.init().await {
|
||||||
|
self.backend_available.store(false, Ordering::Release);
|
||||||
|
let error_state = {
|
||||||
|
let backend_type = self.backend_type.read().await.clone();
|
||||||
|
let current = self.runtime_state.read().await.clone();
|
||||||
|
HidRuntimeState::with_error(
|
||||||
|
&backend_type,
|
||||||
|
¤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.write().await = Some(backend);
|
||||||
self.backend_available.store(true, Ordering::Release);
|
self.sync_runtime_state_from_backend().await;
|
||||||
|
|
||||||
// Start HID event worker (once)
|
// Start HID event worker (once)
|
||||||
self.start_event_worker().await;
|
self.start_event_worker().await;
|
||||||
self.start_health_checker().await;
|
self.restart_runtime_worker().await;
|
||||||
|
|
||||||
info!("HID backend initialized: {:?}", backend_type);
|
info!("HID backend initialized: {:?}", backend_type);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -172,20 +267,24 @@ impl HidController {
|
|||||||
/// Shutdown the HID backend and release resources
|
/// Shutdown the HID backend and release resources
|
||||||
pub async fn shutdown(&self) -> Result<()> {
|
pub async fn shutdown(&self) -> Result<()> {
|
||||||
info!("Shutting down HID controller");
|
info!("Shutting down HID controller");
|
||||||
self.stop_health_checker().await;
|
self.stop_runtime_worker().await;
|
||||||
|
|
||||||
// Close the backend
|
// Close the backend
|
||||||
*self.backend.write().await = None;
|
if let Some(backend) = self.backend.write().await.take() {
|
||||||
|
if let Err(e) = backend.shutdown().await {
|
||||||
|
warn!("Error shutting down HID backend: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.backend_available.store(false, Ordering::Release);
|
self.backend_available.store(false, Ordering::Release);
|
||||||
|
|
||||||
// If OTG backend, notify OtgService to disable HID
|
|
||||||
let backend_type = self.backend_type.read().await.clone();
|
let backend_type = self.backend_type.read().await.clone();
|
||||||
if matches!(backend_type, HidBackendType::Otg) {
|
let mut shutdown_state = HidRuntimeState::from_backend_type(&backend_type);
|
||||||
if let Some(ref otg_service) = self.otg_service {
|
if matches!(backend_type, HidBackendType::None) {
|
||||||
info!("Disabling HID functions in OtgService");
|
shutdown_state.available = false;
|
||||||
otg_service.disable_hid().await?;
|
} else {
|
||||||
}
|
shutdown_state.error = Some("HID backend stopped".to_string());
|
||||||
|
shutdown_state.error_code = Some("shutdown".to_string());
|
||||||
}
|
}
|
||||||
|
self.apply_runtime_state(shutdown_state).await;
|
||||||
|
|
||||||
info!("HID controller shutdown complete");
|
info!("HID controller shutdown complete");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -241,7 +340,7 @@ impl HidController {
|
|||||||
|
|
||||||
/// Check if backend is available
|
/// Check if backend is available
|
||||||
pub async fn is_available(&self) -> bool {
|
pub async fn is_available(&self) -> bool {
|
||||||
self.backend.read().await.is_some()
|
self.backend_available.load(Ordering::Acquire)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get backend type
|
/// Get backend type
|
||||||
@@ -251,60 +350,29 @@ impl HidController {
|
|||||||
|
|
||||||
/// Get backend info
|
/// Get backend info
|
||||||
pub async fn info(&self) -> Option<HidInfo> {
|
pub async fn info(&self) -> Option<HidInfo> {
|
||||||
let backend = self.backend.read().await;
|
let state = self.runtime_state.read().await.clone();
|
||||||
backend.as_ref().map(|b| HidInfo {
|
if !state.available {
|
||||||
name: b.name(),
|
return None;
|
||||||
initialized: true,
|
}
|
||||||
supports_absolute_mouse: b.supports_absolute_mouse(),
|
|
||||||
screen_resolution: b.screen_resolution(),
|
Some(HidInfo {
|
||||||
|
name: state.backend,
|
||||||
|
initialized: state.initialized,
|
||||||
|
supports_absolute_mouse: state.supports_absolute_mouse,
|
||||||
|
screen_resolution: state.screen_resolution,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current state as SystemEvent
|
/// Get current HID runtime state snapshot.
|
||||||
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
|
pub async fn snapshot(&self) -> HidRuntimeState {
|
||||||
let backend = self.backend.read().await;
|
self.runtime_state.read().await.clone()
|
||||||
let backend_type = self.backend_type().await;
|
|
||||||
let (backend_name, initialized) = match backend.as_ref() {
|
|
||||||
Some(b) => (b.name(), true),
|
|
||||||
None => (backend_type.name_str(), false),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Include error information from monitor
|
|
||||||
let (error, error_code) = match self.monitor.status().await {
|
|
||||||
HidHealthStatus::Error {
|
|
||||||
reason, error_code, ..
|
|
||||||
} => (Some(reason), Some(error_code)),
|
|
||||||
_ => (None, None),
|
|
||||||
};
|
|
||||||
|
|
||||||
crate::events::SystemEvent::HidStateChanged {
|
|
||||||
backend: backend_name.to_string(),
|
|
||||||
initialized,
|
|
||||||
error,
|
|
||||||
error_code,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the health monitor reference
|
|
||||||
pub fn monitor(&self) -> &Arc<HidHealthMonitor> {
|
|
||||||
&self.monitor
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current health status
|
|
||||||
pub async fn health_status(&self) -> HidHealthStatus {
|
|
||||||
self.monitor.status().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the HID backend is healthy
|
|
||||||
pub async fn is_healthy(&self) -> bool {
|
|
||||||
self.monitor.is_healthy().await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload the HID backend with new type
|
/// Reload the HID backend with new type
|
||||||
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
|
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
|
||||||
info!("Reloading HID backend: {:?}", new_backend_type);
|
info!("Reloading HID backend: {:?}", new_backend_type);
|
||||||
self.backend_available.store(false, Ordering::Release);
|
self.backend_available.store(false, Ordering::Release);
|
||||||
self.stop_health_checker().await;
|
self.stop_runtime_worker().await;
|
||||||
|
|
||||||
// Shutdown existing backend first
|
// Shutdown existing backend first
|
||||||
if let Some(backend) = self.backend.write().await.take() {
|
if let Some(backend) = self.backend.write().await.take() {
|
||||||
@@ -329,9 +397,8 @@ impl HidController {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Request HID functions from OtgService
|
match otg_service.hid_device_paths().await {
|
||||||
match otg_service.enable_hid().await {
|
Some(handles) => {
|
||||||
Ok(handles) => {
|
|
||||||
// Create OtgBackend from handles
|
// Create OtgBackend from handles
|
||||||
match otg::OtgBackend::from_handles(handles) {
|
match otg::OtgBackend::from_handles(handles) {
|
||||||
Ok(backend) => {
|
Ok(backend) => {
|
||||||
@@ -343,29 +410,18 @@ impl HidController {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to initialize OTG backend: {}", e);
|
warn!("Failed to initialize OTG backend: {}", e);
|
||||||
// Cleanup: disable HID in OtgService
|
|
||||||
if let Err(e2) = otg_service.disable_hid().await {
|
|
||||||
warn!(
|
|
||||||
"Failed to cleanup HID after init failure: {}",
|
|
||||||
e2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to create OTG backend: {}", e);
|
warn!("Failed to create OTG backend: {}", e);
|
||||||
// Cleanup: disable HID in OtgService
|
|
||||||
if let Err(e2) = otg_service.disable_hid().await {
|
|
||||||
warn!("Failed to cleanup HID after creation failure: {}", e2);
|
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
None => {
|
||||||
warn!("Failed to enable HID in OtgService: {}", e);
|
warn!("OTG HID paths are not available");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,27 +459,22 @@ impl HidController {
|
|||||||
|
|
||||||
*self.backend.write().await = new_backend;
|
*self.backend.write().await = new_backend;
|
||||||
|
|
||||||
|
if matches!(new_backend_type, HidBackendType::None) {
|
||||||
|
*self.backend_type.write().await = HidBackendType::None;
|
||||||
|
self.apply_runtime_state(HidRuntimeState::from_backend_type(&HidBackendType::None))
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if self.backend.read().await.is_some() {
|
if self.backend.read().await.is_some() {
|
||||||
info!("HID backend reloaded successfully: {:?}", new_backend_type);
|
info!("HID backend reloaded successfully: {:?}", new_backend_type);
|
||||||
self.backend_available.store(true, Ordering::Release);
|
|
||||||
self.start_event_worker().await;
|
self.start_event_worker().await;
|
||||||
self.start_health_checker().await;
|
|
||||||
|
|
||||||
// Update backend_type on success
|
// Update backend_type on success
|
||||||
*self.backend_type.write().await = new_backend_type.clone();
|
*self.backend_type.write().await = new_backend_type.clone();
|
||||||
|
|
||||||
// Reset monitor state on successful reload
|
self.sync_runtime_state_from_backend().await;
|
||||||
self.monitor.reset().await;
|
self.restart_runtime_worker().await;
|
||||||
|
|
||||||
// Publish HID state changed event
|
|
||||||
let backend_name = new_backend_type.name_str().to_string();
|
|
||||||
self.publish_event(crate::events::SystemEvent::HidStateChanged {
|
|
||||||
backend: backend_name,
|
|
||||||
initialized: true,
|
|
||||||
error: None,
|
|
||||||
error_code: None,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -433,14 +484,14 @@ impl HidController {
|
|||||||
// Update backend_type even on failure (to reflect the attempted change)
|
// Update backend_type even on failure (to reflect the attempted change)
|
||||||
*self.backend_type.write().await = new_backend_type.clone();
|
*self.backend_type.write().await = new_backend_type.clone();
|
||||||
|
|
||||||
// Publish event with initialized=false
|
let current = self.runtime_state.read().await.clone();
|
||||||
self.publish_event(crate::events::SystemEvent::HidStateChanged {
|
let error_state = HidRuntimeState::with_error(
|
||||||
backend: new_backend_type.name_str().to_string(),
|
&new_backend_type,
|
||||||
initialized: false,
|
¤t,
|
||||||
error: Some("Failed to initialize HID backend".to_string()),
|
"Failed to initialize HID backend",
|
||||||
error_code: Some("init_failed".to_string()),
|
"init_failed",
|
||||||
})
|
);
|
||||||
.await;
|
self.apply_runtime_state(error_state).await;
|
||||||
|
|
||||||
Err(AppError::Internal(
|
Err(AppError::Internal(
|
||||||
"Failed to reload HID backend".to_string(),
|
"Failed to reload HID backend".to_string(),
|
||||||
@@ -448,11 +499,20 @@ impl HidController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish event to event bus if available
|
async fn apply_runtime_state(&self, next: HidRuntimeState) {
|
||||||
async fn publish_event(&self, event: crate::events::SystemEvent) {
|
apply_runtime_state(&self.runtime_state, &self.events, next).await;
|
||||||
if let Some(events) = self.events.read().await.as_ref() {
|
|
||||||
events.publish(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn sync_runtime_state_from_backend(&self) {
|
||||||
|
let backend_opt = self.backend.read().await.clone();
|
||||||
|
apply_backend_runtime_state(
|
||||||
|
&self.backend_type,
|
||||||
|
&self.runtime_state,
|
||||||
|
&self.events,
|
||||||
|
self.backend_available.as_ref(),
|
||||||
|
backend_opt.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_event_worker(&self) {
|
async fn start_event_worker(&self) {
|
||||||
@@ -468,8 +528,6 @@ impl HidController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let backend = self.backend.clone();
|
let backend = self.backend.clone();
|
||||||
let monitor = self.monitor.clone();
|
|
||||||
let backend_type = self.backend_type.clone();
|
|
||||||
let pending_move = self.pending_move.clone();
|
let pending_move = self.pending_move.clone();
|
||||||
let pending_move_flag = self.pending_move_flag.clone();
|
let pending_move_flag = self.pending_move_flag.clone();
|
||||||
|
|
||||||
@@ -481,19 +539,13 @@ impl HidController {
|
|||||||
None => break,
|
None => break,
|
||||||
};
|
};
|
||||||
|
|
||||||
process_hid_event(event, &backend, &monitor, &backend_type).await;
|
process_hid_event(event, &backend).await;
|
||||||
|
|
||||||
// After each event, flush latest move if pending
|
// After each event, flush latest move if pending
|
||||||
if pending_move_flag.swap(false, Ordering::AcqRel) {
|
if pending_move_flag.swap(false, Ordering::AcqRel) {
|
||||||
let move_event = { pending_move.lock().take() };
|
let move_event = { pending_move.lock().take() };
|
||||||
if let Some(move_event) = move_event {
|
if let Some(move_event) = move_event {
|
||||||
process_hid_event(
|
process_hid_event(HidEvent::Mouse(move_event), &backend).await;
|
||||||
HidEvent::Mouse(move_event),
|
|
||||||
&backend,
|
|
||||||
&monitor,
|
|
||||||
&backend_type,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,84 +554,43 @@ impl HidController {
|
|||||||
*worker_guard = Some(handle);
|
*worker_guard = Some(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_health_checker(&self) {
|
async fn restart_runtime_worker(&self) {
|
||||||
let mut checker_guard = self.hid_health_checker.lock().await;
|
self.stop_runtime_worker().await;
|
||||||
if checker_guard.is_some() {
|
|
||||||
|
let backend_opt = self.backend.read().await.clone();
|
||||||
|
let Some(backend) = backend_opt else {
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
let backend = self.backend.clone();
|
|
||||||
let backend_type = self.backend_type.clone();
|
|
||||||
let monitor = self.monitor.clone();
|
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
let mut ticker =
|
|
||||||
tokio::time::interval(Duration::from_millis(HID_HEALTH_CHECK_INTERVAL_MS));
|
|
||||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
ticker.tick().await;
|
|
||||||
|
|
||||||
let backend_opt = backend.read().await.clone();
|
|
||||||
let Some(active_backend) = backend_opt else {
|
|
||||||
continue;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let backend_name = backend_type.read().await.name_str().to_string();
|
let mut runtime_rx = backend.subscribe_runtime();
|
||||||
let result =
|
let runtime_state = self.runtime_state.clone();
|
||||||
tokio::task::spawn_blocking(move || active_backend.health_check()).await;
|
let events = self.events.clone();
|
||||||
|
let backend_available = self.backend_available.clone();
|
||||||
|
let backend_type = self.backend_type.clone();
|
||||||
|
|
||||||
match result {
|
let handle = tokio::spawn(async move {
|
||||||
Ok(Ok(())) => {
|
loop {
|
||||||
if monitor.is_error().await {
|
if runtime_rx.changed().await.is_err() {
|
||||||
monitor.report_recovered(&backend_name).await;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(Err(AppError::HidError {
|
apply_backend_runtime_state(
|
||||||
backend,
|
&backend_type,
|
||||||
reason,
|
&runtime_state,
|
||||||
error_code,
|
&events,
|
||||||
})) => {
|
backend_available.as_ref(),
|
||||||
monitor
|
Some(backend.as_ref()),
|
||||||
.report_error(&backend, None, &reason, &error_code)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
monitor
|
|
||||||
.report_error(
|
|
||||||
&backend_name,
|
|
||||||
None,
|
|
||||||
&format!("HID health check failed: {}", e),
|
|
||||||
"health_check_failed",
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
monitor
|
|
||||||
.report_error(
|
|
||||||
&backend_name,
|
|
||||||
None,
|
|
||||||
&format!("HID health check task failed: {}", e),
|
|
||||||
"health_check_join_failed",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
*checker_guard = Some(handle);
|
*self.runtime_worker.lock().await = Some(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_health_checker(&self) {
|
async fn stop_runtime_worker(&self) {
|
||||||
let handle_opt = {
|
if let Some(handle) = self.runtime_worker.lock().await.take() {
|
||||||
let mut checker_guard = self.hid_health_checker.lock().await;
|
|
||||||
checker_guard.take()
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(handle) = handle_opt {
|
|
||||||
handle.abort();
|
handle.abort();
|
||||||
let _ = handle.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,25 +633,37 @@ impl HidController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_hid_event(
|
async fn apply_backend_runtime_state(
|
||||||
event: HidEvent,
|
|
||||||
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
|
|
||||||
monitor: &Arc<HidHealthMonitor>,
|
|
||||||
backend_type: &Arc<RwLock<HidBackendType>>,
|
backend_type: &Arc<RwLock<HidBackendType>>,
|
||||||
|
runtime_state: &Arc<RwLock<HidRuntimeState>>,
|
||||||
|
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
|
||||||
|
backend_available: &AtomicBool,
|
||||||
|
backend: Option<&dyn HidBackend>,
|
||||||
) {
|
) {
|
||||||
|
let backend_kind = backend_type.read().await.clone();
|
||||||
|
let next = match backend {
|
||||||
|
Some(backend) => HidRuntimeState::from_backend(&backend_kind, backend.runtime_snapshot()),
|
||||||
|
None => HidRuntimeState::from_backend_type(&backend_kind),
|
||||||
|
};
|
||||||
|
backend_available.store(next.initialized, Ordering::Release);
|
||||||
|
apply_runtime_state(runtime_state, events, next).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_hid_event(event: HidEvent, backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>) {
|
||||||
let backend_opt = backend.read().await.clone();
|
let backend_opt = backend.read().await.clone();
|
||||||
let backend = match backend_opt {
|
let backend = match backend_opt {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let backend_for_send = backend.clone();
|
||||||
let result = tokio::task::spawn_blocking(move || {
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
futures::executor::block_on(async move {
|
futures::executor::block_on(async move {
|
||||||
match event {
|
match event {
|
||||||
HidEvent::Keyboard(ev) => backend.send_keyboard(ev).await,
|
HidEvent::Keyboard(ev) => backend_for_send.send_keyboard(ev).await,
|
||||||
HidEvent::Mouse(ev) => backend.send_mouse(ev).await,
|
HidEvent::Mouse(ev) => backend_for_send.send_mouse(ev).await,
|
||||||
HidEvent::Consumer(ev) => backend.send_consumer(ev).await,
|
HidEvent::Consumer(ev) => backend_for_send.send_consumer(ev).await,
|
||||||
HidEvent::Reset => backend.reset().await,
|
HidEvent::Reset => backend_for_send.reset().await,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -652,25 +675,9 @@ async fn process_hid_event(
|
|||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {}
|
||||||
if monitor.is_error().await {
|
|
||||||
let backend_type = backend_type.read().await;
|
|
||||||
monitor.report_recovered(backend_type.name_str()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let AppError::HidError {
|
warn!("HID event processing failed: {}", e);
|
||||||
ref backend,
|
|
||||||
ref reason,
|
|
||||||
ref error_code,
|
|
||||||
} = e
|
|
||||||
{
|
|
||||||
if error_code != "eagain_retry" {
|
|
||||||
monitor
|
|
||||||
.report_error(backend, None, reason, error_code)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,3 +687,34 @@ impl Default for HidController {
|
|||||||
Self::new(HidBackendType::None, None)
|
Self::new(HidBackendType::None, None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn device_for_backend_type(backend_type: &HidBackendType) -> Option<String> {
|
||||||
|
match backend_type {
|
||||||
|
HidBackendType::Ch9329 { port, .. } => Some(port.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_runtime_state(
|
||||||
|
runtime_state: &Arc<RwLock<HidRuntimeState>>,
|
||||||
|
events: &Arc<tokio::sync::RwLock<Option<Arc<EventBus>>>>,
|
||||||
|
next: HidRuntimeState,
|
||||||
|
) {
|
||||||
|
let changed = {
|
||||||
|
let mut guard = runtime_state.write().await;
|
||||||
|
if *guard == next {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
*guard = next.clone();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(events) = events.read().await.as_ref() {
|
||||||
|
events.mark_device_info_dirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
470
src/hid/otg.rs
470
src/hid/otg.rs
@@ -1,10 +1,12 @@
|
|||||||
//! OTG USB Gadget HID backend
|
//! OTG USB Gadget HID backend
|
||||||
//!
|
//!
|
||||||
//! This backend uses Linux USB Gadget API to emulate USB HID devices.
|
//! This backend uses Linux USB Gadget API to emulate USB HID devices.
|
||||||
//! It creates and manages three HID devices:
|
//! It opens the HID gadget device nodes created by `OtgService`.
|
||||||
//! - hidg0: Keyboard (8-byte reports, with LED feedback)
|
//! Depending on the configured OTG profile, this may include:
|
||||||
//! - hidg1: Relative Mouse (4-byte reports)
|
//! - hidg0: Keyboard
|
||||||
//! - hidg2: Absolute Mouse (6-byte reports)
|
//! - hidg1: Relative Mouse
|
||||||
|
//! - hidg2: Absolute Mouse
|
||||||
|
//! - hidg3: Consumer Control Keyboard
|
||||||
//!
|
//!
|
||||||
//! Requirements:
|
//! Requirements:
|
||||||
//! - USB OTG/Device controller (UDC)
|
//! - USB OTG/Device controller (UDC)
|
||||||
@@ -20,16 +22,20 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::os::unix::io::AsFd;
|
use std::os::unix::io::AsFd;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::watch;
|
||||||
use tracing::{debug, info, trace, warn};
|
use tracing::{debug, info, trace, warn};
|
||||||
|
|
||||||
use super::backend::HidBackend;
|
use super::backend::{HidBackend, HidBackendRuntimeSnapshot};
|
||||||
use super::keymap;
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
|
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
|
||||||
};
|
};
|
||||||
@@ -46,7 +52,7 @@ enum DeviceType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Keyboard LED state
|
/// Keyboard LED state
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
pub struct LedState {
|
pub struct LedState {
|
||||||
/// Num Lock LED
|
/// Num Lock LED
|
||||||
pub num_lock: bool,
|
pub num_lock: bool,
|
||||||
@@ -124,24 +130,36 @@ pub struct OtgBackend {
|
|||||||
mouse_abs_dev: Mutex<Option<File>>,
|
mouse_abs_dev: Mutex<Option<File>>,
|
||||||
/// Consumer control device file
|
/// Consumer control device file
|
||||||
consumer_dev: Mutex<Option<File>>,
|
consumer_dev: Mutex<Option<File>>,
|
||||||
|
/// Whether keyboard LED/status feedback is enabled.
|
||||||
|
keyboard_leds_enabled: bool,
|
||||||
/// Current keyboard state
|
/// Current keyboard state
|
||||||
keyboard_state: Mutex<KeyboardReport>,
|
keyboard_state: Mutex<KeyboardReport>,
|
||||||
/// Current mouse button state
|
/// Current mouse button state
|
||||||
mouse_buttons: AtomicU8,
|
mouse_buttons: AtomicU8,
|
||||||
/// Last known LED state (using parking_lot::RwLock for sync access)
|
/// Last known LED state (using parking_lot::RwLock for sync access)
|
||||||
led_state: parking_lot::RwLock<LedState>,
|
led_state: Arc<parking_lot::RwLock<LedState>>,
|
||||||
/// Screen resolution for absolute mouse (using parking_lot::RwLock for sync access)
|
/// Screen resolution for absolute mouse (using parking_lot::RwLock for sync access)
|
||||||
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
|
screen_resolution: parking_lot::RwLock<Option<(u32, u32)>>,
|
||||||
/// UDC name for state checking (e.g., "fcc00000.usb")
|
/// UDC name for state checking (e.g., "fcc00000.usb")
|
||||||
udc_name: parking_lot::RwLock<Option<String>>,
|
udc_name: Arc<parking_lot::RwLock<Option<String>>>,
|
||||||
|
/// Whether the backend has been initialized.
|
||||||
|
initialized: AtomicBool,
|
||||||
/// Whether the device is currently online (UDC configured and devices accessible)
|
/// Whether the device is currently online (UDC configured and devices accessible)
|
||||||
online: AtomicBool,
|
online: AtomicBool,
|
||||||
|
/// Last backend error state.
|
||||||
|
last_error: parking_lot::RwLock<Option<(String, String)>>,
|
||||||
/// Last error log time for throttling (using parking_lot for sync)
|
/// Last error log time for throttling (using parking_lot for sync)
|
||||||
last_error_log: parking_lot::Mutex<std::time::Instant>,
|
last_error_log: parking_lot::Mutex<std::time::Instant>,
|
||||||
/// Error count since last successful operation (for log throttling)
|
/// Error count since last successful operation (for log throttling)
|
||||||
error_count: AtomicU8,
|
error_count: AtomicU8,
|
||||||
/// Consecutive EAGAIN count (for offline threshold detection)
|
/// Consecutive EAGAIN count (for offline threshold detection)
|
||||||
eagain_count: AtomicU8,
|
eagain_count: AtomicU8,
|
||||||
|
/// Runtime change notifier.
|
||||||
|
runtime_notify_tx: watch::Sender<()>,
|
||||||
|
/// Runtime monitor stop flag.
|
||||||
|
runtime_worker_stop: Arc<AtomicBool>,
|
||||||
|
/// Runtime monitor thread.
|
||||||
|
runtime_worker: Mutex<Option<thread::JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout)
|
/// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout)
|
||||||
@@ -153,6 +171,7 @@ impl OtgBackend {
|
|||||||
/// This is the ONLY way to create an OtgBackend - it no longer manages
|
/// This is the ONLY way to create an OtgBackend - it no longer manages
|
||||||
/// the USB gadget itself. The gadget must already be set up by OtgService.
|
/// the USB gadget itself. The gadget must already be set up by OtgService.
|
||||||
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
|
pub fn from_handles(paths: HidDevicePaths) -> Result<Self> {
|
||||||
|
let (runtime_notify_tx, _runtime_notify_rx) = watch::channel(());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
keyboard_path: paths.keyboard,
|
keyboard_path: paths.keyboard,
|
||||||
mouse_rel_path: paths.mouse_relative,
|
mouse_rel_path: paths.mouse_relative,
|
||||||
@@ -162,18 +181,59 @@ impl OtgBackend {
|
|||||||
mouse_rel_dev: Mutex::new(None),
|
mouse_rel_dev: Mutex::new(None),
|
||||||
mouse_abs_dev: Mutex::new(None),
|
mouse_abs_dev: Mutex::new(None),
|
||||||
consumer_dev: Mutex::new(None),
|
consumer_dev: Mutex::new(None),
|
||||||
|
keyboard_leds_enabled: paths.keyboard_leds_enabled,
|
||||||
keyboard_state: Mutex::new(KeyboardReport::default()),
|
keyboard_state: Mutex::new(KeyboardReport::default()),
|
||||||
mouse_buttons: AtomicU8::new(0),
|
mouse_buttons: AtomicU8::new(0),
|
||||||
led_state: parking_lot::RwLock::new(LedState::default()),
|
led_state: Arc::new(parking_lot::RwLock::new(LedState::default())),
|
||||||
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
|
screen_resolution: parking_lot::RwLock::new(Some((1920, 1080))),
|
||||||
udc_name: parking_lot::RwLock::new(None),
|
udc_name: Arc::new(parking_lot::RwLock::new(paths.udc)),
|
||||||
|
initialized: AtomicBool::new(false),
|
||||||
online: AtomicBool::new(false),
|
online: AtomicBool::new(false),
|
||||||
|
last_error: parking_lot::RwLock::new(None),
|
||||||
last_error_log: parking_lot::Mutex::new(std::time::Instant::now()),
|
last_error_log: parking_lot::Mutex::new(std::time::Instant::now()),
|
||||||
error_count: AtomicU8::new(0),
|
error_count: AtomicU8::new(0),
|
||||||
eagain_count: AtomicU8::new(0),
|
eagain_count: AtomicU8::new(0),
|
||||||
|
runtime_notify_tx,
|
||||||
|
runtime_worker_stop: Arc::new(AtomicBool::new(false)),
|
||||||
|
runtime_worker: Mutex::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn notify_runtime_changed(&self) {
|
||||||
|
let _ = self.runtime_notify_tx.send(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_error(&self) {
|
||||||
|
let mut error = self.last_error.write();
|
||||||
|
if error.is_some() {
|
||||||
|
*error = None;
|
||||||
|
self.notify_runtime_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_error(&self, reason: impl Into<String>, error_code: impl Into<String>) {
|
||||||
|
let reason = reason.into();
|
||||||
|
let error_code = error_code.into();
|
||||||
|
let was_online = self.online.swap(false, Ordering::Relaxed);
|
||||||
|
let mut error = self.last_error.write();
|
||||||
|
let changed = error.as_ref() != Some(&(reason.clone(), error_code.clone()));
|
||||||
|
*error = Some((reason, error_code));
|
||||||
|
drop(error);
|
||||||
|
if was_online || changed {
|
||||||
|
self.notify_runtime_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_online(&self) {
|
||||||
|
let was_online = self.online.swap(true, Ordering::Relaxed);
|
||||||
|
let mut error = self.last_error.write();
|
||||||
|
let cleared_error = error.take().is_some();
|
||||||
|
drop(error);
|
||||||
|
if !was_online || cleared_error {
|
||||||
|
self.notify_runtime_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Log throttled error message (max once per second)
|
/// Log throttled error message (max once per second)
|
||||||
fn log_throttled_error(&self, msg: &str) {
|
fn log_throttled_error(&self, msg: &str) {
|
||||||
let mut last_log = self.last_error_log.lock();
|
let mut last_log = self.last_error_log.lock();
|
||||||
@@ -237,13 +297,16 @@ impl OtgBackend {
|
|||||||
*self.udc_name.write() = Some(udc.to_string());
|
*self.udc_name.write() = Some(udc.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the UDC is in "configured" state
|
fn read_udc_configured(udc_name: &parking_lot::RwLock<Option<String>>) -> bool {
|
||||||
///
|
let current_udc = udc_name.read().clone().or_else(Self::find_udc);
|
||||||
/// This is based on PiKVM's `__is_udc_configured()` method.
|
if let Some(udc) = current_udc {
|
||||||
/// The UDC state file indicates whether the USB host has enumerated and configured the gadget.
|
{
|
||||||
pub fn is_udc_configured(&self) -> bool {
|
let mut guard = udc_name.write();
|
||||||
let udc_name = self.udc_name.read();
|
if guard.as_ref() != Some(&udc) {
|
||||||
if let Some(ref udc) = *udc_name {
|
*guard = Some(udc.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let state_path = format!("/sys/class/udc/{}/state", udc);
|
let state_path = format!("/sys/class/udc/{}/state", udc);
|
||||||
match fs::read_to_string(&state_path) {
|
match fs::read_to_string(&state_path) {
|
||||||
Ok(content) => {
|
Ok(content) => {
|
||||||
@@ -253,24 +316,20 @@ impl OtgBackend {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
debug!("Failed to read UDC state from {}: {}", state_path, e);
|
debug!("Failed to read UDC state from {}: {}", state_path, e);
|
||||||
// If we can't read the state, assume it might be configured
|
|
||||||
// to avoid blocking operations unnecessarily
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// No UDC name set, try to auto-detect
|
|
||||||
if let Some(udc) = Self::find_udc() {
|
|
||||||
drop(udc_name);
|
|
||||||
*self.udc_name.write() = Some(udc.clone());
|
|
||||||
let state_path = format!("/sys/class/udc/{}/state", udc);
|
|
||||||
fs::read_to_string(&state_path)
|
|
||||||
.map(|s| s.trim().to_lowercase() == "configured")
|
|
||||||
.unwrap_or(true)
|
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if the UDC is in "configured" state
|
||||||
|
///
|
||||||
|
/// This is based on PiKVM's `__is_udc_configured()` method.
|
||||||
|
/// The UDC state file indicates whether the USB host has enumerated and configured the gadget.
|
||||||
|
pub fn is_udc_configured(&self) -> bool {
|
||||||
|
Self::read_udc_configured(&self.udc_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the first available UDC
|
/// Find the first available UDC
|
||||||
@@ -286,11 +345,6 @@ impl OtgBackend {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if device is online
|
|
||||||
pub fn is_online(&self) -> bool {
|
|
||||||
self.online.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure a device is open and ready for I/O
|
/// Ensure a device is open and ready for I/O
|
||||||
///
|
///
|
||||||
/// This method is based on PiKVM's `__ensure_device()` pattern:
|
/// This method is based on PiKVM's `__ensure_device()` pattern:
|
||||||
@@ -308,12 +362,13 @@ impl OtgBackend {
|
|||||||
let path = match path_opt {
|
let path = match path_opt {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
self.online.store(false, Ordering::Relaxed);
|
let err = AppError::HidError {
|
||||||
return Err(AppError::HidError {
|
|
||||||
backend: "otg".to_string(),
|
backend: "otg".to_string(),
|
||||||
reason: "Device disabled".to_string(),
|
reason: "Device disabled".to_string(),
|
||||||
error_code: "disabled".to_string(),
|
error_code: "disabled".to_string(),
|
||||||
});
|
};
|
||||||
|
self.record_error("Device disabled", "disabled");
|
||||||
|
return Err(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -328,10 +383,11 @@ impl OtgBackend {
|
|||||||
);
|
);
|
||||||
*dev = None;
|
*dev = None;
|
||||||
}
|
}
|
||||||
self.online.store(false, Ordering::Relaxed);
|
let reason = format!("Device not found: {}", path.display());
|
||||||
|
self.record_error(reason.clone(), "enoent");
|
||||||
return Err(AppError::HidError {
|
return Err(AppError::HidError {
|
||||||
backend: "otg".to_string(),
|
backend: "otg".to_string(),
|
||||||
reason: format!("Device not found: {}", path.display()),
|
reason,
|
||||||
error_code: "enoent".to_string(),
|
error_code: "enoent".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -346,12 +402,16 @@ impl OtgBackend {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to reopen HID device {}: {}", path.display(), e);
|
warn!("Failed to reopen HID device {}: {}", path.display(), e);
|
||||||
|
self.record_error(
|
||||||
|
format!("Failed to reopen HID device {}: {}", path.display(), e),
|
||||||
|
"not_opened",
|
||||||
|
);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.online.store(true, Ordering::Relaxed);
|
self.mark_online();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,8 +432,8 @@ impl OtgBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert I/O error to HidError with appropriate error code
|
/// Convert I/O error to HidError with appropriate error code
|
||||||
fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError {
|
fn io_error_code(e: &std::io::Error) -> &'static str {
|
||||||
let error_code = match e.raw_os_error() {
|
match e.raw_os_error() {
|
||||||
Some(32) => "epipe", // EPIPE - broken pipe
|
Some(32) => "epipe", // EPIPE - broken pipe
|
||||||
Some(108) => "eshutdown", // ESHUTDOWN - transport endpoint shutdown
|
Some(108) => "eshutdown", // ESHUTDOWN - transport endpoint shutdown
|
||||||
Some(11) => "eagain", // EAGAIN - resource temporarily unavailable
|
Some(11) => "eagain", // EAGAIN - resource temporarily unavailable
|
||||||
@@ -382,7 +442,11 @@ impl OtgBackend {
|
|||||||
Some(5) => "eio", // EIO - I/O error
|
Some(5) => "eio", // EIO - I/O error
|
||||||
Some(2) => "enoent", // ENOENT - no such file or directory
|
Some(2) => "enoent", // ENOENT - no such file or directory
|
||||||
_ => "io_error",
|
_ => "io_error",
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_error_to_hid_error(e: std::io::Error, operation: &str) -> AppError {
|
||||||
|
let error_code = Self::io_error_code(&e);
|
||||||
|
|
||||||
AppError::HidError {
|
AppError::HidError {
|
||||||
backend: "otg".to_string(),
|
backend: "otg".to_string(),
|
||||||
@@ -438,7 +502,7 @@ impl OtgBackend {
|
|||||||
let data = report.to_bytes();
|
let data = report.to_bytes();
|
||||||
match self.write_with_timeout(file, &data) {
|
match self.write_with_timeout(file, &data) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
self.online.store(true, Ordering::Relaxed);
|
self.mark_online();
|
||||||
self.reset_error_count();
|
self.reset_error_count();
|
||||||
debug!("Sent keyboard report: {:02X?}", data);
|
debug!("Sent keyboard report: {:02X?}", data);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -454,10 +518,13 @@ impl OtgBackend {
|
|||||||
match error_code {
|
match error_code {
|
||||||
Some(108) => {
|
Some(108) => {
|
||||||
// ESHUTDOWN - endpoint closed, need to reopen device
|
// ESHUTDOWN - endpoint closed, need to reopen device
|
||||||
self.online.store(false, Ordering::Relaxed);
|
|
||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
debug!("Keyboard ESHUTDOWN, closing for recovery");
|
debug!("Keyboard ESHUTDOWN, closing for recovery");
|
||||||
*dev = None;
|
*dev = None;
|
||||||
|
self.record_error(
|
||||||
|
format!("Failed to write keyboard report: {}", e),
|
||||||
|
"eshutdown",
|
||||||
|
);
|
||||||
Err(Self::io_error_to_hid_error(
|
Err(Self::io_error_to_hid_error(
|
||||||
e,
|
e,
|
||||||
"Failed to write keyboard report",
|
"Failed to write keyboard report",
|
||||||
@@ -469,9 +536,12 @@ impl OtgBackend {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.online.store(false, Ordering::Relaxed);
|
|
||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
warn!("Keyboard write error: {}", e);
|
warn!("Keyboard write error: {}", e);
|
||||||
|
self.record_error(
|
||||||
|
format!("Failed to write keyboard report: {}", e),
|
||||||
|
Self::io_error_code(&e),
|
||||||
|
);
|
||||||
Err(Self::io_error_to_hid_error(
|
Err(Self::io_error_to_hid_error(
|
||||||
e,
|
e,
|
||||||
"Failed to write keyboard report",
|
"Failed to write keyboard report",
|
||||||
@@ -507,7 +577,7 @@ impl OtgBackend {
|
|||||||
let data = [buttons, dx as u8, dy as u8, wheel as u8];
|
let data = [buttons, dx as u8, dy as u8, wheel as u8];
|
||||||
match self.write_with_timeout(file, &data) {
|
match self.write_with_timeout(file, &data) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
self.online.store(true, Ordering::Relaxed);
|
self.mark_online();
|
||||||
self.reset_error_count();
|
self.reset_error_count();
|
||||||
trace!("Sent relative mouse report: {:02X?}", data);
|
trace!("Sent relative mouse report: {:02X?}", data);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -521,10 +591,13 @@ impl OtgBackend {
|
|||||||
|
|
||||||
match error_code {
|
match error_code {
|
||||||
Some(108) => {
|
Some(108) => {
|
||||||
self.online.store(false, Ordering::Relaxed);
|
|
||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
debug!("Relative mouse ESHUTDOWN, closing for recovery");
|
debug!("Relative mouse ESHUTDOWN, closing for recovery");
|
||||||
*dev = None;
|
*dev = None;
|
||||||
|
self.record_error(
|
||||||
|
format!("Failed to write mouse report: {}", e),
|
||||||
|
"eshutdown",
|
||||||
|
);
|
||||||
Err(Self::io_error_to_hid_error(
|
Err(Self::io_error_to_hid_error(
|
||||||
e,
|
e,
|
||||||
"Failed to write mouse report",
|
"Failed to write mouse report",
|
||||||
@@ -535,9 +608,12 @@ impl OtgBackend {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.online.store(false, Ordering::Relaxed);
|
|
||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
warn!("Relative mouse write error: {}", e);
|
warn!("Relative mouse write error: {}", e);
|
||||||
|
self.record_error(
|
||||||
|
format!("Failed to write mouse report: {}", e),
|
||||||
|
Self::io_error_code(&e),
|
||||||
|
);
|
||||||
Err(Self::io_error_to_hid_error(
|
Err(Self::io_error_to_hid_error(
|
||||||
e,
|
e,
|
||||||
"Failed to write mouse report",
|
"Failed to write mouse report",
|
||||||
@@ -580,7 +656,7 @@ impl OtgBackend {
|
|||||||
];
|
];
|
||||||
match self.write_with_timeout(file, &data) {
|
match self.write_with_timeout(file, &data) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
self.online.store(true, Ordering::Relaxed);
|
self.mark_online();
|
||||||
self.reset_error_count();
|
self.reset_error_count();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -593,10 +669,13 @@ impl OtgBackend {
|
|||||||
|
|
||||||
match error_code {
|
match error_code {
|
||||||
Some(108) => {
|
Some(108) => {
|
||||||
self.online.store(false, Ordering::Relaxed);
|
|
||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
debug!("Absolute mouse ESHUTDOWN, closing for recovery");
|
debug!("Absolute mouse ESHUTDOWN, closing for recovery");
|
||||||
*dev = None;
|
*dev = None;
|
||||||
|
self.record_error(
|
||||||
|
format!("Failed to write mouse report: {}", e),
|
||||||
|
"eshutdown",
|
||||||
|
);
|
||||||
Err(Self::io_error_to_hid_error(
|
Err(Self::io_error_to_hid_error(
|
||||||
e,
|
e,
|
||||||
"Failed to write mouse report",
|
"Failed to write mouse report",
|
||||||
@@ -607,9 +686,12 @@ impl OtgBackend {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.online.store(false, Ordering::Relaxed);
|
|
||||||
self.eagain_count.store(0, Ordering::Relaxed);
|
self.eagain_count.store(0, Ordering::Relaxed);
|
||||||
warn!("Absolute mouse write error: {}", e);
|
warn!("Absolute mouse write error: {}", e);
|
||||||
|
self.record_error(
|
||||||
|
format!("Failed to write mouse report: {}", e),
|
||||||
|
Self::io_error_code(&e),
|
||||||
|
);
|
||||||
Err(Self::io_error_to_hid_error(
|
Err(Self::io_error_to_hid_error(
|
||||||
e,
|
e,
|
||||||
"Failed to write mouse report",
|
"Failed to write mouse report",
|
||||||
@@ -648,7 +730,7 @@ impl OtgBackend {
|
|||||||
// Send release (0x0000)
|
// Send release (0x0000)
|
||||||
let release = [0u8, 0u8];
|
let release = [0u8, 0u8];
|
||||||
let _ = self.write_with_timeout(file, &release);
|
let _ = self.write_with_timeout(file, &release);
|
||||||
self.online.store(true, Ordering::Relaxed);
|
self.mark_online();
|
||||||
self.reset_error_count();
|
self.reset_error_count();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -660,9 +742,12 @@ impl OtgBackend {
|
|||||||
let error_code = e.raw_os_error();
|
let error_code = e.raw_os_error();
|
||||||
match error_code {
|
match error_code {
|
||||||
Some(108) => {
|
Some(108) => {
|
||||||
self.online.store(false, Ordering::Relaxed);
|
|
||||||
debug!("Consumer control ESHUTDOWN, closing for recovery");
|
debug!("Consumer control ESHUTDOWN, closing for recovery");
|
||||||
*dev = None;
|
*dev = None;
|
||||||
|
self.record_error(
|
||||||
|
format!("Failed to write consumer report: {}", e),
|
||||||
|
"eshutdown",
|
||||||
|
);
|
||||||
Err(Self::io_error_to_hid_error(
|
Err(Self::io_error_to_hid_error(
|
||||||
e,
|
e,
|
||||||
"Failed to write consumer report",
|
"Failed to write consumer report",
|
||||||
@@ -673,8 +758,11 @@ impl OtgBackend {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.online.store(false, Ordering::Relaxed);
|
|
||||||
warn!("Consumer control write error: {}", e);
|
warn!("Consumer control write error: {}", e);
|
||||||
|
self.record_error(
|
||||||
|
format!("Failed to write consumer report: {}", e),
|
||||||
|
Self::io_error_code(&e),
|
||||||
|
);
|
||||||
Err(Self::io_error_to_hid_error(
|
Err(Self::io_error_to_hid_error(
|
||||||
e,
|
e,
|
||||||
"Failed to write consumer report",
|
"Failed to write consumer report",
|
||||||
@@ -697,50 +785,205 @@ impl OtgBackend {
|
|||||||
self.send_consumer_report(event.usage)
|
self.send_consumer_report(event.usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read keyboard LED state (non-blocking)
|
|
||||||
pub fn read_led_state(&self) -> Result<Option<LedState>> {
|
|
||||||
let mut dev = self.keyboard_dev.lock();
|
|
||||||
if let Some(ref mut file) = *dev {
|
|
||||||
let mut buf = [0u8; 1];
|
|
||||||
match file.read(&mut buf) {
|
|
||||||
Ok(1) => {
|
|
||||||
let state = LedState::from_byte(buf[0]);
|
|
||||||
// Update LED state (using parking_lot RwLock)
|
|
||||||
*self.led_state.write() = state;
|
|
||||||
Ok(Some(state))
|
|
||||||
}
|
|
||||||
Ok(_) => Ok(None), // No data available
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
|
|
||||||
Err(e) => Err(AppError::Internal(format!(
|
|
||||||
"Failed to read LED state: {}",
|
|
||||||
e
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get last known LED state
|
/// Get last known LED state
|
||||||
pub fn led_state(&self) -> LedState {
|
pub fn led_state(&self) -> LedState {
|
||||||
*self.led_state.read()
|
*self.led_state.read()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
|
||||||
|
let initialized = self.initialized.load(Ordering::Relaxed);
|
||||||
|
let mut online = initialized && self.online.load(Ordering::Relaxed);
|
||||||
|
let mut error = self.last_error.read().clone();
|
||||||
|
|
||||||
|
if initialized && !self.check_devices_exist() {
|
||||||
|
online = false;
|
||||||
|
let missing = self.get_missing_devices();
|
||||||
|
error = Some((
|
||||||
|
format!("HID device node missing: {}", missing.join(", ")),
|
||||||
|
"enoent".to_string(),
|
||||||
|
));
|
||||||
|
} else if initialized && !self.is_udc_configured() {
|
||||||
|
online = false;
|
||||||
|
error = Some((
|
||||||
|
"UDC is not in configured state".to_string(),
|
||||||
|
"udc_not_configured".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
HidBackendRuntimeSnapshot {
|
||||||
|
initialized,
|
||||||
|
online,
|
||||||
|
supports_absolute_mouse: self.mouse_abs_path.as_ref().is_some_and(|p| p.exists()),
|
||||||
|
keyboard_leds_enabled: self.keyboard_leds_enabled,
|
||||||
|
led_state: self.led_state(),
|
||||||
|
screen_resolution: *self.screen_resolution.read(),
|
||||||
|
device: self.udc_name.read().clone(),
|
||||||
|
error: error.as_ref().map(|(reason, _)| reason.clone()),
|
||||||
|
error_code: error.as_ref().map(|(_, code)| code.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_keyboard_led_once(
|
||||||
|
file: &mut Option<File>,
|
||||||
|
path: &PathBuf,
|
||||||
|
led_state: &Arc<parking_lot::RwLock<LedState>>,
|
||||||
|
) -> bool {
|
||||||
|
if file.is_none() {
|
||||||
|
match OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_NONBLOCK)
|
||||||
|
.open(path)
|
||||||
|
{
|
||||||
|
Ok(opened) => {
|
||||||
|
*file = Some(opened);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
"Failed to open OTG keyboard LED listener {}: {}",
|
||||||
|
path.display(),
|
||||||
|
err
|
||||||
|
);
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(file_ref) = file.as_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pollfd = [PollFd::new(
|
||||||
|
file_ref.as_fd(),
|
||||||
|
PollFlags::POLLIN | PollFlags::POLLERR | PollFlags::POLLHUP,
|
||||||
|
)];
|
||||||
|
|
||||||
|
match poll(&mut pollfd, PollTimeout::from(500u16)) {
|
||||||
|
Ok(0) => false,
|
||||||
|
Ok(_) => {
|
||||||
|
let Some(revents) = pollfd[0].revents() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP) {
|
||||||
|
*file = None;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !revents.contains(PollFlags::POLLIN) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf = [0u8; 1];
|
||||||
|
match file_ref.read(&mut buf) {
|
||||||
|
Ok(1) => {
|
||||||
|
let next = LedState::from_byte(buf[0]);
|
||||||
|
let mut guard = led_state.write();
|
||||||
|
if *guard == next {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
*guard = next;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => false,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => false,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("OTG keyboard LED listener read failed: {}", err);
|
||||||
|
*file = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("OTG keyboard LED listener poll failed: {}", err);
|
||||||
|
*file = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_runtime_worker(&self) {
|
||||||
|
let mut worker = self.runtime_worker.lock();
|
||||||
|
if worker.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.runtime_worker_stop.store(false, Ordering::Relaxed);
|
||||||
|
let stop = self.runtime_worker_stop.clone();
|
||||||
|
let keyboard_leds_enabled = self.keyboard_leds_enabled;
|
||||||
|
let keyboard_path = self.keyboard_path.clone();
|
||||||
|
let led_state = self.led_state.clone();
|
||||||
|
let udc_name = self.udc_name.clone();
|
||||||
|
let runtime_notify_tx = self.runtime_notify_tx.clone();
|
||||||
|
|
||||||
|
let handle = thread::Builder::new()
|
||||||
|
.name("otg-runtime-monitor".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
let mut last_udc_configured = Some(Self::read_udc_configured(&udc_name));
|
||||||
|
let mut keyboard_led_file: Option<File> = None;
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
let current_udc_configured = Self::read_udc_configured(&udc_name);
|
||||||
|
if last_udc_configured != Some(current_udc_configured) {
|
||||||
|
last_udc_configured = Some(current_udc_configured);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyboard_leds_enabled {
|
||||||
|
if let Some(path) = keyboard_path.as_ref() {
|
||||||
|
changed |= Self::poll_keyboard_led_once(
|
||||||
|
&mut keyboard_led_file,
|
||||||
|
path,
|
||||||
|
&led_state,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
let _ = runtime_notify_tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match handle {
|
||||||
|
Ok(handle) => {
|
||||||
|
*worker = Some(handle);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to spawn OTG runtime monitor: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_runtime_worker(&self) {
|
||||||
|
self.runtime_worker_stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(handle) = self.runtime_worker.lock().take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl HidBackend for OtgBackend {
|
impl HidBackend for OtgBackend {
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"OTG USB Gadget"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn init(&self) -> Result<()> {
|
async fn init(&self) -> Result<()> {
|
||||||
info!("Initializing OTG HID backend");
|
info!("Initializing OTG HID backend");
|
||||||
|
|
||||||
// Auto-detect UDC name for state checking
|
// Auto-detect UDC name for state checking only if OtgService did not provide one
|
||||||
|
if self.udc_name.read().is_none() {
|
||||||
if let Some(udc) = Self::find_udc() {
|
if let Some(udc) = Self::find_udc() {
|
||||||
info!("Auto-detected UDC: {}", udc);
|
info!("Auto-detected UDC: {}", udc);
|
||||||
self.set_udc_name(&udc);
|
self.set_udc_name(&udc);
|
||||||
}
|
}
|
||||||
|
} else if let Some(udc) = self.udc_name.read().clone() {
|
||||||
|
info!("Using configured UDC: {}", udc);
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for devices to appear (they should already exist from OtgService)
|
// Wait for devices to appear (they should already exist from OtgService)
|
||||||
let mut device_paths = Vec::new();
|
let mut device_paths = Vec::new();
|
||||||
@@ -812,24 +1055,22 @@ impl HidBackend for OtgBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark as online if all devices opened successfully
|
// Mark as online if all devices opened successfully
|
||||||
self.online.store(true, Ordering::Relaxed);
|
self.initialized.store(true, Ordering::Relaxed);
|
||||||
|
self.notify_runtime_changed();
|
||||||
|
self.start_runtime_worker();
|
||||||
|
self.mark_online();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
|
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
|
||||||
// Convert JS keycode to USB HID if needed (skip if already USB HID)
|
let usb_key = event.key.to_hid_usage();
|
||||||
let usb_key = if event.is_usb_hid {
|
|
||||||
event.key
|
|
||||||
} else {
|
|
||||||
keymap::js_to_usb(event.key).unwrap_or(event.key)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle modifier keys separately
|
// Handle modifier keys separately
|
||||||
if keymap::is_modifier_key(usb_key) {
|
if event.key.is_modifier() {
|
||||||
let mut state = self.keyboard_state.lock();
|
let mut state = self.keyboard_state.lock();
|
||||||
|
|
||||||
if let Some(bit) = keymap::modifier_bit(usb_key) {
|
if let Some(bit) = event.key.modifier_bit() {
|
||||||
match event.event_type {
|
match event.event_type {
|
||||||
KeyEventType::Down => state.modifiers |= bit,
|
KeyEventType::Down => state.modifiers |= bit,
|
||||||
KeyEventType::Up => state.modifiers &= !bit,
|
KeyEventType::Up => state.modifiers &= !bit,
|
||||||
@@ -925,6 +1166,8 @@ impl HidBackend for OtgBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn shutdown(&self) -> Result<()> {
|
async fn shutdown(&self) -> Result<()> {
|
||||||
|
self.stop_runtime_worker();
|
||||||
|
|
||||||
// Reset before closing
|
// Reset before closing
|
||||||
self.reset().await?;
|
self.reset().await?;
|
||||||
|
|
||||||
@@ -935,49 +1178,30 @@ impl HidBackend for OtgBackend {
|
|||||||
*self.consumer_dev.lock() = None;
|
*self.consumer_dev.lock() = None;
|
||||||
|
|
||||||
// Gadget cleanup is handled by OtgService, not here
|
// Gadget cleanup is handled by OtgService, not here
|
||||||
|
self.initialized.store(false, Ordering::Relaxed);
|
||||||
|
self.online.store(false, Ordering::Relaxed);
|
||||||
|
self.clear_error();
|
||||||
|
self.notify_runtime_changed();
|
||||||
|
|
||||||
info!("OTG backend shutdown");
|
info!("OTG backend shutdown");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn health_check(&self) -> Result<()> {
|
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
|
||||||
if !self.check_devices_exist() {
|
self.build_runtime_snapshot()
|
||||||
let missing = self.get_missing_devices();
|
|
||||||
self.online.store(false, Ordering::Relaxed);
|
|
||||||
return Err(AppError::HidError {
|
|
||||||
backend: "otg".to_string(),
|
|
||||||
reason: format!("HID device node missing: {}", missing.join(", ")),
|
|
||||||
error_code: "enoent".to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.is_udc_configured() {
|
fn subscribe_runtime(&self) -> watch::Receiver<()> {
|
||||||
self.online.store(false, Ordering::Relaxed);
|
self.runtime_notify_tx.subscribe()
|
||||||
return Err(AppError::HidError {
|
|
||||||
backend: "otg".to_string(),
|
|
||||||
reason: "UDC is not in configured state".to_string(),
|
|
||||||
error_code: "udc_not_configured".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
self.online.store(true, Ordering::Relaxed);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_absolute_mouse(&self) -> bool {
|
|
||||||
self.mouse_abs_path.as_ref().is_some_and(|p| p.exists())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
||||||
self.send_consumer_report(event.usage)
|
self.send_consumer_report(event.usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn screen_resolution(&self) -> Option<(u32, u32)> {
|
|
||||||
*self.screen_resolution.read()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_screen_resolution(&mut self, width: u32, height: u32) {
|
fn set_screen_resolution(&mut self, width: u32, height: u32) {
|
||||||
*self.screen_resolution.write() = Some((width, height));
|
*self.screen_resolution.write() = Some((width, height));
|
||||||
|
self.notify_runtime_changed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,6 +1218,10 @@ pub fn is_otg_available() -> bool {
|
|||||||
/// Implement Drop for OtgBackend to close device files
|
/// Implement Drop for OtgBackend to close device files
|
||||||
impl Drop for OtgBackend {
|
impl Drop for OtgBackend {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
self.runtime_worker_stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(handle) = self.runtime_worker.get_mut().take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
// Close device files
|
// Close device files
|
||||||
// Note: Gadget cleanup is handled by OtgService, not here
|
// Note: Gadget cleanup is handled by OtgService, not here
|
||||||
*self.keyboard_dev.lock() = None;
|
*self.keyboard_dev.lock() = None;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::keyboard::CanonicalKey;
|
||||||
|
|
||||||
/// Keyboard event type
|
/// Keyboard event type
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -105,34 +107,29 @@ pub struct KeyboardEvent {
|
|||||||
/// Event type (down/up)
|
/// Event type (down/up)
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub event_type: KeyEventType,
|
pub event_type: KeyEventType,
|
||||||
/// Key code (USB HID usage code or JavaScript key code)
|
/// Canonical keyboard key identifier shared across frontend and backend
|
||||||
pub key: u8,
|
pub key: CanonicalKey,
|
||||||
/// Modifier keys state
|
/// Modifier keys state
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub modifiers: KeyboardModifiers,
|
pub modifiers: KeyboardModifiers,
|
||||||
/// If true, key is already USB HID code (skip js_to_usb conversion)
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_usb_hid: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeyboardEvent {
|
impl KeyboardEvent {
|
||||||
/// Create a key down event (JS keycode, needs conversion)
|
/// Create a key down event
|
||||||
pub fn key_down(key: u8, modifiers: KeyboardModifiers) -> Self {
|
pub fn key_down(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
|
||||||
Self {
|
Self {
|
||||||
event_type: KeyEventType::Down,
|
event_type: KeyEventType::Down,
|
||||||
key,
|
key,
|
||||||
modifiers,
|
modifiers,
|
||||||
is_usb_hid: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a key up event (JS keycode, needs conversion)
|
/// Create a key up event
|
||||||
pub fn key_up(key: u8, modifiers: KeyboardModifiers) -> Self {
|
pub fn key_up(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
|
||||||
Self {
|
Self {
|
||||||
event_type: KeyEventType::Up,
|
event_type: KeyEventType::Up,
|
||||||
key,
|
key,
|
||||||
modifiers,
|
modifiers,
|
||||||
is_usb_hid: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
145
src/main.rs
145
src/main.rs
@@ -7,7 +7,7 @@ use axum_server::tls_rustls::RustlsConfig;
|
|||||||
use clap::{Parser, ValueEnum};
|
use clap::{Parser, ValueEnum};
|
||||||
use futures::{stream::FuturesUnordered, StreamExt};
|
use futures::{stream::FuturesUnordered, StreamExt};
|
||||||
use rustls::crypto::{ring, CryptoProvider};
|
use rustls::crypto::{ring, CryptoProvider};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::{broadcast, mpsc};
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use one_kvm::atx::AtxController;
|
use one_kvm::atx::AtxController;
|
||||||
@@ -18,7 +18,7 @@ use one_kvm::events::EventBus;
|
|||||||
use one_kvm::extensions::ExtensionManager;
|
use one_kvm::extensions::ExtensionManager;
|
||||||
use one_kvm::hid::{HidBackendType, HidController};
|
use one_kvm::hid::{HidBackendType, HidController};
|
||||||
use one_kvm::msd::MsdController;
|
use one_kvm::msd::MsdController;
|
||||||
use one_kvm::otg::{configfs, OtgService};
|
use one_kvm::otg::OtgService;
|
||||||
use one_kvm::rtsp::RtspService;
|
use one_kvm::rtsp::RtspService;
|
||||||
use one_kvm::rustdesk::RustDeskService;
|
use one_kvm::rustdesk::RustDeskService;
|
||||||
use one_kvm::state::AppState;
|
use one_kvm::state::AppState;
|
||||||
@@ -319,32 +319,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let otg_service = Arc::new(OtgService::new());
|
let otg_service = Arc::new(OtgService::new());
|
||||||
tracing::info!("OTG Service created");
|
tracing::info!("OTG Service created");
|
||||||
|
|
||||||
// Pre-enable OTG functions to avoid gadget recreation (prevents kernel crashes)
|
// Reconcile OTG once from the persisted config so controllers only consume its result.
|
||||||
let will_use_otg_hid = matches!(config.hid.backend, config::HidBackend::Otg);
|
if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await {
|
||||||
let will_use_msd = config.msd.enabled;
|
tracing::warn!("Failed to apply OTG config: {}", e);
|
||||||
|
|
||||||
if will_use_otg_hid {
|
|
||||||
let mut hid_functions = config.hid.effective_otg_functions();
|
|
||||||
if let Some(udc) = configfs::resolve_udc_name(config.hid.otg_udc.as_deref()) {
|
|
||||||
if configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer {
|
|
||||||
tracing::warn!(
|
|
||||||
"UDC {} has low endpoint resources, disabling consumer control",
|
|
||||||
udc
|
|
||||||
);
|
|
||||||
hid_functions.consumer = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(e) = otg_service.update_hid_functions(hid_functions).await {
|
|
||||||
tracing::warn!("Failed to apply HID functions: {}", e);
|
|
||||||
}
|
|
||||||
if let Err(e) = otg_service.enable_hid().await {
|
|
||||||
tracing::warn!("Failed to pre-enable HID: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if will_use_msd {
|
|
||||||
if let Err(e) = otg_service.enable_msd().await {
|
|
||||||
tracing::warn!("Failed to pre-enable MSD: {}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create HID controller based on config
|
// Create HID controller based on config
|
||||||
@@ -576,6 +553,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
data_dir.clone(),
|
data_dir.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
extensions.set_event_bus(events.clone()).await;
|
||||||
|
|
||||||
// Start RustDesk service if enabled
|
// Start RustDesk service if enabled
|
||||||
if let Some(ref service) = rustdesk {
|
if let Some(ref service) = rustdesk {
|
||||||
if let Err(e) = service.start().await {
|
if let Err(e) = service.start().await {
|
||||||
@@ -646,6 +625,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!("Extension health check task started");
|
tracing::info!("Extension health check task started");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.publish_device_info().await;
|
||||||
|
|
||||||
// Start device info broadcast task
|
// Start device info broadcast task
|
||||||
// This monitors state change events and broadcasts DeviceInfo to all clients
|
// This monitors state change events and broadcasts DeviceInfo to all clients
|
||||||
spawn_device_info_broadcaster(state.clone(), events);
|
spawn_device_info_broadcaster(state.clone(), events);
|
||||||
@@ -854,12 +835,86 @@ fn generate_self_signed_cert() -> anyhow::Result<rcgen::CertifiedKey<rcgen::KeyP
|
|||||||
/// Spawn a background task that monitors state change events
|
/// Spawn a background task that monitors state change events
|
||||||
/// and broadcasts DeviceInfo to all WebSocket clients with debouncing
|
/// and broadcasts DeviceInfo to all WebSocket clients with debouncing
|
||||||
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
||||||
use one_kvm::events::SystemEvent;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
let mut rx = events.subscribe();
|
enum DeviceInfoTrigger {
|
||||||
|
Event,
|
||||||
|
Lagged { topic: &'static str, count: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_INFO_TOPICS: &[&str] = &[
|
||||||
|
"stream.state_changed",
|
||||||
|
"stream.config_applied",
|
||||||
|
"stream.mode_ready",
|
||||||
|
];
|
||||||
const DEBOUNCE_MS: u64 = 100;
|
const DEBOUNCE_MS: u64 = 100;
|
||||||
|
|
||||||
|
let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
for topic in DEVICE_INFO_TOPICS {
|
||||||
|
let Some(mut rx) = events.subscribe_topic(topic) else {
|
||||||
|
tracing::warn!(
|
||||||
|
"DeviceInfo broadcaster missing topic subscription: {}",
|
||||||
|
topic
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let trigger_tx = trigger_tx.clone();
|
||||||
|
let topic_name = *topic;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(_) => {
|
||||||
|
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
if trigger_tx
|
||||||
|
.send(DeviceInfoTrigger::Lagged {
|
||||||
|
topic: topic_name,
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut dirty_rx = events.subscribe_device_info_dirty();
|
||||||
|
let trigger_tx = trigger_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match dirty_rx.recv().await {
|
||||||
|
Ok(()) => {
|
||||||
|
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
if trigger_tx
|
||||||
|
.send(DeviceInfoTrigger::Lagged {
|
||||||
|
topic: "device_info_dirty",
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
|
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
|
||||||
let mut pending_broadcast = false;
|
let mut pending_broadcast = false;
|
||||||
@@ -869,32 +924,24 @@ fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
|||||||
let recv_result = if pending_broadcast {
|
let recv_result = if pending_broadcast {
|
||||||
let remaining =
|
let remaining =
|
||||||
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
|
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
|
||||||
tokio::time::timeout(Duration::from_millis(remaining), rx.recv()).await
|
tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await
|
||||||
} else {
|
} else {
|
||||||
Ok(rx.recv().await)
|
Ok(trigger_rx.recv().await)
|
||||||
};
|
};
|
||||||
|
|
||||||
match recv_result {
|
match recv_result {
|
||||||
Ok(Ok(event)) => {
|
Ok(Some(DeviceInfoTrigger::Event)) => {
|
||||||
let should_broadcast = matches!(
|
pending_broadcast = true;
|
||||||
event,
|
}
|
||||||
SystemEvent::StreamStateChanged { .. }
|
Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
|
||||||
| SystemEvent::StreamConfigApplied { .. }
|
tracing::warn!(
|
||||||
| SystemEvent::StreamModeReady { .. }
|
"DeviceInfo broadcaster lagged by {} events on topic {}",
|
||||||
| SystemEvent::HidStateChanged { .. }
|
count,
|
||||||
| SystemEvent::MsdStateChanged { .. }
|
topic
|
||||||
| SystemEvent::AtxStateChanged { .. }
|
|
||||||
| SystemEvent::AudioStateChanged { .. }
|
|
||||||
);
|
);
|
||||||
if should_broadcast {
|
|
||||||
pending_broadcast = true;
|
pending_broadcast = true;
|
||||||
}
|
}
|
||||||
}
|
Ok(None) => {
|
||||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(n))) => {
|
|
||||||
tracing::warn!("DeviceInfo broadcaster lagged by {} events", n);
|
|
||||||
pending_broadcast = true;
|
|
||||||
}
|
|
||||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
|
|
||||||
tracing::info!("Event bus closed, stopping DeviceInfo broadcaster");
|
tracing::info!("Event bus closed, stopping DeviceInfo broadcaster");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ use super::types::{DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MsdMo
|
|||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
||||||
|
|
||||||
/// USB Gadget path (system constant)
|
|
||||||
const GADGET_PATH: &str = "/sys/kernel/config/usb_gadget/one-kvm";
|
|
||||||
|
|
||||||
/// MSD Controller
|
/// MSD Controller
|
||||||
pub struct MsdController {
|
pub struct MsdController {
|
||||||
/// OTG Service reference
|
/// OTG Service reference
|
||||||
@@ -83,9 +80,11 @@ impl MsdController {
|
|||||||
warn!("Failed to create ventoy directory: {}", e);
|
warn!("Failed to create ventoy directory: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Request MSD function from OtgService
|
// 2. Get active MSD function from OtgService
|
||||||
info!("Requesting MSD function from OtgService");
|
info!("Fetching MSD function from OtgService");
|
||||||
let msd_func = self.otg_service.enable_msd().await?;
|
let msd_func = self.otg_service.msd_function().await.ok_or_else(|| {
|
||||||
|
AppError::Internal("MSD function is not active in OtgService".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
// 3. Store function handle
|
// 3. Store function handle
|
||||||
*self.msd_function.write().await = Some(msd_func);
|
*self.msd_function.write().await = Some(msd_func);
|
||||||
@@ -115,15 +114,6 @@ impl MsdController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current state as SystemEvent
|
|
||||||
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
|
|
||||||
let state = self.state.read().await;
|
|
||||||
crate::events::SystemEvent::MsdStateChanged {
|
|
||||||
mode: state.mode.clone(),
|
|
||||||
connected: state.connected,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current MSD state
|
/// Get current MSD state
|
||||||
pub async fn state(&self) -> MsdState {
|
pub async fn state(&self) -> MsdState {
|
||||||
self.state.read().await.clone()
|
self.state.read().await.clone()
|
||||||
@@ -131,9 +121,7 @@ impl MsdController {
|
|||||||
|
|
||||||
/// Set event bus for broadcasting state changes
|
/// Set event bus for broadcasting state changes
|
||||||
pub async fn set_event_bus(&self, events: std::sync::Arc<crate::events::EventBus>) {
|
pub async fn set_event_bus(&self, events: std::sync::Arc<crate::events::EventBus>) {
|
||||||
*self.events.write().await = Some(events.clone());
|
*self.events.write().await = Some(events);
|
||||||
// Also set event bus on the monitor for health notifications
|
|
||||||
self.monitor.set_event_bus(events).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish an event to the event bus
|
/// Publish an event to the event bus
|
||||||
@@ -143,6 +131,12 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mark_device_info_dirty(&self) {
|
||||||
|
if let Some(ref bus) = *self.events.read().await {
|
||||||
|
bus.mark_device_info_dirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if MSD is available
|
/// Check if MSD is available
|
||||||
pub async fn is_available(&self) -> bool {
|
pub async fn is_available(&self) -> bool {
|
||||||
self.state.read().await.available
|
self.state.read().await.available
|
||||||
@@ -195,7 +189,7 @@ impl MsdController {
|
|||||||
MsdLunConfig::disk(image.path.clone(), read_only)
|
MsdLunConfig::disk(image.path.clone(), read_only)
|
||||||
};
|
};
|
||||||
|
|
||||||
let gadget_path = PathBuf::from(GADGET_PATH);
|
let gadget_path = self.active_gadget_path().await?;
|
||||||
if let Some(ref msd) = *self.msd_function.read().await {
|
if let Some(ref msd) = *self.msd_function.read().await {
|
||||||
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
||||||
let error_msg = format!("Failed to configure LUN: {}", e);
|
let error_msg = format!("Failed to configure LUN: {}", e);
|
||||||
@@ -230,20 +224,7 @@ impl MsdController {
|
|||||||
self.monitor.report_recovered().await;
|
self.monitor.report_recovered().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish events
|
self.mark_device_info_dirty().await;
|
||||||
self.publish_event(crate::events::SystemEvent::MsdImageMounted {
|
|
||||||
image_id: image.id.clone(),
|
|
||||||
image_name: image.name.clone(),
|
|
||||||
size: image.size,
|
|
||||||
cdrom,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
|
||||||
mode: MsdMode::Image,
|
|
||||||
connected: true,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -282,7 +263,7 @@ impl MsdController {
|
|||||||
// Configure LUN as read-write disk
|
// Configure LUN as read-write disk
|
||||||
let config = MsdLunConfig::disk(self.drive_path.clone(), false);
|
let config = MsdLunConfig::disk(self.drive_path.clone(), false);
|
||||||
|
|
||||||
let gadget_path = PathBuf::from(GADGET_PATH);
|
let gadget_path = self.active_gadget_path().await?;
|
||||||
if let Some(ref msd) = *self.msd_function.read().await {
|
if let Some(ref msd) = *self.msd_function.read().await {
|
||||||
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
||||||
let error_msg = format!("Failed to configure LUN: {}", e);
|
let error_msg = format!("Failed to configure LUN: {}", e);
|
||||||
@@ -314,12 +295,7 @@ impl MsdController {
|
|||||||
self.monitor.report_recovered().await;
|
self.monitor.report_recovered().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish event
|
self.mark_device_info_dirty().await;
|
||||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
|
||||||
mode: MsdMode::Drive,
|
|
||||||
connected: true,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -336,7 +312,7 @@ impl MsdController {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let gadget_path = PathBuf::from(GADGET_PATH);
|
let gadget_path = self.active_gadget_path().await?;
|
||||||
if let Some(ref msd) = *self.msd_function.read().await {
|
if let Some(ref msd) = *self.msd_function.read().await {
|
||||||
msd.disconnect_lun_async(&gadget_path, 0).await?;
|
msd.disconnect_lun_async(&gadget_path, 0).await?;
|
||||||
}
|
}
|
||||||
@@ -351,15 +327,7 @@ impl MsdController {
|
|||||||
drop(state);
|
drop(state);
|
||||||
drop(_op_guard);
|
drop(_op_guard);
|
||||||
|
|
||||||
// Publish events
|
self.mark_device_info_dirty().await;
|
||||||
self.publish_event(crate::events::SystemEvent::MsdImageUnmounted)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
|
||||||
mode: MsdMode::None,
|
|
||||||
connected: false,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -543,6 +511,13 @@ impl MsdController {
|
|||||||
downloads.keys().cloned().collect()
|
downloads.keys().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn active_gadget_path(&self) -> Result<PathBuf> {
|
||||||
|
self.otg_service
|
||||||
|
.gadget_path()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| AppError::Internal("OTG gadget path is not available".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Shutdown the controller
|
/// Shutdown the controller
|
||||||
pub async fn shutdown(&self) -> Result<()> {
|
pub async fn shutdown(&self) -> Result<()> {
|
||||||
info!("Shutting down MSD controller");
|
info!("Shutting down MSD controller");
|
||||||
@@ -552,11 +527,7 @@ impl MsdController {
|
|||||||
warn!("Error disconnecting during shutdown: {}", e);
|
warn!("Error disconnecting during shutdown: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Notify OtgService to disable MSD
|
// 2. Clear local state
|
||||||
info!("Disabling MSD function in OtgService");
|
|
||||||
self.otg_service.disable_msd().await?;
|
|
||||||
|
|
||||||
// 3. Clear local state
|
|
||||||
*self.msd_function.write().await = None;
|
*self.msd_function.write().await = None;
|
||||||
|
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
|
|||||||
@@ -3,15 +3,13 @@
|
|||||||
//! This module provides health monitoring for MSD operations, including:
|
//! This module provides health monitoring for MSD operations, including:
|
||||||
//! - ConfigFS operation error tracking
|
//! - ConfigFS operation error tracking
|
||||||
//! - Image mount/unmount error tracking
|
//! - Image mount/unmount error tracking
|
||||||
//! - Error notification
|
//! - Error state tracking
|
||||||
//! - Log throttling to prevent log flooding
|
//! - Log throttling to prevent log flooding
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::events::{EventBus, SystemEvent};
|
|
||||||
use crate::utils::LogThrottler;
|
use crate::utils::LogThrottler;
|
||||||
|
|
||||||
/// MSD health status
|
/// MSD health status
|
||||||
@@ -46,21 +44,12 @@ impl Default for MsdMonitorConfig {
|
|||||||
|
|
||||||
/// MSD health monitor
|
/// MSD health monitor
|
||||||
///
|
///
|
||||||
/// Monitors MSD operation health and manages error notifications.
|
/// Monitors MSD operation health and manages error state.
|
||||||
/// Publishes WebSocket events when operation status changes.
|
|
||||||
pub struct MsdHealthMonitor {
|
pub struct MsdHealthMonitor {
|
||||||
/// Current health status
|
/// Current health status
|
||||||
status: RwLock<MsdHealthStatus>,
|
status: RwLock<MsdHealthStatus>,
|
||||||
/// Event bus for notifications
|
|
||||||
events: RwLock<Option<Arc<EventBus>>>,
|
|
||||||
/// Log throttler to prevent log flooding
|
/// Log throttler to prevent log flooding
|
||||||
throttler: LogThrottler,
|
throttler: LogThrottler,
|
||||||
/// Configuration
|
|
||||||
#[allow(dead_code)]
|
|
||||||
config: MsdMonitorConfig,
|
|
||||||
/// Whether monitoring is active (reserved for future use)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
running: AtomicBool,
|
|
||||||
/// Error count (for tracking)
|
/// Error count (for tracking)
|
||||||
error_count: AtomicU32,
|
error_count: AtomicU32,
|
||||||
/// Last error code (for change detection)
|
/// Last error code (for change detection)
|
||||||
@@ -73,10 +62,7 @@ impl MsdHealthMonitor {
|
|||||||
let throttle_secs = config.log_throttle_secs;
|
let throttle_secs = config.log_throttle_secs;
|
||||||
Self {
|
Self {
|
||||||
status: RwLock::new(MsdHealthStatus::Healthy),
|
status: RwLock::new(MsdHealthStatus::Healthy),
|
||||||
events: RwLock::new(None),
|
|
||||||
throttler: LogThrottler::with_secs(throttle_secs),
|
throttler: LogThrottler::with_secs(throttle_secs),
|
||||||
config,
|
|
||||||
running: AtomicBool::new(false),
|
|
||||||
error_count: AtomicU32::new(0),
|
error_count: AtomicU32::new(0),
|
||||||
last_error_code: RwLock::new(None),
|
last_error_code: RwLock::new(None),
|
||||||
}
|
}
|
||||||
@@ -87,17 +73,12 @@ impl MsdHealthMonitor {
|
|||||||
Self::new(MsdMonitorConfig::default())
|
Self::new(MsdMonitorConfig::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the event bus for broadcasting state changes
|
|
||||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
|
||||||
*self.events.write().await = Some(events);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Report an error from MSD operations
|
/// Report an error from MSD operations
|
||||||
///
|
///
|
||||||
/// This method is called when an MSD operation fails. It:
|
/// This method is called when an MSD operation fails. It:
|
||||||
/// 1. Updates the health status
|
/// 1. Updates the health status
|
||||||
/// 2. Logs the error (with throttling)
|
/// 2. Logs the error (with throttling)
|
||||||
/// 3. Publishes a WebSocket event if the error is new or changed
|
/// 3. Updates in-memory error state
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
@@ -129,22 +110,12 @@ impl MsdHealthMonitor {
|
|||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
error_code: error_code.to_string(),
|
error_code: error_code.to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Publish event (only if error changed or first occurrence)
|
|
||||||
if error_changed || count == 1 {
|
|
||||||
if let Some(ref events) = *self.events.read().await {
|
|
||||||
events.publish(SystemEvent::MsdError {
|
|
||||||
reason: reason.to_string(),
|
|
||||||
error_code: error_code.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Report that the MSD has recovered from error
|
/// Report that the MSD has recovered from error
|
||||||
///
|
///
|
||||||
/// This method is called when an MSD operation succeeds after errors.
|
/// This method is called when an MSD operation succeeds after errors.
|
||||||
/// It resets the error state and publishes a recovery event.
|
/// It resets the error state.
|
||||||
pub async fn report_recovered(&self) {
|
pub async fn report_recovered(&self) {
|
||||||
let prev_status = self.status.read().await.clone();
|
let prev_status = self.status.read().await.clone();
|
||||||
|
|
||||||
@@ -158,11 +129,6 @@ impl MsdHealthMonitor {
|
|||||||
self.throttler.clear_all();
|
self.throttler.clear_all();
|
||||||
*self.last_error_code.write().await = None;
|
*self.last_error_code.write().await = None;
|
||||||
*self.status.write().await = MsdHealthStatus::Healthy;
|
*self.status.write().await = MsdHealthStatus::Healthy;
|
||||||
|
|
||||||
// Publish recovery event
|
|
||||||
if let Some(ref events) = *self.events.read().await {
|
|
||||||
events.publish(SystemEvent::MsdRecovered);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
@@ -29,6 +30,42 @@ pub fn is_configfs_available() -> bool {
|
|||||||
Path::new(CONFIGFS_PATH).exists()
|
Path::new(CONFIGFS_PATH).exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure libcomposite support is available for USB gadget operations.
|
||||||
|
///
|
||||||
|
/// This is a best-effort runtime fallback for systems where `libcomposite`
|
||||||
|
/// is built as a module and not loaded yet. It does not try to mount configfs;
|
||||||
|
/// mounting remains an explicit system responsibility.
|
||||||
|
pub fn ensure_libcomposite_loaded() -> Result<()> {
|
||||||
|
if is_configfs_available() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !Path::new("/sys/module/libcomposite").exists() {
|
||||||
|
let status = Command::new("modprobe")
|
||||||
|
.arg("libcomposite")
|
||||||
|
.status()
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to run modprobe libcomposite: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"modprobe libcomposite failed with status {}",
|
||||||
|
status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_configfs_available() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::Internal(
|
||||||
|
"libcomposite is not available after modprobe; check configfs mount and kernel support"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Find available UDC (USB Device Controller)
|
/// Find available UDC (USB Device Controller)
|
||||||
pub fn find_udc() -> Option<String> {
|
pub fn find_udc() -> Option<String> {
|
||||||
let udc_path = Path::new("/sys/class/udc");
|
let udc_path = Path::new("/sys/class/udc");
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ use super::configfs::{
|
|||||||
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
|
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
|
||||||
};
|
};
|
||||||
use super::function::{FunctionMeta, GadgetFunction};
|
use super::function::{FunctionMeta, GadgetFunction};
|
||||||
use super::report_desc::{CONSUMER_CONTROL, KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
use super::report_desc::{
|
||||||
|
CONSUMER_CONTROL, KEYBOARD, KEYBOARD_WITH_LED, MOUSE_ABSOLUTE, MOUSE_RELATIVE,
|
||||||
|
};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
/// HID function type
|
/// HID function type
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum HidFunctionType {
|
pub enum HidFunctionType {
|
||||||
/// Keyboard (no LED feedback)
|
/// Keyboard
|
||||||
/// Uses 1 endpoint: IN
|
|
||||||
Keyboard,
|
Keyboard,
|
||||||
/// Relative mouse (traditional mouse movement)
|
/// Relative mouse (traditional mouse movement)
|
||||||
/// Uses 1 endpoint: IN
|
/// Uses 1 endpoint: IN
|
||||||
@@ -28,7 +29,7 @@ pub enum HidFunctionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HidFunctionType {
|
impl HidFunctionType {
|
||||||
/// Get endpoints required for this function type
|
/// Get the base endpoint cost for this function type.
|
||||||
pub fn endpoints(&self) -> u8 {
|
pub fn endpoints(&self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
HidFunctionType::Keyboard => 1,
|
HidFunctionType::Keyboard => 1,
|
||||||
@@ -59,7 +60,7 @@ impl HidFunctionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get report length in bytes
|
/// Get report length in bytes
|
||||||
pub fn report_length(&self) -> u8 {
|
pub fn report_length(&self, _keyboard_leds: bool) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
HidFunctionType::Keyboard => 8,
|
HidFunctionType::Keyboard => 8,
|
||||||
HidFunctionType::MouseRelative => 4,
|
HidFunctionType::MouseRelative => 4,
|
||||||
@@ -69,9 +70,15 @@ impl HidFunctionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get report descriptor
|
/// Get report descriptor
|
||||||
pub fn report_desc(&self) -> &'static [u8] {
|
pub fn report_desc(&self, keyboard_leds: bool) -> &'static [u8] {
|
||||||
match self {
|
match self {
|
||||||
HidFunctionType::Keyboard => KEYBOARD,
|
HidFunctionType::Keyboard => {
|
||||||
|
if keyboard_leds {
|
||||||
|
KEYBOARD_WITH_LED
|
||||||
|
} else {
|
||||||
|
KEYBOARD
|
||||||
|
}
|
||||||
|
}
|
||||||
HidFunctionType::MouseRelative => MOUSE_RELATIVE,
|
HidFunctionType::MouseRelative => MOUSE_RELATIVE,
|
||||||
HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE,
|
HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE,
|
||||||
HidFunctionType::ConsumerControl => CONSUMER_CONTROL,
|
HidFunctionType::ConsumerControl => CONSUMER_CONTROL,
|
||||||
@@ -98,15 +105,18 @@ pub struct HidFunction {
|
|||||||
func_type: HidFunctionType,
|
func_type: HidFunctionType,
|
||||||
/// Cached function name (avoids repeated allocation)
|
/// Cached function name (avoids repeated allocation)
|
||||||
name: String,
|
name: String,
|
||||||
|
/// Whether keyboard LED/status feedback is enabled.
|
||||||
|
keyboard_leds: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HidFunction {
|
impl HidFunction {
|
||||||
/// Create a keyboard function
|
/// Create a keyboard function
|
||||||
pub fn keyboard(instance: u8) -> Self {
|
pub fn keyboard(instance: u8, keyboard_leds: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
instance,
|
instance,
|
||||||
func_type: HidFunctionType::Keyboard,
|
func_type: HidFunctionType::Keyboard,
|
||||||
name: format!("hid.usb{}", instance),
|
name: format!("hid.usb{}", instance),
|
||||||
|
keyboard_leds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +126,7 @@ impl HidFunction {
|
|||||||
instance,
|
instance,
|
||||||
func_type: HidFunctionType::MouseRelative,
|
func_type: HidFunctionType::MouseRelative,
|
||||||
name: format!("hid.usb{}", instance),
|
name: format!("hid.usb{}", instance),
|
||||||
|
keyboard_leds: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +136,7 @@ impl HidFunction {
|
|||||||
instance,
|
instance,
|
||||||
func_type: HidFunctionType::MouseAbsolute,
|
func_type: HidFunctionType::MouseAbsolute,
|
||||||
name: format!("hid.usb{}", instance),
|
name: format!("hid.usb{}", instance),
|
||||||
|
keyboard_leds: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +146,7 @@ impl HidFunction {
|
|||||||
instance,
|
instance,
|
||||||
func_type: HidFunctionType::ConsumerControl,
|
func_type: HidFunctionType::ConsumerControl,
|
||||||
name: format!("hid.usb{}", instance),
|
name: format!("hid.usb{}", instance),
|
||||||
|
keyboard_leds: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,11 +194,14 @@ impl GadgetFunction for HidFunction {
|
|||||||
)?;
|
)?;
|
||||||
write_file(
|
write_file(
|
||||||
&func_path.join("report_length"),
|
&func_path.join("report_length"),
|
||||||
&self.func_type.report_length().to_string(),
|
&self.func_type.report_length(self.keyboard_leds).to_string(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Write report descriptor
|
// Write report descriptor
|
||||||
write_bytes(&func_path.join("report_desc"), self.func_type.report_desc())?;
|
write_bytes(
|
||||||
|
&func_path.join("report_desc"),
|
||||||
|
self.func_type.report_desc(self.keyboard_leds),
|
||||||
|
)?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Created HID function: {} at {}",
|
"Created HID function: {} at {}",
|
||||||
@@ -232,14 +248,15 @@ mod tests {
|
|||||||
assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1);
|
assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1);
|
||||||
assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1);
|
assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1);
|
||||||
|
|
||||||
assert_eq!(HidFunctionType::Keyboard.report_length(), 8);
|
assert_eq!(HidFunctionType::Keyboard.report_length(false), 8);
|
||||||
assert_eq!(HidFunctionType::MouseRelative.report_length(), 4);
|
assert_eq!(HidFunctionType::Keyboard.report_length(true), 8);
|
||||||
assert_eq!(HidFunctionType::MouseAbsolute.report_length(), 6);
|
assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4);
|
||||||
|
assert_eq!(HidFunctionType::MouseAbsolute.report_length(false), 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hid_function_names() {
|
fn test_hid_function_names() {
|
||||||
let kb = HidFunction::keyboard(0);
|
let kb = HidFunction::keyboard(0, false);
|
||||||
assert_eq!(kb.name(), "hid.usb0");
|
assert_eq!(kb.name(), "hid.usb0");
|
||||||
assert_eq!(kb.device_path(), PathBuf::from("/dev/hidg0"));
|
assert_eq!(kb.device_path(), PathBuf::from("/dev/hidg0"));
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use crate::error::{AppError, Result};
|
|||||||
const REBIND_DELAY_MS: u64 = 300;
|
const REBIND_DELAY_MS: u64 = 300;
|
||||||
|
|
||||||
/// USB Gadget device descriptor configuration
|
/// USB Gadget device descriptor configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct GadgetDescriptor {
|
pub struct GadgetDescriptor {
|
||||||
pub vendor_id: u16,
|
pub vendor_id: u16,
|
||||||
pub product_id: u16,
|
pub product_id: u16,
|
||||||
@@ -131,8 +131,8 @@ impl OtgGadgetManager {
|
|||||||
|
|
||||||
/// Add keyboard function
|
/// Add keyboard function
|
||||||
/// Returns the expected device path (e.g., /dev/hidg0)
|
/// Returns the expected device path (e.g., /dev/hidg0)
|
||||||
pub fn add_keyboard(&mut self) -> Result<PathBuf> {
|
pub fn add_keyboard(&mut self, keyboard_leds: bool) -> Result<PathBuf> {
|
||||||
let func = HidFunction::keyboard(self.hid_instance);
|
let func = HidFunction::keyboard(self.hid_instance, keyboard_leds);
|
||||||
let device_path = func.device_path();
|
let device_path = func.device_path();
|
||||||
self.add_function(Box::new(func))?;
|
self.add_function(Box::new(func))?;
|
||||||
self.hid_instance += 1;
|
self.hid_instance += 1;
|
||||||
@@ -245,12 +245,8 @@ impl OtgGadgetManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bind gadget to UDC
|
/// Bind gadget to a specific UDC
|
||||||
pub fn bind(&mut self) -> Result<()> {
|
pub fn bind(&mut self, udc: &str) -> Result<()> {
|
||||||
let udc = Self::find_udc().ok_or_else(|| {
|
|
||||||
AppError::Internal("No USB Device Controller (UDC) found".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Recreate config symlinks before binding to avoid kernel gadget issues after rebind
|
// Recreate config symlinks before binding to avoid kernel gadget issues after rebind
|
||||||
if let Err(e) = self.recreate_config_links() {
|
if let Err(e) = self.recreate_config_links() {
|
||||||
warn!("Failed to recreate gadget config links before bind: {}", e);
|
warn!("Failed to recreate gadget config links before bind: {}", e);
|
||||||
@@ -258,7 +254,7 @@ impl OtgGadgetManager {
|
|||||||
|
|
||||||
info!("Binding gadget to UDC: {}", udc);
|
info!("Binding gadget to UDC: {}", udc);
|
||||||
write_file(&self.gadget_path.join("UDC"), &udc)?;
|
write_file(&self.gadget_path.join("UDC"), &udc)?;
|
||||||
self.bound_udc = Some(udc);
|
self.bound_udc = Some(udc.to_string());
|
||||||
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
|
std::thread::sleep(std::time::Duration::from_millis(REBIND_DELAY_MS));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -504,7 +500,7 @@ mod tests {
|
|||||||
let mut manager = OtgGadgetManager::with_config("test", 8);
|
let mut manager = OtgGadgetManager::with_config("test", 8);
|
||||||
|
|
||||||
// Keyboard uses 1 endpoint
|
// Keyboard uses 1 endpoint
|
||||||
let _ = manager.add_keyboard();
|
let _ = manager.add_keyboard(false);
|
||||||
assert_eq!(manager.endpoint_allocator.used(), 1);
|
assert_eq!(manager.endpoint_allocator.used(), 1);
|
||||||
|
|
||||||
// Mouse uses 1 endpoint each
|
// Mouse uses 1 endpoint each
|
||||||
|
|||||||
@@ -32,4 +32,4 @@ pub use hid::{HidFunction, HidFunctionType};
|
|||||||
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
|
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
|
||||||
pub use msd::{MsdFunction, MsdLunConfig};
|
pub use msd::{MsdFunction, MsdLunConfig};
|
||||||
pub use report_desc::{KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
pub use report_desc::{KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
||||||
pub use service::{HidDevicePaths, OtgService, OtgServiceState};
|
pub use service::{HidDevicePaths, OtgDesiredState, OtgService, OtgServiceState};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! HID Report Descriptors
|
//! HID Report Descriptors
|
||||||
|
|
||||||
/// Keyboard HID Report Descriptor (no LED output - saves 1 endpoint)
|
/// Keyboard HID Report Descriptor (no LED output)
|
||||||
/// Report format (8 bytes input):
|
/// Report format (8 bytes input):
|
||||||
/// [0] Modifier keys (8 bits)
|
/// [0] Modifier keys (8 bits)
|
||||||
/// [1] Reserved
|
/// [1] Reserved
|
||||||
@@ -34,6 +34,53 @@ pub const KEYBOARD: &[u8] = &[
|
|||||||
0xC0, // End Collection
|
0xC0, // End Collection
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Keyboard HID Report Descriptor with LED output support.
|
||||||
|
/// Input report format (8 bytes):
|
||||||
|
/// [0] Modifier keys (8 bits)
|
||||||
|
/// [1] Reserved
|
||||||
|
/// [2-7] Key codes (6 keys)
|
||||||
|
/// Output report format (1 byte):
|
||||||
|
/// [0] Num Lock / Caps Lock / Scroll Lock / Compose / Kana
|
||||||
|
pub const KEYBOARD_WITH_LED: &[u8] = &[
|
||||||
|
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||||
|
0x09, 0x06, // Usage (Keyboard)
|
||||||
|
0xA1, 0x01, // Collection (Application)
|
||||||
|
// Modifier keys input (8 bits)
|
||||||
|
0x05, 0x07, // Usage Page (Key Codes)
|
||||||
|
0x19, 0xE0, // Usage Minimum (224) - Left Control
|
||||||
|
0x29, 0xE7, // Usage Maximum (231) - Right GUI
|
||||||
|
0x15, 0x00, // Logical Minimum (0)
|
||||||
|
0x25, 0x01, // Logical Maximum (1)
|
||||||
|
0x75, 0x01, // Report Size (1)
|
||||||
|
0x95, 0x08, // Report Count (8)
|
||||||
|
0x81, 0x02, // Input (Data, Variable, Absolute) - Modifier byte
|
||||||
|
// Reserved byte
|
||||||
|
0x95, 0x01, // Report Count (1)
|
||||||
|
0x75, 0x08, // Report Size (8)
|
||||||
|
0x81, 0x01, // Input (Constant) - Reserved byte
|
||||||
|
// LED output bits
|
||||||
|
0x95, 0x05, // Report Count (5)
|
||||||
|
0x75, 0x01, // Report Size (1)
|
||||||
|
0x05, 0x08, // Usage Page (LEDs)
|
||||||
|
0x19, 0x01, // Usage Minimum (1)
|
||||||
|
0x29, 0x05, // Usage Maximum (5)
|
||||||
|
0x91, 0x02, // Output (Data, Variable, Absolute)
|
||||||
|
// LED padding
|
||||||
|
0x95, 0x01, // Report Count (1)
|
||||||
|
0x75, 0x03, // Report Size (3)
|
||||||
|
0x91, 0x01, // Output (Constant)
|
||||||
|
// Key array (6 bytes)
|
||||||
|
0x95, 0x06, // Report Count (6)
|
||||||
|
0x75, 0x08, // Report Size (8)
|
||||||
|
0x15, 0x00, // Logical Minimum (0)
|
||||||
|
0x26, 0xFF, 0x00, // Logical Maximum (255)
|
||||||
|
0x05, 0x07, // Usage Page (Key Codes)
|
||||||
|
0x19, 0x00, // Usage Minimum (0)
|
||||||
|
0x2A, 0xFF, 0x00, // Usage Maximum (255)
|
||||||
|
0x81, 0x00, // Input (Data, Array) - Key array (6 keys)
|
||||||
|
0xC0, // End Collection
|
||||||
|
];
|
||||||
|
|
||||||
/// Relative Mouse HID Report Descriptor (4 bytes report)
|
/// Relative Mouse HID Report Descriptor (4 bytes report)
|
||||||
/// Report format:
|
/// Report format:
|
||||||
/// [0] Buttons (5 bits) + padding (3 bits)
|
/// [0] Buttons (5 bits) + padding (3 bits)
|
||||||
@@ -155,6 +202,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_report_descriptor_sizes() {
|
fn test_report_descriptor_sizes() {
|
||||||
assert!(!KEYBOARD.is_empty());
|
assert!(!KEYBOARD.is_empty());
|
||||||
|
assert!(!KEYBOARD_WITH_LED.is_empty());
|
||||||
assert!(!MOUSE_RELATIVE.is_empty());
|
assert!(!MOUSE_RELATIVE.is_empty());
|
||||||
assert!(!MOUSE_ABSOLUTE.is_empty());
|
assert!(!MOUSE_ABSOLUTE.is_empty());
|
||||||
assert!(!CONSUMER_CONTROL.is_empty());
|
assert!(!CONSUMER_CONTROL.is_empty());
|
||||||
|
|||||||
@@ -1,39 +1,18 @@
|
|||||||
//! OTG Service - unified gadget lifecycle management
|
//! OTG Service - unified gadget lifecycle management
|
||||||
//!
|
//!
|
||||||
//! This module provides centralized management for USB OTG gadget functions.
|
//! This module provides centralized management for USB OTG gadget functions.
|
||||||
//! It solves the ownership problem where both HID and MSD need access to the
|
//! It is the single owner of the USB gadget desired state and reconciles
|
||||||
//! same USB gadget but should be independently configurable.
|
//! ConfigFS to match that state.
|
||||||
//!
|
|
||||||
//! Architecture:
|
|
||||||
//! ```text
|
|
||||||
//! ┌─────────────────────────┐
|
|
||||||
//! │ OtgService │
|
|
||||||
//! │ ┌───────────────────┐ │
|
|
||||||
//! │ │ OtgGadgetManager │ │
|
|
||||||
//! │ └───────────────────┘ │
|
|
||||||
//! │ ↓ ↓ │
|
|
||||||
//! │ ┌─────┐ ┌─────┐ │
|
|
||||||
//! │ │ HID │ │ MSD │ │
|
|
||||||
//! │ └─────┘ └─────┘ │
|
|
||||||
//! └─────────────────────────┘
|
|
||||||
//! ↑ ↑
|
|
||||||
//! HidController MsdController
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
||||||
use super::msd::MsdFunction;
|
use super::msd::MsdFunction;
|
||||||
use crate::config::{OtgDescriptorConfig, OtgHidFunctions};
|
use crate::config::{HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
/// Bitflags for requested functions (lock-free)
|
|
||||||
const FLAG_HID: u8 = 0b01;
|
|
||||||
const FLAG_MSD: u8 = 0b10;
|
|
||||||
|
|
||||||
/// HID device paths
|
/// HID device paths
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct HidDevicePaths {
|
pub struct HidDevicePaths {
|
||||||
@@ -41,6 +20,8 @@ pub struct HidDevicePaths {
|
|||||||
pub mouse_relative: Option<PathBuf>,
|
pub mouse_relative: Option<PathBuf>,
|
||||||
pub mouse_absolute: Option<PathBuf>,
|
pub mouse_absolute: Option<PathBuf>,
|
||||||
pub consumer: Option<PathBuf>,
|
pub consumer: Option<PathBuf>,
|
||||||
|
pub udc: Option<String>,
|
||||||
|
pub keyboard_leds_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HidDevicePaths {
|
impl HidDevicePaths {
|
||||||
@@ -62,6 +43,59 @@ impl HidDevicePaths {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Desired OTG gadget state derived from configuration.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct OtgDesiredState {
|
||||||
|
pub udc: Option<String>,
|
||||||
|
pub descriptor: GadgetDescriptor,
|
||||||
|
pub hid_functions: Option<OtgHidFunctions>,
|
||||||
|
pub keyboard_leds: bool,
|
||||||
|
pub msd_enabled: bool,
|
||||||
|
pub max_endpoints: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OtgDesiredState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
udc: None,
|
||||||
|
descriptor: GadgetDescriptor::default(),
|
||||||
|
hid_functions: None,
|
||||||
|
keyboard_leds: false,
|
||||||
|
msd_enabled: false,
|
||||||
|
max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OtgDesiredState {
|
||||||
|
pub fn from_config(hid: &HidConfig, msd: &MsdConfig) -> Result<Self> {
|
||||||
|
let hid_functions = if hid.backend == HidBackend::Otg {
|
||||||
|
let functions = hid.constrained_otg_functions();
|
||||||
|
Some(functions)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
hid.validate_otg_endpoint_budget(msd.enabled)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
udc: hid.resolved_otg_udc(),
|
||||||
|
descriptor: GadgetDescriptor::from(&hid.otg_descriptor),
|
||||||
|
hid_functions,
|
||||||
|
keyboard_leds: hid.effective_otg_keyboard_leds(),
|
||||||
|
msd_enabled: msd.enabled,
|
||||||
|
max_endpoints: hid
|
||||||
|
.resolved_otg_endpoint_limit()
|
||||||
|
.unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn hid_enabled(&self) -> bool {
|
||||||
|
self.hid_functions.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// OTG Service state
|
/// OTG Service state
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct OtgServiceState {
|
pub struct OtgServiceState {
|
||||||
@@ -71,19 +105,23 @@ pub struct OtgServiceState {
|
|||||||
pub hid_enabled: bool,
|
pub hid_enabled: bool,
|
||||||
/// Whether MSD function is enabled
|
/// Whether MSD function is enabled
|
||||||
pub msd_enabled: bool,
|
pub msd_enabled: bool,
|
||||||
|
/// Bound UDC name
|
||||||
|
pub configured_udc: Option<String>,
|
||||||
/// HID device paths (set after gadget setup)
|
/// HID device paths (set after gadget setup)
|
||||||
pub hid_paths: Option<HidDevicePaths>,
|
pub hid_paths: Option<HidDevicePaths>,
|
||||||
/// HID function selection (set after gadget setup)
|
/// HID function selection (set after gadget setup)
|
||||||
pub hid_functions: Option<OtgHidFunctions>,
|
pub hid_functions: Option<OtgHidFunctions>,
|
||||||
|
/// Whether keyboard LED/status feedback is enabled.
|
||||||
|
pub keyboard_leds_enabled: bool,
|
||||||
|
/// Applied endpoint budget.
|
||||||
|
pub max_endpoints: u8,
|
||||||
|
/// Applied descriptor configuration
|
||||||
|
pub descriptor: Option<GadgetDescriptor>,
|
||||||
/// Error message if setup failed
|
/// Error message if setup failed
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OTG Service - unified gadget lifecycle management
|
/// OTG Service - unified gadget lifecycle management
|
||||||
///
|
|
||||||
/// This service owns the OtgGadgetManager and provides a high-level interface
|
|
||||||
/// for enabling/disabling HID and MSD functions. It ensures proper coordination
|
|
||||||
/// between the two subsystems and handles gadget lifecycle management.
|
|
||||||
pub struct OtgService {
|
pub struct OtgService {
|
||||||
/// The underlying gadget manager
|
/// The underlying gadget manager
|
||||||
manager: Mutex<Option<OtgGadgetManager>>,
|
manager: Mutex<Option<OtgGadgetManager>>,
|
||||||
@@ -91,12 +129,8 @@ pub struct OtgService {
|
|||||||
state: RwLock<OtgServiceState>,
|
state: RwLock<OtgServiceState>,
|
||||||
/// MSD function handle (for runtime LUN configuration)
|
/// MSD function handle (for runtime LUN configuration)
|
||||||
msd_function: RwLock<Option<MsdFunction>>,
|
msd_function: RwLock<Option<MsdFunction>>,
|
||||||
/// Requested functions flags (atomic, lock-free read/write)
|
/// Desired OTG state
|
||||||
requested_flags: AtomicU8,
|
desired: RwLock<OtgDesiredState>,
|
||||||
/// Requested HID function set
|
|
||||||
hid_functions: RwLock<OtgHidFunctions>,
|
|
||||||
/// Current descriptor configuration
|
|
||||||
current_descriptor: RwLock<GadgetDescriptor>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OtgService {
|
impl OtgService {
|
||||||
@@ -106,41 +140,7 @@ impl OtgService {
|
|||||||
manager: Mutex::new(None),
|
manager: Mutex::new(None),
|
||||||
state: RwLock::new(OtgServiceState::default()),
|
state: RwLock::new(OtgServiceState::default()),
|
||||||
msd_function: RwLock::new(None),
|
msd_function: RwLock::new(None),
|
||||||
requested_flags: AtomicU8::new(0),
|
desired: RwLock::new(OtgDesiredState::default()),
|
||||||
hid_functions: RwLock::new(OtgHidFunctions::default()),
|
|
||||||
current_descriptor: RwLock::new(GadgetDescriptor::default()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if HID is requested (lock-free)
|
|
||||||
#[inline]
|
|
||||||
fn is_hid_requested(&self) -> bool {
|
|
||||||
self.requested_flags.load(Ordering::Acquire) & FLAG_HID != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if MSD is requested (lock-free)
|
|
||||||
#[inline]
|
|
||||||
fn is_msd_requested(&self) -> bool {
|
|
||||||
self.requested_flags.load(Ordering::Acquire) & FLAG_MSD != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set HID requested flag (lock-free)
|
|
||||||
#[inline]
|
|
||||||
fn set_hid_requested(&self, requested: bool) {
|
|
||||||
if requested {
|
|
||||||
self.requested_flags.fetch_or(FLAG_HID, Ordering::Release);
|
|
||||||
} else {
|
|
||||||
self.requested_flags.fetch_and(!FLAG_HID, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set MSD requested flag (lock-free)
|
|
||||||
#[inline]
|
|
||||||
fn set_msd_requested(&self, requested: bool) {
|
|
||||||
if requested {
|
|
||||||
self.requested_flags.fetch_or(FLAG_MSD, Ordering::Release);
|
|
||||||
} else {
|
|
||||||
self.requested_flags.fetch_and(!FLAG_MSD, Ordering::Release);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,258 +180,119 @@ impl OtgService {
|
|||||||
self.state.read().await.hid_paths.clone()
|
self.state.read().await.hid_paths.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current HID function selection
|
|
||||||
pub async fn hid_functions(&self) -> OtgHidFunctions {
|
|
||||||
self.hid_functions.read().await.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update HID function selection
|
|
||||||
pub async fn update_hid_functions(&self, functions: OtgHidFunctions) -> Result<()> {
|
|
||||||
if functions.is_empty() {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"OTG HID functions cannot be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut current = self.hid_functions.write().await;
|
|
||||||
if *current == functions {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
*current = functions;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If HID is active, recreate gadget with new function set
|
|
||||||
if self.is_hid_requested() {
|
|
||||||
self.recreate_gadget().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get MSD function handle (for LUN configuration)
|
/// Get MSD function handle (for LUN configuration)
|
||||||
pub async fn msd_function(&self) -> Option<MsdFunction> {
|
pub async fn msd_function(&self) -> Option<MsdFunction> {
|
||||||
self.msd_function.read().await.clone()
|
self.msd_function.read().await.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable HID functions
|
/// Apply desired OTG state derived from the current application config.
|
||||||
///
|
pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> {
|
||||||
/// This will create the gadget if not already created, add HID functions,
|
let desired = OtgDesiredState::from_config(hid, msd)?;
|
||||||
/// and bind the gadget to UDC.
|
self.apply_desired_state(desired).await
|
||||||
pub async fn enable_hid(&self) -> Result<HidDevicePaths> {
|
}
|
||||||
info!("Enabling HID functions via OtgService");
|
|
||||||
|
|
||||||
// Mark HID as requested (lock-free)
|
/// Apply a fully materialized desired OTG state.
|
||||||
self.set_hid_requested(true);
|
pub async fn apply_desired_state(&self, desired: OtgDesiredState) -> Result<()> {
|
||||||
|
|
||||||
// Check if already enabled and function set unchanged
|
|
||||||
let requested_functions = self.hid_functions.read().await.clone();
|
|
||||||
{
|
{
|
||||||
let state = self.state.read().await;
|
let mut current = self.desired.write().await;
|
||||||
if state.hid_enabled && state.hid_functions.as_ref() == Some(&requested_functions) {
|
*current = desired;
|
||||||
if let Some(ref paths) = state.hid_paths {
|
|
||||||
info!("HID already enabled, returning existing paths");
|
|
||||||
return Ok(paths.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate gadget with both HID and MSD if needed
|
self.reconcile_gadget().await
|
||||||
self.recreate_gadget().await?;
|
|
||||||
|
|
||||||
// Get HID paths from state
|
|
||||||
let state = self.state.read().await;
|
|
||||||
state
|
|
||||||
.hid_paths
|
|
||||||
.clone()
|
|
||||||
.ok_or_else(|| AppError::Internal("HID paths not set after gadget setup".to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disable HID functions
|
async fn reconcile_gadget(&self) -> Result<()> {
|
||||||
///
|
let desired = self.desired.read().await.clone();
|
||||||
/// This will unbind the gadget, remove HID functions, and optionally
|
|
||||||
/// recreate the gadget with only MSD if MSD is still enabled.
|
|
||||||
pub async fn disable_hid(&self) -> Result<()> {
|
|
||||||
info!("Disabling HID functions via OtgService");
|
|
||||||
|
|
||||||
// Mark HID as not requested (lock-free)
|
|
||||||
self.set_hid_requested(false);
|
|
||||||
|
|
||||||
// Check if HID is enabled
|
|
||||||
{
|
|
||||||
let state = self.state.read().await;
|
|
||||||
if !state.hid_enabled {
|
|
||||||
info!("HID already disabled");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreate gadget without HID (or destroy if MSD also disabled)
|
|
||||||
self.recreate_gadget().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable MSD function
|
|
||||||
///
|
|
||||||
/// This will create the gadget if not already created, add MSD function,
|
|
||||||
/// and bind the gadget to UDC.
|
|
||||||
pub async fn enable_msd(&self) -> Result<MsdFunction> {
|
|
||||||
info!("Enabling MSD function via OtgService");
|
|
||||||
|
|
||||||
// Mark MSD as requested (lock-free)
|
|
||||||
self.set_msd_requested(true);
|
|
||||||
|
|
||||||
// Check if already enabled
|
|
||||||
{
|
|
||||||
let state = self.state.read().await;
|
|
||||||
if state.msd_enabled {
|
|
||||||
let msd = self.msd_function.read().await;
|
|
||||||
if let Some(ref func) = *msd {
|
|
||||||
info!("MSD already enabled, returning existing function");
|
|
||||||
return Ok(func.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreate gadget with both HID and MSD if needed
|
|
||||||
self.recreate_gadget().await?;
|
|
||||||
|
|
||||||
// Get MSD function
|
|
||||||
let msd = self.msd_function.read().await;
|
|
||||||
msd.clone().ok_or_else(|| {
|
|
||||||
AppError::Internal("MSD function not set after gadget setup".to_string())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disable MSD function
|
|
||||||
///
|
|
||||||
/// This will unbind the gadget, remove MSD function, and optionally
|
|
||||||
/// recreate the gadget with only HID if HID is still enabled.
|
|
||||||
pub async fn disable_msd(&self) -> Result<()> {
|
|
||||||
info!("Disabling MSD function via OtgService");
|
|
||||||
|
|
||||||
// Mark MSD as not requested (lock-free)
|
|
||||||
self.set_msd_requested(false);
|
|
||||||
|
|
||||||
// Check if MSD is enabled
|
|
||||||
{
|
|
||||||
let state = self.state.read().await;
|
|
||||||
if !state.msd_enabled {
|
|
||||||
info!("MSD already disabled");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreate gadget without MSD (or destroy if HID also disabled)
|
|
||||||
self.recreate_gadget().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recreate the gadget with currently requested functions
|
|
||||||
///
|
|
||||||
/// This is called whenever the set of enabled functions changes.
|
|
||||||
/// It will:
|
|
||||||
/// 1. Check if recreation is needed (function set changed)
|
|
||||||
/// 2. If needed: cleanup existing gadget
|
|
||||||
/// 3. Create new gadget with requested functions
|
|
||||||
/// 4. Setup and bind
|
|
||||||
async fn recreate_gadget(&self) -> Result<()> {
|
|
||||||
// Read requested flags atomically (lock-free)
|
|
||||||
let hid_requested = self.is_hid_requested();
|
|
||||||
let msd_requested = self.is_msd_requested();
|
|
||||||
let hid_functions = if hid_requested {
|
|
||||||
self.hid_functions.read().await.clone()
|
|
||||||
} else {
|
|
||||||
OtgHidFunctions::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Recreating gadget with: HID={}, MSD={}",
|
"Reconciling OTG gadget: HID={}, MSD={}, UDC={:?}",
|
||||||
hid_requested, msd_requested
|
desired.hid_enabled(),
|
||||||
|
desired.msd_enabled,
|
||||||
|
desired.udc
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if gadget already matches requested state
|
|
||||||
{
|
{
|
||||||
let state = self.state.read().await;
|
let state = self.state.read().await;
|
||||||
let functions_match = if hid_requested {
|
|
||||||
state.hid_functions.as_ref() == Some(&hid_functions)
|
|
||||||
} else {
|
|
||||||
state.hid_functions.is_none()
|
|
||||||
};
|
|
||||||
if state.gadget_active
|
if state.gadget_active
|
||||||
&& state.hid_enabled == hid_requested
|
&& state.hid_enabled == desired.hid_enabled()
|
||||||
&& state.msd_enabled == msd_requested
|
&& state.msd_enabled == desired.msd_enabled
|
||||||
&& functions_match
|
&& state.configured_udc == desired.udc
|
||||||
|
&& state.hid_functions == desired.hid_functions
|
||||||
|
&& state.keyboard_leds_enabled == desired.keyboard_leds
|
||||||
|
&& state.max_endpoints == desired.max_endpoints
|
||||||
|
&& state.descriptor.as_ref() == Some(&desired.descriptor)
|
||||||
{
|
{
|
||||||
info!("Gadget already has requested functions, skipping recreate");
|
info!("OTG gadget already matches desired state");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup existing gadget
|
|
||||||
{
|
{
|
||||||
let mut manager = self.manager.lock().await;
|
let mut manager = self.manager.lock().await;
|
||||||
if let Some(mut m) = manager.take() {
|
if let Some(mut m) = manager.take() {
|
||||||
info!("Cleaning up existing gadget before recreate");
|
info!("Cleaning up existing gadget before OTG reconcile");
|
||||||
if let Err(e) = m.cleanup() {
|
if let Err(e) = m.cleanup() {
|
||||||
warn!("Error cleaning up existing gadget: {}", e);
|
warn!("Error cleaning up existing gadget: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear MSD function
|
|
||||||
*self.msd_function.write().await = None;
|
*self.msd_function.write().await = None;
|
||||||
|
|
||||||
// Update state to inactive
|
|
||||||
{
|
{
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
state.gadget_active = false;
|
state.gadget_active = false;
|
||||||
state.hid_enabled = false;
|
state.hid_enabled = false;
|
||||||
state.msd_enabled = false;
|
state.msd_enabled = false;
|
||||||
|
state.configured_udc = None;
|
||||||
state.hid_paths = None;
|
state.hid_paths = None;
|
||||||
state.hid_functions = None;
|
state.hid_functions = None;
|
||||||
|
state.keyboard_leds_enabled = false;
|
||||||
|
state.max_endpoints = super::endpoint::DEFAULT_MAX_ENDPOINTS;
|
||||||
|
state.descriptor = None;
|
||||||
state.error = None;
|
state.error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If nothing requested, we're done
|
if !desired.hid_enabled() && !desired.msd_enabled {
|
||||||
if !hid_requested && !msd_requested {
|
info!("OTG desired state is empty, gadget removed");
|
||||||
info!("No functions requested, gadget destroyed");
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if OTG is available
|
if let Err(e) = super::configfs::ensure_libcomposite_loaded() {
|
||||||
if !Self::is_available() {
|
warn!("Failed to ensure libcomposite is available: {}", e);
|
||||||
let error = "OTG not available: ConfigFS not mounted or no UDC found".to_string();
|
}
|
||||||
let mut state = self.state.write().await;
|
|
||||||
state.error = Some(error.clone());
|
if !OtgGadgetManager::is_available() {
|
||||||
|
let error = "OTG not available: ConfigFS not mounted".to_string();
|
||||||
|
self.state.write().await.error = Some(error.clone());
|
||||||
return Err(AppError::Internal(error));
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new gadget manager with current descriptor
|
let udc = desired.udc.clone().ok_or_else(|| {
|
||||||
let descriptor = self.current_descriptor.read().await.clone();
|
let error = "OTG not available: no UDC found".to_string();
|
||||||
|
AppError::Internal(error)
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut manager = OtgGadgetManager::with_descriptor(
|
let mut manager = OtgGadgetManager::with_descriptor(
|
||||||
super::configfs::DEFAULT_GADGET_NAME,
|
super::configfs::DEFAULT_GADGET_NAME,
|
||||||
super::endpoint::DEFAULT_MAX_ENDPOINTS,
|
desired.max_endpoints,
|
||||||
descriptor,
|
desired.descriptor.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut hid_paths = None;
|
let mut hid_paths = None;
|
||||||
|
if let Some(hid_functions) = desired.hid_functions.clone() {
|
||||||
// Add HID functions if requested
|
let mut paths = HidDevicePaths {
|
||||||
if hid_requested {
|
udc: Some(udc.clone()),
|
||||||
if hid_functions.is_empty() {
|
keyboard_leds_enabled: desired.keyboard_leds,
|
||||||
let error = "HID functions set is empty".to_string();
|
..Default::default()
|
||||||
let mut state = self.state.write().await;
|
};
|
||||||
state.error = Some(error.clone());
|
|
||||||
return Err(AppError::BadRequest(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut paths = HidDevicePaths::default();
|
|
||||||
|
|
||||||
if hid_functions.keyboard {
|
if hid_functions.keyboard {
|
||||||
match manager.add_keyboard() {
|
match manager.add_keyboard(desired.keyboard_leds) {
|
||||||
Ok(kb) => paths.keyboard = Some(kb),
|
Ok(kb) => paths.keyboard = Some(kb),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error = format!("Failed to add keyboard HID function: {}", e);
|
let error = format!("Failed to add keyboard HID function: {}", e);
|
||||||
let mut state = self.state.write().await;
|
self.state.write().await.error = Some(error.clone());
|
||||||
state.error = Some(error.clone());
|
|
||||||
return Err(AppError::Internal(error));
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,8 +303,7 @@ impl OtgService {
|
|||||||
Ok(rel) => paths.mouse_relative = Some(rel),
|
Ok(rel) => paths.mouse_relative = Some(rel),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error = format!("Failed to add relative mouse HID function: {}", e);
|
let error = format!("Failed to add relative mouse HID function: {}", e);
|
||||||
let mut state = self.state.write().await;
|
self.state.write().await.error = Some(error.clone());
|
||||||
state.error = Some(error.clone());
|
|
||||||
return Err(AppError::Internal(error));
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,8 +314,7 @@ impl OtgService {
|
|||||||
Ok(abs) => paths.mouse_absolute = Some(abs),
|
Ok(abs) => paths.mouse_absolute = Some(abs),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error = format!("Failed to add absolute mouse HID function: {}", e);
|
let error = format!("Failed to add absolute mouse HID function: {}", e);
|
||||||
let mut state = self.state.write().await;
|
self.state.write().await.error = Some(error.clone());
|
||||||
state.error = Some(error.clone());
|
|
||||||
return Err(AppError::Internal(error));
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,8 +325,7 @@ impl OtgService {
|
|||||||
Ok(consumer) => paths.consumer = Some(consumer),
|
Ok(consumer) => paths.consumer = Some(consumer),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error = format!("Failed to add consumer HID function: {}", e);
|
let error = format!("Failed to add consumer HID function: {}", e);
|
||||||
let mut state = self.state.write().await;
|
self.state.write().await.error = Some(error.clone());
|
||||||
state.error = Some(error.clone());
|
|
||||||
return Err(AppError::Internal(error));
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,8 +335,7 @@ impl OtgService {
|
|||||||
debug!("HID functions added to gadget");
|
debug!("HID functions added to gadget");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add MSD function if requested
|
let msd_func = if desired.msd_enabled {
|
||||||
let msd_func = if msd_requested {
|
|
||||||
match manager.add_msd() {
|
match manager.add_msd() {
|
||||||
Ok(func) => {
|
Ok(func) => {
|
||||||
debug!("MSD function added to gadget");
|
debug!("MSD function added to gadget");
|
||||||
@@ -486,8 +343,7 @@ impl OtgService {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error = format!("Failed to add MSD function: {}", e);
|
let error = format!("Failed to add MSD function: {}", e);
|
||||||
let mut state = self.state.write().await;
|
self.state.write().await.error = Some(error.clone());
|
||||||
state.error = Some(error.clone());
|
|
||||||
return Err(AppError::Internal(error));
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,25 +351,19 @@ impl OtgService {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setup gadget
|
|
||||||
if let Err(e) = manager.setup() {
|
if let Err(e) = manager.setup() {
|
||||||
let error = format!("Failed to setup gadget: {}", e);
|
let error = format!("Failed to setup gadget: {}", e);
|
||||||
let mut state = self.state.write().await;
|
self.state.write().await.error = Some(error.clone());
|
||||||
state.error = Some(error.clone());
|
|
||||||
return Err(AppError::Internal(error));
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind to UDC
|
if let Err(e) = manager.bind(&udc) {
|
||||||
if let Err(e) = manager.bind() {
|
let error = format!("Failed to bind gadget to UDC {}: {}", udc, e);
|
||||||
let error = format!("Failed to bind gadget to UDC: {}", e);
|
self.state.write().await.error = Some(error.clone());
|
||||||
let mut state = self.state.write().await;
|
|
||||||
state.error = Some(error.clone());
|
|
||||||
// Cleanup on failure
|
|
||||||
let _ = manager.cleanup();
|
let _ = manager.cleanup();
|
||||||
return Err(AppError::Internal(error));
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for HID devices to appear
|
|
||||||
if let Some(ref paths) = hid_paths {
|
if let Some(ref paths) = hid_paths {
|
||||||
let device_paths = paths.existing_paths();
|
let device_paths = paths.existing_paths();
|
||||||
if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await {
|
if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await {
|
||||||
@@ -521,103 +371,36 @@ impl OtgService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store manager and update state
|
|
||||||
{
|
|
||||||
*self.manager.lock().await = Some(manager);
|
*self.manager.lock().await = Some(manager);
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
*self.msd_function.write().await = msd_func;
|
*self.msd_function.write().await = msd_func;
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
state.gadget_active = true;
|
state.gadget_active = true;
|
||||||
state.hid_enabled = hid_requested;
|
state.hid_enabled = desired.hid_enabled();
|
||||||
state.msd_enabled = msd_requested;
|
state.msd_enabled = desired.msd_enabled;
|
||||||
|
state.configured_udc = Some(udc);
|
||||||
state.hid_paths = hid_paths;
|
state.hid_paths = hid_paths;
|
||||||
state.hid_functions = if hid_requested {
|
state.hid_functions = desired.hid_functions;
|
||||||
Some(hid_functions)
|
state.keyboard_leds_enabled = desired.keyboard_leds;
|
||||||
} else {
|
state.max_endpoints = desired.max_endpoints;
|
||||||
None
|
state.descriptor = Some(desired.descriptor);
|
||||||
};
|
|
||||||
state.error = None;
|
state.error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Gadget created successfully");
|
info!("OTG gadget reconciled successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the descriptor configuration
|
|
||||||
///
|
|
||||||
/// This updates the stored descriptor and triggers a gadget recreation
|
|
||||||
/// if the gadget is currently active.
|
|
||||||
pub async fn update_descriptor(&self, config: &OtgDescriptorConfig) -> Result<()> {
|
|
||||||
let new_descriptor = GadgetDescriptor {
|
|
||||||
vendor_id: config.vendor_id,
|
|
||||||
product_id: config.product_id,
|
|
||||||
device_version: super::configfs::DEFAULT_USB_BCD_DEVICE,
|
|
||||||
manufacturer: config.manufacturer.clone(),
|
|
||||||
product: config.product.clone(),
|
|
||||||
serial_number: config
|
|
||||||
.serial_number
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "0123456789".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update stored descriptor
|
|
||||||
*self.current_descriptor.write().await = new_descriptor;
|
|
||||||
|
|
||||||
// If gadget is active, recreate it with new descriptor
|
|
||||||
let state = self.state.read().await;
|
|
||||||
if state.gadget_active {
|
|
||||||
drop(state); // Release read lock before calling recreate
|
|
||||||
info!("Descriptor changed, recreating gadget");
|
|
||||||
self.force_recreate_gadget().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force recreate the gadget (used when descriptor changes)
|
|
||||||
async fn force_recreate_gadget(&self) -> Result<()> {
|
|
||||||
// Cleanup existing gadget
|
|
||||||
{
|
|
||||||
let mut manager = self.manager.lock().await;
|
|
||||||
if let Some(mut m) = manager.take() {
|
|
||||||
info!("Cleaning up existing gadget for descriptor change");
|
|
||||||
if let Err(e) = m.cleanup() {
|
|
||||||
warn!("Error cleaning up existing gadget: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear MSD function
|
|
||||||
*self.msd_function.write().await = None;
|
|
||||||
|
|
||||||
// Update state to inactive
|
|
||||||
{
|
|
||||||
let mut state = self.state.write().await;
|
|
||||||
state.gadget_active = false;
|
|
||||||
state.hid_enabled = false;
|
|
||||||
state.msd_enabled = false;
|
|
||||||
state.hid_paths = None;
|
|
||||||
state.hid_functions = None;
|
|
||||||
state.error = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreate with current requested functions
|
|
||||||
self.recreate_gadget().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shutdown the OTG service and cleanup all resources
|
/// Shutdown the OTG service and cleanup all resources
|
||||||
pub async fn shutdown(&self) -> Result<()> {
|
pub async fn shutdown(&self) -> Result<()> {
|
||||||
info!("Shutting down OTG service");
|
info!("Shutting down OTG service");
|
||||||
|
|
||||||
// Mark nothing as requested (lock-free)
|
{
|
||||||
self.requested_flags.store(0, Ordering::Release);
|
let mut desired = self.desired.write().await;
|
||||||
|
*desired = OtgDesiredState::default();
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup gadget
|
|
||||||
let mut manager = self.manager.lock().await;
|
let mut manager = self.manager.lock().await;
|
||||||
if let Some(mut m) = manager.take() {
|
if let Some(mut m) = manager.take() {
|
||||||
if let Err(e) = m.cleanup() {
|
if let Err(e) = m.cleanup() {
|
||||||
@@ -625,7 +408,6 @@ impl OtgService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear state
|
|
||||||
*self.msd_function.write().await = None;
|
*self.msd_function.write().await = None;
|
||||||
{
|
{
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
@@ -645,11 +427,26 @@ impl Default for OtgService {
|
|||||||
|
|
||||||
impl Drop for OtgService {
|
impl Drop for OtgService {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// Gadget cleanup is handled by OtgGadgetManager's Drop
|
|
||||||
debug!("OtgService dropping");
|
debug!("OtgService dropping");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<&OtgDescriptorConfig> for GadgetDescriptor {
|
||||||
|
fn from(config: &OtgDescriptorConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
vendor_id: config.vendor_id,
|
||||||
|
product_id: config.product_id,
|
||||||
|
device_version: super::configfs::DEFAULT_USB_BCD_DEVICE,
|
||||||
|
manufacturer: config.manufacturer.clone(),
|
||||||
|
product: config.product.clone(),
|
||||||
|
serial_number: config
|
||||||
|
.serial_number
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "0123456789".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -657,8 +454,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_service_creation() {
|
fn test_service_creation() {
|
||||||
let _service = OtgService::new();
|
let _service = OtgService::new();
|
||||||
// Just test that creation doesn't panic
|
let _ = OtgService::is_available();
|
||||||
let _ = OtgService::is_available(); // Depends on environment
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use tokio::sync::{broadcast, mpsc, Mutex};
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::audio::AudioController;
|
use crate::audio::AudioController;
|
||||||
use crate::hid::{HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
|
use crate::hid::{CanonicalKey, HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
|
||||||
use crate::video::codec_constraints::{
|
use crate::video::codec_constraints::{
|
||||||
encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec,
|
encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec,
|
||||||
};
|
};
|
||||||
@@ -652,22 +652,22 @@ impl Connection {
|
|||||||
// H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.)
|
// H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.)
|
||||||
// and most RustDesk clients support H264 hardware decoding
|
// and most RustDesk clients support H264 hardware decoding
|
||||||
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264)
|
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264)
|
||||||
&& registry.is_format_available(VideoEncoderType::H264, false)
|
&& registry.is_codec_available(VideoEncoderType::H264)
|
||||||
{
|
{
|
||||||
return VideoEncoderType::H264;
|
return VideoEncoderType::H264;
|
||||||
}
|
}
|
||||||
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265)
|
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265)
|
||||||
&& registry.is_format_available(VideoEncoderType::H265, false)
|
&& registry.is_codec_available(VideoEncoderType::H265)
|
||||||
{
|
{
|
||||||
return VideoEncoderType::H265;
|
return VideoEncoderType::H265;
|
||||||
}
|
}
|
||||||
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8)
|
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8)
|
||||||
&& registry.is_format_available(VideoEncoderType::VP8, false)
|
&& registry.is_codec_available(VideoEncoderType::VP8)
|
||||||
{
|
{
|
||||||
return VideoEncoderType::VP8;
|
return VideoEncoderType::VP8;
|
||||||
}
|
}
|
||||||
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9)
|
if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9)
|
||||||
&& registry.is_format_available(VideoEncoderType::VP9, false)
|
&& registry.is_codec_available(VideoEncoderType::VP9)
|
||||||
{
|
{
|
||||||
return VideoEncoderType::VP9;
|
return VideoEncoderType::VP9;
|
||||||
}
|
}
|
||||||
@@ -784,7 +784,7 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let registry = EncoderRegistry::global();
|
let registry = EncoderRegistry::global();
|
||||||
if registry.is_format_available(new_codec, false) {
|
if registry.is_codec_available(new_codec) {
|
||||||
info!(
|
info!(
|
||||||
"Client requested codec switch: {:?} -> {:?}",
|
"Client requested codec switch: {:?} -> {:?}",
|
||||||
self.negotiated_codec, new_codec
|
self.negotiated_codec, new_codec
|
||||||
@@ -1121,16 +1121,16 @@ impl Connection {
|
|||||||
// Check which encoders are available (include software fallback)
|
// Check which encoders are available (include software fallback)
|
||||||
let h264_available = constraints
|
let h264_available = constraints
|
||||||
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264)
|
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264)
|
||||||
&& registry.is_format_available(VideoEncoderType::H264, false);
|
&& registry.is_codec_available(VideoEncoderType::H264);
|
||||||
let h265_available = constraints
|
let h265_available = constraints
|
||||||
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265)
|
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265)
|
||||||
&& registry.is_format_available(VideoEncoderType::H265, false);
|
&& registry.is_codec_available(VideoEncoderType::H265);
|
||||||
let vp8_available = constraints
|
let vp8_available = constraints
|
||||||
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8)
|
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8)
|
||||||
&& registry.is_format_available(VideoEncoderType::VP8, false);
|
&& registry.is_codec_available(VideoEncoderType::VP8);
|
||||||
let vp9_available = constraints
|
let vp9_available = constraints
|
||||||
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9)
|
.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9)
|
||||||
&& registry.is_format_available(VideoEncoderType::VP9, false);
|
&& registry.is_codec_available(VideoEncoderType::VP9);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}",
|
"Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}",
|
||||||
@@ -1328,15 +1328,13 @@ impl Connection {
|
|||||||
);
|
);
|
||||||
let caps_down = KeyboardEvent {
|
let caps_down = KeyboardEvent {
|
||||||
event_type: KeyEventType::Down,
|
event_type: KeyEventType::Down,
|
||||||
key: 0x39, // USB HID CapsLock
|
key: CanonicalKey::CapsLock,
|
||||||
modifiers: KeyboardModifiers::default(),
|
modifiers: KeyboardModifiers::default(),
|
||||||
is_usb_hid: true,
|
|
||||||
};
|
};
|
||||||
let caps_up = KeyboardEvent {
|
let caps_up = KeyboardEvent {
|
||||||
event_type: KeyEventType::Up,
|
event_type: KeyEventType::Up,
|
||||||
key: 0x39,
|
key: CanonicalKey::CapsLock,
|
||||||
modifiers: KeyboardModifiers::default(),
|
modifiers: KeyboardModifiers::default(),
|
||||||
is_usb_hid: true,
|
|
||||||
};
|
};
|
||||||
if let Err(e) = hid.send_keyboard(caps_down).await {
|
if let Err(e) = hid.send_keyboard(caps_down).await {
|
||||||
warn!("Failed to send CapsLock down: {}", e);
|
warn!("Failed to send CapsLock down: {}", e);
|
||||||
@@ -1351,7 +1349,7 @@ impl Connection {
|
|||||||
if let Some(kb_event) = convert_key_event(ke) {
|
if let Some(kb_event) = convert_key_event(ke) {
|
||||||
debug!(
|
debug!(
|
||||||
"Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}",
|
"Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}",
|
||||||
kb_event.key,
|
kb_event.key.to_hid_usage(),
|
||||||
kb_event.event_type,
|
kb_event.event_type,
|
||||||
kb_event.modifiers.to_hid_byte()
|
kb_event.modifiers.to_hid_byte()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
use super::protocol::hbb::message::key_event as ke_union;
|
use super::protocol::hbb::message::key_event as ke_union;
|
||||||
use super::protocol::{ControlKey, KeyEvent, MouseEvent};
|
use super::protocol::{ControlKey, KeyEvent, MouseEvent};
|
||||||
use crate::hid::{
|
use crate::hid::{
|
||||||
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent as OneKvmMouseEvent,
|
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
|
||||||
MouseEventType,
|
MouseEvent as OneKvmMouseEvent, MouseEventType,
|
||||||
};
|
};
|
||||||
use protobuf::Enum;
|
use protobuf::Enum;
|
||||||
|
|
||||||
@@ -217,11 +217,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
|
|||||||
// Handle control keys
|
// Handle control keys
|
||||||
if let Some(ke_union::Union::ControlKey(ck)) = &event.union {
|
if let Some(ke_union::Union::ControlKey(ck)) = &event.union {
|
||||||
if let Some(key) = control_key_to_hid(ck.value()) {
|
if let Some(key) = control_key_to_hid(ck.value()) {
|
||||||
|
let key = CanonicalKey::from_hid_usage(key)?;
|
||||||
return Some(KeyboardEvent {
|
return Some(KeyboardEvent {
|
||||||
event_type,
|
event_type,
|
||||||
key,
|
key,
|
||||||
modifiers,
|
modifiers,
|
||||||
is_usb_hid: true, // Already converted to USB HID code
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,11 +230,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
|
|||||||
if let Some(ke_union::Union::Chr(chr)) = &event.union {
|
if let Some(ke_union::Union::Chr(chr)) = &event.union {
|
||||||
// chr contains USB HID scancode on Windows, X11 keycode on Linux
|
// chr contains USB HID scancode on Windows, X11 keycode on Linux
|
||||||
if let Some(key) = keycode_to_hid(*chr) {
|
if let Some(key) = keycode_to_hid(*chr) {
|
||||||
|
let key = CanonicalKey::from_hid_usage(key)?;
|
||||||
return Some(KeyboardEvent {
|
return Some(KeyboardEvent {
|
||||||
event_type,
|
event_type,
|
||||||
key,
|
key,
|
||||||
modifiers,
|
modifiers,
|
||||||
is_usb_hid: true, // Already converted to USB HID code
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,6 +608,6 @@ mod tests {
|
|||||||
|
|
||||||
let kb_event = result.unwrap();
|
let kb_event = result.unwrap();
|
||||||
assert_eq!(kb_event.event_type, KeyEventType::Down);
|
assert_eq!(kb_event.event_type, KeyEventType::Down);
|
||||||
assert_eq!(kb_event.key, 0x28); // Return key USB HID code
|
assert_eq!(kb_event.key, CanonicalKey::Enter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/state.rs
72
src/state.rs
@@ -1,5 +1,5 @@
|
|||||||
use std::{collections::VecDeque, sync::Arc};
|
use std::{collections::VecDeque, sync::Arc};
|
||||||
use tokio::sync::{broadcast, RwLock};
|
use tokio::sync::{broadcast, watch, RwLock};
|
||||||
|
|
||||||
use crate::atx::AtxController;
|
use crate::atx::AtxController;
|
||||||
use crate::audio::AudioController;
|
use crate::audio::AudioController;
|
||||||
@@ -7,9 +7,9 @@ use crate::auth::{SessionStore, UserStore};
|
|||||||
use crate::config::ConfigStore;
|
use crate::config::ConfigStore;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
|
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, MsdDeviceInfo, SystemEvent,
|
||||||
VideoDeviceInfo,
|
TtydDeviceInfo, VideoDeviceInfo,
|
||||||
};
|
};
|
||||||
use crate::extensions::ExtensionManager;
|
use crate::extensions::{ExtensionId, ExtensionManager};
|
||||||
use crate::hid::HidController;
|
use crate::hid::HidController;
|
||||||
use crate::msd::MsdController;
|
use crate::msd::MsdController;
|
||||||
use crate::otg::OtgService;
|
use crate::otg::OtgService;
|
||||||
@@ -58,6 +58,8 @@ pub struct AppState {
|
|||||||
pub extensions: Arc<ExtensionManager>,
|
pub extensions: Arc<ExtensionManager>,
|
||||||
/// Event bus for real-time notifications
|
/// Event bus for real-time notifications
|
||||||
pub events: Arc<EventBus>,
|
pub events: Arc<EventBus>,
|
||||||
|
/// Latest device info snapshot for WebSocket clients
|
||||||
|
device_info_tx: watch::Sender<Option<SystemEvent>>,
|
||||||
/// Online update service
|
/// Online update service
|
||||||
pub update: Arc<UpdateService>,
|
pub update: Arc<UpdateService>,
|
||||||
/// Shutdown signal sender
|
/// Shutdown signal sender
|
||||||
@@ -89,6 +91,8 @@ impl AppState {
|
|||||||
shutdown_tx: broadcast::Sender<()>,
|
shutdown_tx: broadcast::Sender<()>,
|
||||||
data_dir: std::path::PathBuf,
|
data_dir: std::path::PathBuf,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
|
let (device_info_tx, _device_info_rx) = watch::channel(None);
|
||||||
|
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
config,
|
config,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -103,6 +107,7 @@ impl AppState {
|
|||||||
rtsp: Arc::new(RwLock::new(rtsp)),
|
rtsp: Arc::new(RwLock::new(rtsp)),
|
||||||
extensions,
|
extensions,
|
||||||
events,
|
events,
|
||||||
|
device_info_tx,
|
||||||
update,
|
update,
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
||||||
@@ -120,6 +125,11 @@ impl AppState {
|
|||||||
self.shutdown_tx.subscribe()
|
self.shutdown_tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribe to the latest device info snapshot.
|
||||||
|
pub fn subscribe_device_info(&self) -> watch::Receiver<Option<SystemEvent>> {
|
||||||
|
self.device_info_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Record revoked session IDs (bounded queue)
|
/// Record revoked session IDs (bounded queue)
|
||||||
pub async fn remember_revoked_sessions(&self, session_ids: Vec<String>) {
|
pub async fn remember_revoked_sessions(&self, session_ids: Vec<String>) {
|
||||||
if session_ids.is_empty() {
|
if session_ids.is_empty() {
|
||||||
@@ -147,12 +157,13 @@ impl AppState {
|
|||||||
/// Uses tokio::join! to collect all device info in parallel for better performance.
|
/// Uses tokio::join! to collect all device info in parallel for better performance.
|
||||||
pub async fn get_device_info(&self) -> SystemEvent {
|
pub async fn get_device_info(&self) -> SystemEvent {
|
||||||
// Collect all device info in parallel
|
// Collect all device info in parallel
|
||||||
let (video, hid, msd, atx, audio) = tokio::join!(
|
let (video, hid, msd, atx, audio, ttyd) = tokio::join!(
|
||||||
self.collect_video_info(),
|
self.collect_video_info(),
|
||||||
self.collect_hid_info(),
|
self.collect_hid_info(),
|
||||||
self.collect_msd_info(),
|
self.collect_msd_info(),
|
||||||
self.collect_atx_info(),
|
self.collect_atx_info(),
|
||||||
self.collect_audio_info(),
|
self.collect_audio_info(),
|
||||||
|
self.collect_ttyd_info(),
|
||||||
);
|
);
|
||||||
|
|
||||||
SystemEvent::DeviceInfo {
|
SystemEvent::DeviceInfo {
|
||||||
@@ -161,13 +172,14 @@ impl AppState {
|
|||||||
msd,
|
msd,
|
||||||
atx,
|
atx,
|
||||||
audio,
|
audio,
|
||||||
|
ttyd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish DeviceInfo event to all connected WebSocket clients
|
/// Publish DeviceInfo event to all connected WebSocket clients
|
||||||
pub async fn publish_device_info(&self) {
|
pub async fn publish_device_info(&self) {
|
||||||
let device_info = self.get_device_info().await;
|
let device_info = self.get_device_info().await;
|
||||||
self.events.publish(device_info);
|
let _ = self.device_info_tx.send(Some(device_info));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collect video device information
|
/// Collect video device information
|
||||||
@@ -178,32 +190,19 @@ impl AppState {
|
|||||||
|
|
||||||
/// Collect HID device information
|
/// Collect HID device information
|
||||||
async fn collect_hid_info(&self) -> HidDeviceInfo {
|
async fn collect_hid_info(&self) -> HidDeviceInfo {
|
||||||
let info = self.hid.info().await;
|
let state = self.hid.snapshot().await;
|
||||||
let backend_type = self.hid.backend_type().await;
|
|
||||||
|
|
||||||
match info {
|
HidDeviceInfo {
|
||||||
Some(hid_info) => HidDeviceInfo {
|
available: state.available,
|
||||||
available: true,
|
backend: state.backend,
|
||||||
backend: hid_info.name.to_string(),
|
initialized: state.initialized,
|
||||||
initialized: hid_info.initialized,
|
online: state.online,
|
||||||
supports_absolute_mouse: hid_info.supports_absolute_mouse,
|
supports_absolute_mouse: state.supports_absolute_mouse,
|
||||||
device: match backend_type {
|
keyboard_leds_enabled: state.keyboard_leds_enabled,
|
||||||
crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()),
|
led_state: state.led_state,
|
||||||
_ => None,
|
device: state.device,
|
||||||
},
|
error: state.error,
|
||||||
error: None,
|
error_code: state.error_code,
|
||||||
},
|
|
||||||
None => HidDeviceInfo {
|
|
||||||
available: false,
|
|
||||||
backend: backend_type.name_str().to_string(),
|
|
||||||
initialized: false,
|
|
||||||
supports_absolute_mouse: false,
|
|
||||||
device: match backend_type {
|
|
||||||
crate::hid::HidBackendType::Ch9329 { ref port, .. } => Some(port.clone()),
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
error: Some("HID backend not available".to_string()),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +212,7 @@ impl AppState {
|
|||||||
let msd = msd_guard.as_ref()?;
|
let msd = msd_guard.as_ref()?;
|
||||||
|
|
||||||
let state = msd.state().await;
|
let state = msd.state().await;
|
||||||
|
let error = msd.monitor().error_message().await;
|
||||||
Some(MsdDeviceInfo {
|
Some(MsdDeviceInfo {
|
||||||
available: state.available,
|
available: state.available,
|
||||||
mode: match state.mode {
|
mode: match state.mode {
|
||||||
@@ -223,7 +223,7 @@ impl AppState {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
image_id: state.current_image.map(|img| img.id),
|
image_id: state.current_image.map(|img| img.id),
|
||||||
error: None,
|
error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,4 +266,14 @@ impl AppState {
|
|||||||
error: status.error,
|
error: status.error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collect ttyd status information
|
||||||
|
async fn collect_ttyd_info(&self) -> TtydDeviceInfo {
|
||||||
|
let status = self.extensions.status(ExtensionId::Ttyd).await;
|
||||||
|
|
||||||
|
TtydDeviceInfo {
|
||||||
|
available: self.extensions.check_available(ExtensionId::Ttyd),
|
||||||
|
running: status.is_running(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
//! # Components
|
||||||
//!
|
//!
|
||||||
//! - `MjpegStreamer` - High-level MJPEG streaming manager
|
|
||||||
//! - `MjpegStreamHandler` - HTTP multipart MJPEG video streaming
|
//! - `MjpegStreamHandler` - HTTP multipart MJPEG video streaming
|
||||||
//! - `WsHidHandler` - WebSocket HID input handler
|
//! - `WsHidHandler` - WebSocket HID input handler
|
||||||
|
|
||||||
pub mod mjpeg;
|
pub mod mjpeg;
|
||||||
pub mod mjpeg_streamer;
|
|
||||||
pub mod ws_hid;
|
pub mod ws_hid;
|
||||||
|
|
||||||
pub use mjpeg::{ClientGuard, MjpegStreamHandler};
|
pub use mjpeg::{ClientGuard, MjpegStreamHandler};
|
||||||
pub use mjpeg_streamer::{
|
|
||||||
MjpegStreamer, MjpegStreamerConfig, MjpegStreamerState, MjpegStreamerStats,
|
|
||||||
};
|
|
||||||
pub use ws_hid::WsHidHandler;
|
pub use ws_hid::WsHidHandler;
|
||||||
|
|||||||
@@ -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::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
|
use super::detect_best_codec_for_format;
|
||||||
|
use super::registry::EncoderBackend;
|
||||||
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
@@ -69,21 +71,17 @@ impl std::fmt::Display for H264EncoderType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Map codec name to encoder type
|
/// Map codec name to encoder type
|
||||||
fn codec_name_to_type(name: &str) -> H264EncoderType {
|
impl From<EncoderBackend> for H264EncoderType {
|
||||||
if name.contains("nvenc") {
|
fn from(backend: EncoderBackend) -> Self {
|
||||||
H264EncoderType::Nvenc
|
match backend {
|
||||||
} else if name.contains("qsv") {
|
EncoderBackend::Nvenc => H264EncoderType::Nvenc,
|
||||||
H264EncoderType::Qsv
|
EncoderBackend::Qsv => H264EncoderType::Qsv,
|
||||||
} else if name.contains("amf") {
|
EncoderBackend::Amf => H264EncoderType::Amf,
|
||||||
H264EncoderType::Amf
|
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
|
||||||
} else if name.contains("vaapi") {
|
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
|
||||||
H264EncoderType::Vaapi
|
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
|
||||||
} else if name.contains("rkmpp") {
|
EncoderBackend::Software => H264EncoderType::Software,
|
||||||
H264EncoderType::Rkmpp
|
}
|
||||||
} else if name.contains("v4l2m2m") {
|
|
||||||
H264EncoderType::V4l2M2m
|
|
||||||
} else {
|
|
||||||
H264EncoderType::Software
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,22 +213,16 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
|||||||
pub fn detect_best_encoder(width: u32, height: u32) -> (H264EncoderType, Option<String>) {
|
pub fn detect_best_encoder(width: u32, height: u32) -> (H264EncoderType, Option<String>) {
|
||||||
let encoders = get_available_encoders(width, height);
|
let encoders = get_available_encoders(width, height);
|
||||||
|
|
||||||
if encoders.is_empty() {
|
if let Some((encoder_type, codec_name)) =
|
||||||
|
detect_best_codec_for_format(&encoders, hwcodec::common::DataFormat::H264, |_| true)
|
||||||
|
{
|
||||||
|
info!("Best H.264 encoder: {} ({})", codec_name, encoder_type);
|
||||||
|
(encoder_type, Some(codec_name))
|
||||||
|
} else {
|
||||||
warn!("No H.264 encoders available from hwcodec");
|
warn!("No H.264 encoders available from hwcodec");
|
||||||
return (H264EncoderType::None, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find H264 encoder (not H265)
|
|
||||||
for codec in &encoders {
|
|
||||||
if codec.format == hwcodec::common::DataFormat::H264 {
|
|
||||||
let encoder_type = codec_name_to_type(&codec.name);
|
|
||||||
info!("Best H.264 encoder: {} ({})", codec.name, encoder_type);
|
|
||||||
return (encoder_type, Some(codec.name.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(H264EncoderType::None, None)
|
(H264EncoderType::None, None)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Encoded frame from hwcodec (cloned for ownership)
|
/// Encoded frame from hwcodec (cloned for ownership)
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -252,9 +244,6 @@ pub struct H264Encoder {
|
|||||||
codec_name: String,
|
codec_name: String,
|
||||||
/// Frame counter
|
/// Frame counter
|
||||||
frame_count: u64,
|
frame_count: u64,
|
||||||
/// YUV420P buffer for input (reserved for future use)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
yuv_buffer: Vec<u8>,
|
|
||||||
/// Required YUV buffer length from hwcodec
|
/// Required YUV buffer length from hwcodec
|
||||||
yuv_length: i32,
|
yuv_length: i32,
|
||||||
}
|
}
|
||||||
@@ -321,7 +310,7 @@ impl H264Encoder {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let yuv_length = inner.length;
|
let yuv_length = inner.length;
|
||||||
let encoder_type = codec_name_to_type(codec_name);
|
let encoder_type = H264EncoderType::from(EncoderBackend::from_codec_name(codec_name));
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"H.264 encoder created: {} (type: {}, buffer_length: {}, input_format: {:?})",
|
"H.264 encoder created: {} (type: {}, buffer_length: {}, input_format: {:?})",
|
||||||
@@ -334,7 +323,6 @@ impl H264Encoder {
|
|||||||
encoder_type,
|
encoder_type,
|
||||||
codec_name: codec_name.to_string(),
|
codec_name: codec_name.to_string(),
|
||||||
frame_count: 0,
|
frame_count: 0,
|
||||||
yuv_buffer: vec![0u8; yuv_length as usize],
|
|
||||||
yuv_length,
|
yuv_length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use hwcodec::ffmpeg::AVPixelFormat;
|
|||||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
|
use super::detect_best_codec_for_format;
|
||||||
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||||
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
@@ -221,43 +222,25 @@ pub fn get_available_h265_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
|||||||
pub fn detect_best_h265_encoder(width: u32, height: u32) -> (H265EncoderType, Option<String>) {
|
pub fn detect_best_h265_encoder(width: u32, height: u32) -> (H265EncoderType, Option<String>) {
|
||||||
let encoders = get_available_h265_encoders(width, height);
|
let encoders = get_available_h265_encoders(width, height);
|
||||||
|
|
||||||
if encoders.is_empty() {
|
|
||||||
warn!("No H.265 encoders available");
|
|
||||||
return (H265EncoderType::None, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer hardware encoders over software (libx265)
|
// Prefer hardware encoders over software (libx265)
|
||||||
// Hardware priority: NVENC > QSV > AMF > VAAPI > RKMPP > V4L2 M2M > Software
|
// Hardware priority: NVENC > QSV > AMF > VAAPI > RKMPP > V4L2 M2M > Software
|
||||||
let codec = encoders
|
if let Some((encoder_type, codec_name)) =
|
||||||
.iter()
|
detect_best_codec_for_format(&encoders, DataFormat::H265, |codec| {
|
||||||
.find(|e| !e.name.contains("libx265"))
|
!codec.name.contains("libx265")
|
||||||
.or_else(|| encoders.first())
|
})
|
||||||
.unwrap();
|
{
|
||||||
|
info!("Selected H.265 encoder: {} ({})", codec_name, encoder_type);
|
||||||
let encoder_type = if codec.name.contains("nvenc") {
|
(encoder_type, Some(codec_name))
|
||||||
H265EncoderType::Nvenc
|
|
||||||
} else if codec.name.contains("qsv") {
|
|
||||||
H265EncoderType::Qsv
|
|
||||||
} else if codec.name.contains("amf") {
|
|
||||||
H265EncoderType::Amf
|
|
||||||
} else if codec.name.contains("vaapi") {
|
|
||||||
H265EncoderType::Vaapi
|
|
||||||
} else if codec.name.contains("rkmpp") {
|
|
||||||
H265EncoderType::Rkmpp
|
|
||||||
} else if codec.name.contains("v4l2m2m") {
|
|
||||||
H265EncoderType::V4l2M2m
|
|
||||||
} else {
|
} else {
|
||||||
H265EncoderType::Software // Default to software for unknown
|
warn!("No H.265 encoders available");
|
||||||
};
|
(H265EncoderType::None, None)
|
||||||
|
}
|
||||||
info!("Selected H.265 encoder: {} ({})", codec.name, encoder_type);
|
|
||||||
(encoder_type, Some(codec.name.clone()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if H265 hardware encoding is available
|
/// Check if H265 hardware encoding is available
|
||||||
pub fn is_h265_available() -> bool {
|
pub fn is_h265_available() -> bool {
|
||||||
let registry = EncoderRegistry::global();
|
let registry = EncoderRegistry::global();
|
||||||
registry.is_format_available(VideoEncoderType::H265, true)
|
registry.is_codec_available(VideoEncoderType::H265)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encoded frame from hwcodec (cloned for ownership)
|
/// Encoded frame from hwcodec (cloned for ownership)
|
||||||
@@ -268,7 +251,7 @@ pub struct HwEncodeFrame {
|
|||||||
pub key: i32,
|
pub key: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// H.265 encoder using hwcodec (hardware only)
|
/// H.265 encoder using hwcodec
|
||||||
pub struct H265Encoder {
|
pub struct H265Encoder {
|
||||||
/// hwcodec encoder instance
|
/// hwcodec encoder instance
|
||||||
inner: HwEncoder,
|
inner: HwEncoder,
|
||||||
|
|||||||
@@ -3,17 +3,21 @@
|
|||||||
//! This module provides video encoding capabilities including:
|
//! This module provides video encoding capabilities including:
|
||||||
//! - JPEG encoding for raw frames (YUYV, NV12, etc.)
|
//! - JPEG encoding for raw frames (YUYV, NV12, etc.)
|
||||||
//! - H264 encoding (hardware + software)
|
//! - H264 encoding (hardware + software)
|
||||||
//! - H265 encoding (hardware only)
|
//! - H265 encoding (hardware + software)
|
||||||
//! - VP8 encoding (hardware only - VAAPI)
|
//! - VP8 encoding (hardware + software)
|
||||||
//! - VP9 encoding (hardware only - VAAPI)
|
//! - VP9 encoding (hardware + software)
|
||||||
//! - WebRTC video codec abstraction
|
//! - WebRTC video codec abstraction
|
||||||
//! - Encoder registry for automatic detection
|
//! - Encoder registry for automatic detection
|
||||||
|
|
||||||
|
use hwcodec::common::DataFormat;
|
||||||
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
pub mod codec;
|
pub mod codec;
|
||||||
pub mod h264;
|
pub mod h264;
|
||||||
pub mod h265;
|
pub mod h265;
|
||||||
pub mod jpeg;
|
pub mod jpeg;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
pub mod self_check;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
pub mod vp8;
|
pub mod vp8;
|
||||||
pub mod vp9;
|
pub mod vp9;
|
||||||
@@ -28,18 +32,53 @@ pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, Vid
|
|||||||
|
|
||||||
// Encoder registry
|
// Encoder registry
|
||||||
pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType};
|
pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||||
|
pub use self_check::{
|
||||||
|
build_hardware_self_check_runtime_error, run_hardware_self_check, VideoEncoderSelfCheckCell,
|
||||||
|
VideoEncoderSelfCheckCodec, VideoEncoderSelfCheckResponse, VideoEncoderSelfCheckRow,
|
||||||
|
};
|
||||||
|
|
||||||
// H264 encoder
|
// H264 encoder
|
||||||
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
||||||
|
|
||||||
// H265 encoder (hardware only)
|
// H265 encoder
|
||||||
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
||||||
|
|
||||||
// VP8 encoder (hardware only)
|
// VP8 encoder
|
||||||
pub use vp8::{VP8Config, VP8Encoder, VP8EncoderType, VP8InputFormat};
|
pub use vp8::{VP8Config, VP8Encoder, VP8EncoderType, VP8InputFormat};
|
||||||
|
|
||||||
// VP9 encoder (hardware only)
|
// VP9 encoder
|
||||||
pub use vp9::{VP9Config, VP9Encoder, VP9EncoderType, VP9InputFormat};
|
pub use vp9::{VP9Config, VP9Encoder, VP9EncoderType, VP9InputFormat};
|
||||||
|
|
||||||
// JPEG encoder
|
// JPEG encoder
|
||||||
pub use jpeg::JpegEncoder;
|
pub use jpeg::JpegEncoder;
|
||||||
|
|
||||||
|
pub(crate) fn select_codec_for_format<F>(
|
||||||
|
encoders: &[CodecInfo],
|
||||||
|
format: DataFormat,
|
||||||
|
preferred: F,
|
||||||
|
) -> Option<&CodecInfo>
|
||||||
|
where
|
||||||
|
F: Fn(&CodecInfo) -> bool,
|
||||||
|
{
|
||||||
|
encoders
|
||||||
|
.iter()
|
||||||
|
.find(|codec| codec.format == format && preferred(codec))
|
||||||
|
.or_else(|| encoders.iter().find(|codec| codec.format == format))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn detect_best_codec_for_format<T, F>(
|
||||||
|
encoders: &[CodecInfo],
|
||||||
|
format: DataFormat,
|
||||||
|
preferred: F,
|
||||||
|
) -> Option<(T, String)>
|
||||||
|
where
|
||||||
|
T: From<EncoderBackend>,
|
||||||
|
F: Fn(&CodecInfo) -> bool,
|
||||||
|
{
|
||||||
|
select_codec_for_format(encoders, format, preferred).map(|codec| {
|
||||||
|
(
|
||||||
|
T::from(EncoderBackend::from_codec_name(&codec.name)),
|
||||||
|
codec.name.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Duration;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||||
@@ -28,6 +29,10 @@ pub enum VideoEncoderType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VideoEncoderType {
|
impl VideoEncoderType {
|
||||||
|
pub const fn ordered() -> [Self; 4] {
|
||||||
|
[Self::H264, Self::H265, Self::VP8, Self::VP9]
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert to hwcodec DataFormat
|
/// Convert to hwcodec DataFormat
|
||||||
pub fn to_data_format(&self) -> DataFormat {
|
pub fn to_data_format(&self) -> DataFormat {
|
||||||
match self {
|
match self {
|
||||||
@@ -68,17 +73,6 @@ impl VideoEncoderType {
|
|||||||
VideoEncoderType::VP9 => "VP9",
|
VideoEncoderType::VP9 => "VP9",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this format requires hardware-only encoding
|
|
||||||
/// H264 supports software fallback, others require hardware
|
|
||||||
pub fn hardware_only(&self) -> bool {
|
|
||||||
match self {
|
|
||||||
VideoEncoderType::H264 => false,
|
|
||||||
VideoEncoderType::H265 => true,
|
|
||||||
VideoEncoderType::VP8 => true,
|
|
||||||
VideoEncoderType::VP9 => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for VideoEncoderType {
|
impl std::fmt::Display for VideoEncoderType {
|
||||||
@@ -210,14 +204,84 @@ pub struct EncoderRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EncoderRegistry {
|
impl EncoderRegistry {
|
||||||
|
fn detect_encoders_with_timeout(ctx: EncodeContext, timeout: Duration) -> Vec<CodecInfo> {
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let handle = std::thread::Builder::new()
|
||||||
|
.name("ffmpeg-encoder-detect".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
let result = HwEncoder::available_encoders(ctx, None);
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
let Ok(handle) = handle else {
|
||||||
|
warn!("Failed to spawn encoder detection thread");
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
match rx.recv_timeout(timeout) {
|
||||||
|
Ok(encoders) => {
|
||||||
|
let _ = handle.join();
|
||||||
|
encoders
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
|
warn!(
|
||||||
|
"Encoder detection timed out after {}ms, skipping hardware detection",
|
||||||
|
timeout.as_millis()
|
||||||
|
);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = handle.join();
|
||||||
|
});
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
|
let _ = handle.join();
|
||||||
|
warn!("Encoder detection thread exited unexpectedly");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_software_fallbacks(&mut self) {
|
||||||
|
info!("Registering software encoders...");
|
||||||
|
|
||||||
|
for format in VideoEncoderType::ordered() {
|
||||||
|
let encoders = self.encoders.entry(format).or_default();
|
||||||
|
if encoders.iter().any(|encoder| !encoder.is_hardware) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let codec_name = match format {
|
||||||
|
VideoEncoderType::H264 => "libx264",
|
||||||
|
VideoEncoderType::H265 => "libx265",
|
||||||
|
VideoEncoderType::VP8 => "libvpx",
|
||||||
|
VideoEncoderType::VP9 => "libvpx-vp9",
|
||||||
|
};
|
||||||
|
|
||||||
|
encoders.push(AvailableEncoder {
|
||||||
|
format,
|
||||||
|
codec_name: codec_name.to_string(),
|
||||||
|
backend: EncoderBackend::Software,
|
||||||
|
priority: 100,
|
||||||
|
is_hardware: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Registered software encoder: {} for {} (priority: {})",
|
||||||
|
codec_name, format, 100
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the global registry instance
|
/// Get the global registry instance
|
||||||
///
|
///
|
||||||
/// The registry is initialized lazily on first access with 1920x1080 detection.
|
/// The registry is initialized lazily on first access with 1280x720 detection.
|
||||||
pub fn global() -> &'static Self {
|
pub fn global() -> &'static Self {
|
||||||
static INSTANCE: OnceLock<EncoderRegistry> = OnceLock::new();
|
static INSTANCE: OnceLock<EncoderRegistry> = OnceLock::new();
|
||||||
INSTANCE.get_or_init(|| {
|
INSTANCE.get_or_init(|| {
|
||||||
let mut registry = EncoderRegistry::new();
|
let mut registry = EncoderRegistry::new();
|
||||||
registry.detect_encoders(1920, 1080);
|
registry.detect_encoders(1280, 720);
|
||||||
registry
|
registry
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -257,32 +321,11 @@ impl EncoderRegistry {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DETECT_TIMEOUT_MS: u64 = 5000;
|
const DETECT_TIMEOUT_MS: u64 = 5000;
|
||||||
|
|
||||||
// Get all available encoders from hwcodec with a hard timeout
|
|
||||||
let all_encoders = {
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
info!("Encoder detection timeout: {}ms", DETECT_TIMEOUT_MS);
|
info!("Encoder detection timeout: {}ms", DETECT_TIMEOUT_MS);
|
||||||
|
let all_encoders = Self::detect_encoders_with_timeout(
|
||||||
let (tx, rx) = mpsc::channel();
|
ctx.clone(),
|
||||||
let ctx_clone = ctx.clone();
|
Duration::from_millis(DETECT_TIMEOUT_MS),
|
||||||
std::thread::spawn(move || {
|
|
||||||
let result = HwEncoder::available_encoders(ctx_clone, None);
|
|
||||||
let _ = tx.send(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
match rx.recv_timeout(Duration::from_millis(DETECT_TIMEOUT_MS)) {
|
|
||||||
Ok(encoders) => encoders,
|
|
||||||
Err(_) => {
|
|
||||||
warn!(
|
|
||||||
"Encoder detection timed out after {}ms, skipping hardware detection",
|
|
||||||
DETECT_TIMEOUT_MS
|
|
||||||
);
|
);
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
info!("Found {} encoders from hwcodec", all_encoders.len());
|
info!("Found {} encoders from hwcodec", all_encoders.len());
|
||||||
|
|
||||||
@@ -305,32 +348,7 @@ impl EncoderRegistry {
|
|||||||
encoders.sort_by_key(|e| e.priority);
|
encoders.sort_by_key(|e| e.priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register software encoders as fallback
|
self.register_software_fallbacks();
|
||||||
info!("Registering software encoders...");
|
|
||||||
let software_encoders = [
|
|
||||||
(VideoEncoderType::H264, "libx264", 100),
|
|
||||||
(VideoEncoderType::H265, "libx265", 100),
|
|
||||||
(VideoEncoderType::VP8, "libvpx", 100),
|
|
||||||
(VideoEncoderType::VP9, "libvpx-vp9", 100),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (format, codec_name, priority) in software_encoders {
|
|
||||||
self.encoders
|
|
||||||
.entry(format)
|
|
||||||
.or_default()
|
|
||||||
.push(AvailableEncoder {
|
|
||||||
format,
|
|
||||||
codec_name: codec_name.to_string(),
|
|
||||||
backend: EncoderBackend::Software,
|
|
||||||
priority,
|
|
||||||
is_hardware: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Registered software encoder: {} for {} (priority: {})",
|
|
||||||
codec_name, format, priority
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log summary
|
// Log summary
|
||||||
for (format, encoders) in &self.encoders {
|
for (format, encoders) in &self.encoders {
|
||||||
@@ -370,6 +388,10 @@ impl EncoderRegistry {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn best_available_encoder(&self, format: VideoEncoderType) -> Option<&AvailableEncoder> {
|
||||||
|
self.best_encoder(format, false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all encoders for a format
|
/// Get all encoders for a format
|
||||||
pub fn encoders_for_format(&self, format: VideoEncoderType) -> &[AvailableEncoder] {
|
pub fn encoders_for_format(&self, format: VideoEncoderType) -> &[AvailableEncoder] {
|
||||||
self.encoders
|
self.encoders
|
||||||
@@ -405,31 +427,17 @@ impl EncoderRegistry {
|
|||||||
self.best_encoder(format, hardware_only).is_some()
|
self.best_encoder(format, hardware_only).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_codec_available(&self, format: VideoEncoderType) -> bool {
|
||||||
|
self.best_available_encoder(format).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get available formats for user selection
|
/// Get available formats for user selection
|
||||||
///
|
///
|
||||||
/// Returns formats that are actually usable based on their requirements:
|
|
||||||
/// - H264: Available if any encoder exists (hardware or software)
|
|
||||||
/// - H265/VP8/VP9: Available only if hardware encoder exists
|
|
||||||
pub fn selectable_formats(&self) -> Vec<VideoEncoderType> {
|
pub fn selectable_formats(&self) -> Vec<VideoEncoderType> {
|
||||||
let mut formats = Vec::new();
|
VideoEncoderType::ordered()
|
||||||
|
.into_iter()
|
||||||
// H264 - supports software fallback
|
.filter(|format| self.is_codec_available(*format))
|
||||||
if self.is_format_available(VideoEncoderType::H264, false) {
|
.collect()
|
||||||
formats.push(VideoEncoderType::H264);
|
|
||||||
}
|
|
||||||
|
|
||||||
// H265/VP8/VP9 - hardware only
|
|
||||||
for format in [
|
|
||||||
VideoEncoderType::H265,
|
|
||||||
VideoEncoderType::VP8,
|
|
||||||
VideoEncoderType::VP9,
|
|
||||||
] {
|
|
||||||
if self.is_format_available(format, true) {
|
|
||||||
formats.push(format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get detection resolution
|
/// Get detection resolution
|
||||||
@@ -534,11 +542,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hardware_only_requirement() {
|
fn test_codec_ordering() {
|
||||||
assert!(!VideoEncoderType::H264.hardware_only());
|
assert_eq!(
|
||||||
assert!(VideoEncoderType::H265.hardware_only());
|
VideoEncoderType::ordered(),
|
||||||
assert!(VideoEncoderType::VP8.hardware_only());
|
[
|
||||||
assert!(VideoEncoderType::VP9.hardware_only());
|
VideoEncoderType::H264,
|
||||||
|
VideoEncoderType::H265,
|
||||||
|
VideoEncoderType::VP8,
|
||||||
|
VideoEncoderType::VP9,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
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::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
|
use super::detect_best_codec_for_format;
|
||||||
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||||
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
@@ -156,32 +157,24 @@ pub fn get_available_vp8_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
|||||||
pub fn detect_best_vp8_encoder(width: u32, height: u32) -> (VP8EncoderType, Option<String>) {
|
pub fn detect_best_vp8_encoder(width: u32, height: u32) -> (VP8EncoderType, Option<String>) {
|
||||||
let encoders = get_available_vp8_encoders(width, height);
|
let encoders = get_available_vp8_encoders(width, height);
|
||||||
|
|
||||||
if encoders.is_empty() {
|
|
||||||
warn!("No VP8 encoders available");
|
|
||||||
return (VP8EncoderType::None, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer hardware encoders (VAAPI) over software (libvpx)
|
// Prefer hardware encoders (VAAPI) over software (libvpx)
|
||||||
let codec = encoders
|
if let Some((encoder_type, codec_name)) =
|
||||||
.iter()
|
detect_best_codec_for_format(&encoders, DataFormat::VP8, |codec| {
|
||||||
.find(|e| e.name.contains("vaapi"))
|
codec.name.contains("vaapi")
|
||||||
.or_else(|| encoders.first())
|
})
|
||||||
.unwrap();
|
{
|
||||||
|
info!("Selected VP8 encoder: {} ({})", codec_name, encoder_type);
|
||||||
let encoder_type = if codec.name.contains("vaapi") {
|
(encoder_type, Some(codec_name))
|
||||||
VP8EncoderType::Vaapi
|
|
||||||
} else {
|
} else {
|
||||||
VP8EncoderType::Software // Default to software for unknown
|
warn!("No VP8 encoders available");
|
||||||
};
|
(VP8EncoderType::None, None)
|
||||||
|
}
|
||||||
info!("Selected VP8 encoder: {} ({})", codec.name, encoder_type);
|
|
||||||
(encoder_type, Some(codec.name.clone()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if VP8 hardware encoding is available
|
/// Check if VP8 hardware encoding is available
|
||||||
pub fn is_vp8_available() -> bool {
|
pub fn is_vp8_available() -> bool {
|
||||||
let registry = EncoderRegistry::global();
|
let registry = EncoderRegistry::global();
|
||||||
registry.is_format_available(VideoEncoderType::VP8, true)
|
registry.is_codec_available(VideoEncoderType::VP8)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encoded frame from hwcodec (cloned for ownership)
|
/// Encoded frame from hwcodec (cloned for ownership)
|
||||||
@@ -192,7 +185,7 @@ pub struct HwEncodeFrame {
|
|||||||
pub key: i32,
|
pub key: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VP8 encoder using hwcodec (hardware only - VAAPI)
|
/// VP8 encoder using hwcodec
|
||||||
pub struct VP8Encoder {
|
pub struct VP8Encoder {
|
||||||
/// hwcodec encoder instance
|
/// hwcodec encoder instance
|
||||||
inner: HwEncoder,
|
inner: HwEncoder,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use hwcodec::ffmpeg::AVPixelFormat;
|
|||||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
|
use super::detect_best_codec_for_format;
|
||||||
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
use super::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||||
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
use super::traits::{EncodedFormat, EncodedFrame, Encoder, EncoderConfig};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
@@ -156,32 +157,24 @@ pub fn get_available_vp9_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
|||||||
pub fn detect_best_vp9_encoder(width: u32, height: u32) -> (VP9EncoderType, Option<String>) {
|
pub fn detect_best_vp9_encoder(width: u32, height: u32) -> (VP9EncoderType, Option<String>) {
|
||||||
let encoders = get_available_vp9_encoders(width, height);
|
let encoders = get_available_vp9_encoders(width, height);
|
||||||
|
|
||||||
if encoders.is_empty() {
|
|
||||||
warn!("No VP9 encoders available");
|
|
||||||
return (VP9EncoderType::None, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer hardware encoders (VAAPI) over software (libvpx-vp9)
|
// Prefer hardware encoders (VAAPI) over software (libvpx-vp9)
|
||||||
let codec = encoders
|
if let Some((encoder_type, codec_name)) =
|
||||||
.iter()
|
detect_best_codec_for_format(&encoders, DataFormat::VP9, |codec| {
|
||||||
.find(|e| e.name.contains("vaapi"))
|
codec.name.contains("vaapi")
|
||||||
.or_else(|| encoders.first())
|
})
|
||||||
.unwrap();
|
{
|
||||||
|
info!("Selected VP9 encoder: {} ({})", codec_name, encoder_type);
|
||||||
let encoder_type = if codec.name.contains("vaapi") {
|
(encoder_type, Some(codec_name))
|
||||||
VP9EncoderType::Vaapi
|
|
||||||
} else {
|
} else {
|
||||||
VP9EncoderType::Software // Default to software for unknown
|
warn!("No VP9 encoders available");
|
||||||
};
|
(VP9EncoderType::None, None)
|
||||||
|
}
|
||||||
info!("Selected VP9 encoder: {} ({})", codec.name, encoder_type);
|
|
||||||
(encoder_type, Some(codec.name.clone()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if VP9 hardware encoding is available
|
/// Check if VP9 hardware encoding is available
|
||||||
pub fn is_vp9_available() -> bool {
|
pub fn is_vp9_available() -> bool {
|
||||||
let registry = EncoderRegistry::global();
|
let registry = EncoderRegistry::global();
|
||||||
registry.is_format_available(VideoEncoderType::VP9, true)
|
registry.is_codec_available(VideoEncoderType::VP9)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encoded frame from hwcodec (cloned for ownership)
|
/// Encoded frame from hwcodec (cloned for ownership)
|
||||||
@@ -192,7 +185,7 @@ pub struct HwEncodeFrame {
|
|||||||
pub key: i32,
|
pub key: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VP9 encoder using hwcodec (hardware only - VAAPI)
|
/// VP9 encoder using hwcodec
|
||||||
pub struct VP9Encoder {
|
pub struct VP9Encoder {
|
||||||
/// hwcodec encoder instance
|
/// hwcodec encoder instance
|
||||||
inner: HwEncoder,
|
inner: HwEncoder,
|
||||||
|
|||||||
@@ -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.
|
//! This module provides V4L2 video capture, encoding, and streaming functionality.
|
||||||
|
|
||||||
pub mod capture;
|
|
||||||
pub mod codec_constraints;
|
pub mod codec_constraints;
|
||||||
pub mod convert;
|
pub mod convert;
|
||||||
pub mod decoder;
|
pub mod decoder;
|
||||||
@@ -10,25 +9,18 @@ pub mod device;
|
|||||||
pub mod encoder;
|
pub mod encoder;
|
||||||
pub mod format;
|
pub mod format;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
pub mod h264_pipeline;
|
|
||||||
pub mod shared_video_pipeline;
|
pub mod shared_video_pipeline;
|
||||||
pub mod stream_manager;
|
pub mod stream_manager;
|
||||||
pub mod streamer;
|
pub mod streamer;
|
||||||
pub mod v4l2r_capture;
|
pub mod v4l2r_capture;
|
||||||
pub mod video_session;
|
|
||||||
|
|
||||||
pub use capture::VideoCapturer;
|
|
||||||
pub use convert::{PixelConverter, Yuv420pBuffer};
|
pub use convert::{PixelConverter, Yuv420pBuffer};
|
||||||
pub use device::{VideoDevice, VideoDeviceInfo};
|
pub use device::{VideoDevice, VideoDeviceInfo};
|
||||||
pub use encoder::{H264Encoder, H264EncoderType, JpegEncoder};
|
pub use encoder::{H264Encoder, H264EncoderType, JpegEncoder};
|
||||||
pub use format::PixelFormat;
|
pub use format::PixelFormat;
|
||||||
pub use frame::VideoFrame;
|
pub use frame::VideoFrame;
|
||||||
pub use h264_pipeline::{H264Pipeline, H264PipelineBuilder, H264PipelineConfig};
|
|
||||||
pub use shared_video_pipeline::{
|
pub use shared_video_pipeline::{
|
||||||
EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
|
EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
|
||||||
};
|
};
|
||||||
pub use stream_manager::VideoStreamManager;
|
pub use stream_manager::VideoStreamManager;
|
||||||
pub use streamer::{Streamer, StreamerState};
|
pub use streamer::{Streamer, StreamerState};
|
||||||
pub use video_session::{
|
|
||||||
CodecInfo, VideoSessionInfo, VideoSessionManager, VideoSessionManagerConfig, VideoSessionState,
|
|
||||||
};
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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
|
//! ├── MJPEG Mode
|
||||||
//! │ └── Streamer ──► MjpegStreamHandler
|
//! │ └── Streamer ──► MjpegStreamHandler
|
||||||
//! │ (Future: MjpegStreamer with WsAudio/WsHid)
|
|
||||||
//! │
|
//! │
|
||||||
//! └── WebRTC Mode
|
//! └── WebRTC Mode
|
||||||
//! └── WebRtcStreamer ──► H264SessionManager
|
//! └── WebRtcStreamer ──► H264SessionManager
|
||||||
@@ -211,21 +210,7 @@ impl VideoStreamManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure WebRTC capture source after initialization
|
self.sync_webrtc_capture_source("after init").await;
|
||||||
let (device_path, resolution, format, fps, jpeg_quality) =
|
|
||||||
self.streamer.current_capture_config().await;
|
|
||||||
info!(
|
|
||||||
"WebRTC capture config after init: {}x{} {:?} @ {}fps",
|
|
||||||
resolution.width, resolution.height, format, fps
|
|
||||||
);
|
|
||||||
self.webrtc_streamer
|
|
||||||
.update_video_config(resolution, format, fps)
|
|
||||||
.await;
|
|
||||||
if let Some(device_path) = device_path {
|
|
||||||
self.webrtc_streamer
|
|
||||||
.set_capture_device(device_path, jpeg_quality)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -351,11 +336,17 @@ impl VideoStreamManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.sync_webrtc_capture_source("for WebRTC ensure").await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_webrtc_capture_source(&self, reason: &str) {
|
||||||
let (device_path, resolution, format, fps, jpeg_quality) =
|
let (device_path, resolution, format, fps, jpeg_quality) =
|
||||||
self.streamer.current_capture_config().await;
|
self.streamer.current_capture_config().await;
|
||||||
info!(
|
info!(
|
||||||
"Configuring WebRTC capture: {}x{} {:?} @ {}fps",
|
"Syncing WebRTC capture source {}: {}x{} {:?} @ {}fps",
|
||||||
resolution.width, resolution.height, format, fps
|
reason, resolution.width, resolution.height, format, fps
|
||||||
);
|
);
|
||||||
self.webrtc_streamer
|
self.webrtc_streamer
|
||||||
.update_video_config(resolution, format, fps)
|
.update_video_config(resolution, format, fps)
|
||||||
@@ -364,9 +355,9 @@ impl VideoStreamManager {
|
|||||||
self.webrtc_streamer
|
self.webrtc_streamer
|
||||||
.set_capture_device(device_path, jpeg_quality)
|
.set_capture_device(device_path, jpeg_quality)
|
||||||
.await;
|
.await;
|
||||||
|
} else {
|
||||||
|
warn!("No capture device configured while syncing WebRTC capture source");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal implementation of mode switching (called with lock held)
|
/// Internal implementation of mode switching (called with lock held)
|
||||||
@@ -471,22 +462,7 @@ impl VideoStreamManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (device_path, resolution, format, fps, jpeg_quality) =
|
self.sync_webrtc_capture_source("for WebRTC mode").await;
|
||||||
self.streamer.current_capture_config().await;
|
|
||||||
info!(
|
|
||||||
"Configuring WebRTC capture pipeline: {}x{} {:?} @ {}fps",
|
|
||||||
resolution.width, resolution.height, format, fps
|
|
||||||
);
|
|
||||||
self.webrtc_streamer
|
|
||||||
.update_video_config(resolution, format, fps)
|
|
||||||
.await;
|
|
||||||
if let Some(device_path) = device_path {
|
|
||||||
self.webrtc_streamer
|
|
||||||
.set_capture_device(device_path, jpeg_quality)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
warn!("No capture device configured for WebRTC");
|
|
||||||
}
|
|
||||||
|
|
||||||
let codec = self.webrtc_streamer.current_video_codec().await;
|
let codec = self.webrtc_streamer.current_video_codec().await;
|
||||||
let is_hardware = self.webrtc_streamer.is_hardware_encoding().await;
|
let is_hardware = self.webrtc_streamer.is_hardware_encoding().await;
|
||||||
@@ -532,17 +508,30 @@ impl VideoStreamManager {
|
|||||||
device_path, format, resolution.width, resolution.height, fps, mode
|
device_path, format, resolution.width, resolution.height, fps, mode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if mode == StreamMode::WebRTC {
|
||||||
|
// Stop the shared pipeline before replacing the capture source so WebRTC
|
||||||
|
// sessions do not stay attached to a stale frame source.
|
||||||
|
self.webrtc_streamer
|
||||||
|
.update_video_config(resolution, format, fps)
|
||||||
|
.await;
|
||||||
|
info!("WebRTC streamer config updated (pipeline stopped, sessions closed)");
|
||||||
|
}
|
||||||
|
|
||||||
// Apply to streamer (handles video capture)
|
// Apply to streamer (handles video capture)
|
||||||
self.streamer
|
self.streamer
|
||||||
.apply_video_config(device_path, format, resolution, fps)
|
.apply_video_config(device_path, format, resolution, fps)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if mode != StreamMode::WebRTC {
|
||||||
|
if let Err(e) = self.start().await {
|
||||||
|
error!("Failed to start streamer after config change: {}", e);
|
||||||
|
} else {
|
||||||
|
info!("Streamer started after config change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update WebRTC config if in WebRTC mode
|
// Update WebRTC config if in WebRTC mode
|
||||||
if mode == StreamMode::WebRTC {
|
if mode == StreamMode::WebRTC {
|
||||||
self.webrtc_streamer
|
|
||||||
.update_video_config(resolution, format, fps)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
|
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
|
||||||
self.streamer.current_capture_config().await;
|
self.streamer.current_capture_config().await;
|
||||||
if actual_format != format || actual_resolution != resolution || actual_fps != fps {
|
if actual_format != format || actual_resolution != resolution || actual_fps != fps {
|
||||||
@@ -590,19 +579,7 @@ impl VideoStreamManager {
|
|||||||
self.streamer.init_auto().await?;
|
self.streamer.init_auto().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronize WebRTC config with current capture config
|
self.sync_webrtc_capture_source("before start").await;
|
||||||
let (device_path, resolution, format, fps, jpeg_quality) =
|
|
||||||
self.streamer.current_capture_config().await;
|
|
||||||
self.webrtc_streamer
|
|
||||||
.update_video_config(resolution, format, fps)
|
|
||||||
.await;
|
|
||||||
if let Some(device_path) = device_path {
|
|
||||||
self.webrtc_streamer
|
|
||||||
.set_capture_device(device_path, jpeg_quality)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
warn!("No capture device configured for WebRTC");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,24 +724,10 @@ impl VideoStreamManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Synchronize WebRTC config with capture config
|
// 2. Synchronize WebRTC config with capture config
|
||||||
let (device_path, resolution, format, fps, jpeg_quality) =
|
let (device_path, _, _, _, _) = self.streamer.current_capture_config().await;
|
||||||
self.streamer.current_capture_config().await;
|
self.sync_webrtc_capture_source("for encoded frame subscription")
|
||||||
tracing::info!(
|
|
||||||
"Connecting encoded frame subscription: {}x{} {:?} @ {}fps",
|
|
||||||
resolution.width,
|
|
||||||
resolution.height,
|
|
||||||
format,
|
|
||||||
fps
|
|
||||||
);
|
|
||||||
self.webrtc_streamer
|
|
||||||
.update_video_config(resolution, format, fps)
|
|
||||||
.await;
|
.await;
|
||||||
if let Some(device_path) = device_path {
|
if device_path.is_none() {
|
||||||
self.webrtc_streamer
|
|
||||||
.set_capture_device(device_path, jpeg_quality)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("No capture device configured for encoded frames");
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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::config::*;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::SystemEvent;
|
|
||||||
use crate::rtsp::RtspService;
|
use crate::rtsp::RtspService;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::video::codec_constraints::{
|
use crate::video::codec_constraints::{
|
||||||
enforce_constraints_with_stream_manager, StreamCodecConstraints,
|
enforce_constraints_with_stream_manager, StreamCodecConstraints,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType {
|
||||||
|
match config.backend {
|
||||||
|
HidBackend::Otg => crate::hid::HidBackendType::Otg,
|
||||||
|
HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
|
||||||
|
port: config.ch9329_port.clone(),
|
||||||
|
baud_rate: config.ch9329_baudrate,
|
||||||
|
},
|
||||||
|
HidBackend::None => crate::hid::HidBackendType::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconcile_otg_from_store(state: &Arc<AppState>) -> Result<()> {
|
||||||
|
let config = state.config.get();
|
||||||
|
state
|
||||||
|
.otg_service
|
||||||
|
.apply_config(&config.hid, &config.msd)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
/// 应用 Video 配置变更
|
/// 应用 Video 配置变更
|
||||||
pub async fn apply_video_config(
|
pub async fn apply_video_config(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
@@ -45,73 +64,11 @@ pub async fn apply_video_config(
|
|||||||
|
|
||||||
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
|
let resolution = crate::video::format::Resolution::new(new_config.width, new_config.height);
|
||||||
|
|
||||||
// Step 1: 更新 WebRTC streamer 配置(停止现有 pipeline 和 sessions)
|
|
||||||
state
|
state
|
||||||
.stream_manager
|
.stream_manager
|
||||||
.webrtc_streamer()
|
|
||||||
.update_video_config(resolution, format, new_config.fps)
|
|
||||||
.await;
|
|
||||||
tracing::info!("WebRTC streamer config updated");
|
|
||||||
|
|
||||||
// Step 2: 应用视频配置到 streamer(重新创建 capturer)
|
|
||||||
state
|
|
||||||
.stream_manager
|
|
||||||
.streamer()
|
|
||||||
.apply_video_config(&device, format, resolution, new_config.fps)
|
.apply_video_config(&device, format, resolution, new_config.fps)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to apply video config: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("Failed to apply video config: {}", e)))?;
|
||||||
tracing::info!("Video config applied to streamer");
|
|
||||||
|
|
||||||
// Step 3: 重启 streamer(仅 MJPEG 模式)
|
|
||||||
if !state.stream_manager.is_webrtc_enabled().await {
|
|
||||||
if let Err(e) = state.stream_manager.start().await {
|
|
||||||
tracing::error!("Failed to start streamer after config change: {}", e);
|
|
||||||
} else {
|
|
||||||
tracing::info!("Streamer started after config change");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 配置 WebRTC direct capture(所有模式统一配置)
|
|
||||||
let (device_path, _resolution, _format, _fps, jpeg_quality) = state
|
|
||||||
.stream_manager
|
|
||||||
.streamer()
|
|
||||||
.current_capture_config()
|
|
||||||
.await;
|
|
||||||
if let Some(device_path) = device_path {
|
|
||||||
state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.set_capture_device(device_path, jpeg_quality)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("No capture device configured for WebRTC");
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.stream_manager.is_webrtc_enabled().await {
|
|
||||||
use crate::video::encoder::VideoCodecType;
|
|
||||||
let codec = state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.current_video_codec()
|
|
||||||
.await;
|
|
||||||
let codec_str = match codec {
|
|
||||||
VideoCodecType::H264 => "h264",
|
|
||||||
VideoCodecType::H265 => "h265",
|
|
||||||
VideoCodecType::VP8 => "vp8",
|
|
||||||
VideoCodecType::VP9 => "vp9",
|
|
||||||
}
|
|
||||||
.to_string();
|
|
||||||
let is_hardware = state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.is_hardware_encoding()
|
|
||||||
.await;
|
|
||||||
state.events.publish(SystemEvent::WebRTCReady {
|
|
||||||
transition_id: None,
|
|
||||||
codec: codec_str,
|
|
||||||
hardware: is_hardware,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("Video config applied successfully");
|
tracing::info!("Video config applied successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -188,56 +145,26 @@ pub async fn apply_hid_config(
|
|||||||
old_config: &HidConfig,
|
old_config: &HidConfig,
|
||||||
new_config: &HidConfig,
|
new_config: &HidConfig,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// 检查 OTG 描述符是否变更
|
let current_msd_enabled = state.config.get().msd.enabled;
|
||||||
|
new_config.validate_otg_endpoint_budget(current_msd_enabled)?;
|
||||||
|
|
||||||
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
|
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
|
||||||
let old_hid_functions = old_config.effective_otg_functions();
|
let old_hid_functions = old_config.constrained_otg_functions();
|
||||||
let mut new_hid_functions = new_config.effective_otg_functions();
|
let new_hid_functions = new_config.constrained_otg_functions();
|
||||||
|
|
||||||
// Low-endpoint UDCs (e.g., musb) cannot handle consumer control endpoints reliably
|
|
||||||
if new_config.backend == HidBackend::Otg {
|
|
||||||
if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.otg_udc.as_deref()) {
|
|
||||||
if crate::otg::configfs::is_low_endpoint_udc(&udc) && new_hid_functions.consumer {
|
|
||||||
tracing::warn!(
|
|
||||||
"UDC {} has low endpoint resources, disabling consumer control",
|
|
||||||
udc
|
|
||||||
);
|
|
||||||
new_hid_functions.consumer = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let hid_functions_changed = old_hid_functions != new_hid_functions;
|
let hid_functions_changed = old_hid_functions != new_hid_functions;
|
||||||
|
let keyboard_leds_changed =
|
||||||
|
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
|
||||||
|
let endpoint_budget_changed =
|
||||||
|
old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit();
|
||||||
|
|
||||||
if new_config.backend == HidBackend::Otg && new_hid_functions.is_empty() {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"OTG HID functions cannot be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果描述符变更且当前使用 OTG 后端,需要重建 Gadget
|
|
||||||
if descriptor_changed && new_config.backend == HidBackend::Otg {
|
|
||||||
tracing::info!("OTG descriptor changed, updating gadget...");
|
|
||||||
if let Err(e) = state
|
|
||||||
.otg_service
|
|
||||||
.update_descriptor(&new_config.otg_descriptor)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!("Failed to update OTG descriptor: {}", e);
|
|
||||||
return Err(AppError::Config(format!(
|
|
||||||
"OTG descriptor update failed: {}",
|
|
||||||
e
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
tracing::info!("OTG descriptor updated successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否需要重载 HID 后端
|
|
||||||
if old_config.backend == new_config.backend
|
if old_config.backend == new_config.backend
|
||||||
&& old_config.ch9329_port == new_config.ch9329_port
|
&& old_config.ch9329_port == new_config.ch9329_port
|
||||||
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
|
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
|
||||||
&& old_config.otg_udc == new_config.otg_udc
|
&& old_config.otg_udc == new_config.otg_udc
|
||||||
&& !descriptor_changed
|
&& !descriptor_changed
|
||||||
&& !hid_functions_changed
|
&& !hid_functions_changed
|
||||||
|
&& !keyboard_leds_changed
|
||||||
|
&& !endpoint_budget_changed
|
||||||
{
|
{
|
||||||
tracing::info!("HID config unchanged, skipping reload");
|
tracing::info!("HID config unchanged, skipping reload");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -245,30 +172,27 @@ pub async fn apply_hid_config(
|
|||||||
|
|
||||||
tracing::info!("Applying HID config changes...");
|
tracing::info!("Applying HID config changes...");
|
||||||
|
|
||||||
if new_config.backend == HidBackend::Otg
|
let new_hid_backend = hid_backend_type(new_config);
|
||||||
&& (hid_functions_changed || old_config.backend != HidBackend::Otg)
|
let transitioning_away_from_otg =
|
||||||
{
|
old_config.backend == HidBackend::Otg && new_config.backend != HidBackend::Otg;
|
||||||
|
|
||||||
|
if transitioning_away_from_otg {
|
||||||
state
|
state
|
||||||
.otg_service
|
.hid
|
||||||
.update_hid_functions(new_hid_functions.clone())
|
.reload(new_hid_backend.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Config(format!("OTG HID function update failed: {}", e)))?;
|
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_hid_backend = match new_config.backend {
|
reconcile_otg_from_store(state).await?;
|
||||||
HidBackend::Otg => crate::hid::HidBackendType::Otg,
|
|
||||||
HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
|
|
||||||
port: new_config.ch9329_port.clone(),
|
|
||||||
baud_rate: new_config.ch9329_baudrate,
|
|
||||||
},
|
|
||||||
HidBackend::None => crate::hid::HidBackendType::None,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
if !transitioning_away_from_otg {
|
||||||
state
|
state
|
||||||
.hid
|
.hid
|
||||||
.reload(new_hid_backend)
|
.reload(new_hid_backend)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
|
.map_err(|e| AppError::Config(format!("HID reload failed: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"HID backend reloaded successfully: {:?}",
|
"HID backend reloaded successfully: {:?}",
|
||||||
@@ -284,6 +208,12 @@ pub async fn apply_msd_config(
|
|||||||
old_config: &MsdConfig,
|
old_config: &MsdConfig,
|
||||||
new_config: &MsdConfig,
|
new_config: &MsdConfig,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.get()
|
||||||
|
.hid
|
||||||
|
.validate_otg_endpoint_budget(new_config.enabled)?;
|
||||||
|
|
||||||
tracing::info!("MSD config sent, checking if reload needed...");
|
tracing::info!("MSD config sent, checking if reload needed...");
|
||||||
tracing::debug!("Old MSD config: {:?}", old_config);
|
tracing::debug!("Old MSD config: {:?}", old_config);
|
||||||
tracing::debug!("New MSD config: {:?}", new_config);
|
tracing::debug!("New MSD config: {:?}", new_config);
|
||||||
@@ -323,6 +253,8 @@ pub async fn apply_msd_config(
|
|||||||
if new_msd_enabled {
|
if new_msd_enabled {
|
||||||
tracing::info!("(Re)initializing MSD...");
|
tracing::info!("(Re)initializing MSD...");
|
||||||
|
|
||||||
|
reconcile_otg_from_store(state).await?;
|
||||||
|
|
||||||
// Shutdown existing controller if present
|
// Shutdown existing controller if present
|
||||||
let mut msd_guard = state.msd.write().await;
|
let mut msd_guard = state.msd.write().await;
|
||||||
if let Some(msd) = msd_guard.as_mut() {
|
if let Some(msd) = msd_guard.as_mut() {
|
||||||
@@ -358,6 +290,17 @@ pub async fn apply_msd_config(
|
|||||||
}
|
}
|
||||||
*msd_guard = None;
|
*msd_guard = None;
|
||||||
tracing::info!("MSD shutdown complete");
|
tracing::info!("MSD shutdown complete");
|
||||||
|
|
||||||
|
reconcile_otg_from_store(state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_config = state.config.get();
|
||||||
|
if current_config.hid.backend == HidBackend::Otg && old_msd_enabled != new_msd_enabled {
|
||||||
|
state
|
||||||
|
.hid
|
||||||
|
.reload(crate::hid::HidBackendType::Otg)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Config(format!("OTG HID reload failed: {}", e)))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -307,7 +307,9 @@ pub struct HidConfigUpdate {
|
|||||||
pub otg_udc: Option<String>,
|
pub otg_udc: Option<String>,
|
||||||
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
||||||
pub otg_profile: Option<OtgHidProfile>,
|
pub otg_profile: Option<OtgHidProfile>,
|
||||||
|
pub otg_endpoint_budget: Option<OtgEndpointBudget>,
|
||||||
pub otg_functions: Option<OtgHidFunctionsUpdate>,
|
pub otg_functions: Option<OtgHidFunctionsUpdate>,
|
||||||
|
pub otg_keyboard_leds: Option<bool>,
|
||||||
pub mouse_absolute: Option<bool>,
|
pub mouse_absolute: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,9 +348,15 @@ impl HidConfigUpdate {
|
|||||||
if let Some(profile) = self.otg_profile.clone() {
|
if let Some(profile) = self.otg_profile.clone() {
|
||||||
config.otg_profile = profile;
|
config.otg_profile = profile;
|
||||||
}
|
}
|
||||||
|
if let Some(budget) = self.otg_endpoint_budget {
|
||||||
|
config.otg_endpoint_budget = budget;
|
||||||
|
}
|
||||||
if let Some(ref functions) = self.otg_functions {
|
if let Some(ref functions) = self.otg_functions {
|
||||||
functions.apply_to(&mut config.otg_functions);
|
functions.apply_to(&mut config.otg_functions);
|
||||||
}
|
}
|
||||||
|
if let Some(enabled) = self.otg_keyboard_leds {
|
||||||
|
config.otg_keyboard_leds = enabled;
|
||||||
|
}
|
||||||
if let Some(absolute) = self.mouse_absolute {
|
if let Some(absolute) = self.mouse_absolute {
|
||||||
config.mouse_absolute = absolute;
|
config.mouse_absolute = absolute;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use axum::{
|
|||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
@@ -324,27 +324,3 @@ pub async fn update_easytier_config(
|
|||||||
|
|
||||||
Ok(Json(new_config.extensions.easytier.clone()))
|
Ok(Json(new_config.extensions.easytier.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Ttyd status for console (simplified)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Simple ttyd status for console view
|
|
||||||
#[typeshare]
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct TtydStatus {
|
|
||||||
pub available: bool,
|
|
||||||
pub running: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get ttyd status for console view
|
|
||||||
/// GET /api/extensions/ttyd/status
|
|
||||||
pub async fn get_ttyd_status(State(state): State<Arc<AppState>>) -> Json<TtydStatus> {
|
|
||||||
let mgr = &state.extensions;
|
|
||||||
let status = mgr.status(ExtensionId::Ttyd).await;
|
|
||||||
|
|
||||||
Json(TtydStatus {
|
|
||||||
available: mgr.check_available(ExtensionId::Ttyd),
|
|
||||||
running: status.is_running(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ use tracing::{info, warn};
|
|||||||
use crate::auth::{Session, SESSION_COOKIE};
|
use crate::auth::{Session, SESSION_COOKIE};
|
||||||
use crate::config::{AppConfig, StreamMode};
|
use crate::config::{AppConfig, StreamMode};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::SystemEvent;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
|
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
|
||||||
use crate::video::codec_constraints::codec_to_id;
|
use crate::video::codec_constraints::codec_to_id;
|
||||||
use crate::video::encoder::BitratePreset;
|
use crate::video::encoder::{
|
||||||
|
build_hardware_self_check_runtime_error, run_hardware_self_check, BitratePreset,
|
||||||
|
VideoEncoderSelfCheckResponse,
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Health & Info
|
// Health & Info
|
||||||
@@ -596,38 +598,14 @@ pub struct SetupRequest {
|
|||||||
pub hid_ch9329_baudrate: Option<u32>,
|
pub hid_ch9329_baudrate: Option<u32>,
|
||||||
pub hid_otg_udc: Option<String>,
|
pub hid_otg_udc: Option<String>,
|
||||||
pub hid_otg_profile: Option<String>,
|
pub hid_otg_profile: Option<String>,
|
||||||
|
pub hid_otg_endpoint_budget: Option<crate::config::OtgEndpointBudget>,
|
||||||
|
pub hid_otg_keyboard_leds: Option<bool>,
|
||||||
|
pub msd_enabled: Option<bool>,
|
||||||
// Extension settings
|
// Extension settings
|
||||||
pub ttyd_enabled: Option<bool>,
|
pub ttyd_enabled: Option<bool>,
|
||||||
pub rustdesk_enabled: Option<bool>,
|
pub rustdesk_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_otg_profile_for_low_endpoint(config: &mut AppConfig) {
|
|
||||||
if !matches!(config.hid.backend, crate::config::HidBackend::Otg) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let udc = crate::otg::configfs::resolve_udc_name(config.hid.otg_udc.as_deref());
|
|
||||||
let Some(udc) = udc else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !crate::otg::configfs::is_low_endpoint_udc(&udc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match config.hid.otg_profile {
|
|
||||||
crate::config::OtgHidProfile::Full => {
|
|
||||||
config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumer;
|
|
||||||
}
|
|
||||||
crate::config::OtgHidProfile::FullNoMsd => {
|
|
||||||
config.hid.otg_profile = crate::config::OtgHidProfile::FullNoConsumerNoMsd;
|
|
||||||
}
|
|
||||||
crate::config::OtgHidProfile::Custom => {
|
|
||||||
if config.hid.otg_functions.consumer {
|
|
||||||
config.hid.otg_functions.consumer = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn setup_init(
|
pub async fn setup_init(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<SetupRequest>,
|
Json(req): Json<SetupRequest>,
|
||||||
@@ -701,31 +679,18 @@ pub async fn setup_init(
|
|||||||
config.hid.otg_udc = Some(udc);
|
config.hid.otg_udc = Some(udc);
|
||||||
}
|
}
|
||||||
if let Some(profile) = req.hid_otg_profile.clone() {
|
if let Some(profile) = req.hid_otg_profile.clone() {
|
||||||
config.hid.otg_profile = match profile.as_str() {
|
if let Some(parsed) = crate::config::OtgHidProfile::from_legacy_str(&profile) {
|
||||||
"full" => crate::config::OtgHidProfile::Full,
|
config.hid.otg_profile = parsed;
|
||||||
"full_no_msd" => crate::config::OtgHidProfile::FullNoMsd,
|
|
||||||
"full_no_consumer" => crate::config::OtgHidProfile::FullNoConsumer,
|
|
||||||
"full_no_consumer_no_msd" => crate::config::OtgHidProfile::FullNoConsumerNoMsd,
|
|
||||||
"legacy_keyboard" => crate::config::OtgHidProfile::LegacyKeyboard,
|
|
||||||
"legacy_mouse_relative" => crate::config::OtgHidProfile::LegacyMouseRelative,
|
|
||||||
"custom" => crate::config::OtgHidProfile::Custom,
|
|
||||||
_ => config.hid.otg_profile.clone(),
|
|
||||||
};
|
|
||||||
if matches!(config.hid.backend, crate::config::HidBackend::Otg) {
|
|
||||||
match config.hid.otg_profile {
|
|
||||||
crate::config::OtgHidProfile::Full
|
|
||||||
| crate::config::OtgHidProfile::FullNoConsumer => {
|
|
||||||
config.msd.enabled = true;
|
|
||||||
}
|
|
||||||
crate::config::OtgHidProfile::FullNoMsd
|
|
||||||
| crate::config::OtgHidProfile::FullNoConsumerNoMsd
|
|
||||||
| crate::config::OtgHidProfile::LegacyKeyboard
|
|
||||||
| crate::config::OtgHidProfile::LegacyMouseRelative => {
|
|
||||||
config.msd.enabled = false;
|
|
||||||
}
|
|
||||||
crate::config::OtgHidProfile::Custom => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(budget) = req.hid_otg_endpoint_budget {
|
||||||
|
config.hid.otg_endpoint_budget = budget;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = req.hid_otg_keyboard_leds {
|
||||||
|
config.hid.otg_keyboard_leds = enabled;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = req.msd_enabled {
|
||||||
|
config.msd.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extension settings
|
// Extension settings
|
||||||
@@ -735,29 +700,18 @@ pub async fn setup_init(
|
|||||||
if let Some(enabled) = req.rustdesk_enabled {
|
if let Some(enabled) = req.rustdesk_enabled {
|
||||||
config.rustdesk.enabled = enabled;
|
config.rustdesk.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
normalize_otg_profile_for_low_endpoint(config);
|
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Get updated config for HID reload
|
// Get updated config for HID reload
|
||||||
let new_config = state.config.get();
|
let new_config = state.config.get();
|
||||||
|
|
||||||
if matches!(new_config.hid.backend, crate::config::HidBackend::Otg) {
|
if let Err(e) = state
|
||||||
let mut hid_functions = new_config.hid.effective_otg_functions();
|
.otg_service
|
||||||
if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref())
|
.apply_config(&new_config.hid, &new_config.msd)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
if crate::otg::configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer {
|
tracing::warn!("Failed to apply OTG config during setup: {}", e);
|
||||||
tracing::warn!(
|
|
||||||
"UDC {} has low endpoint resources, disabling consumer control",
|
|
||||||
udc
|
|
||||||
);
|
|
||||||
hid_functions.consumer = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(e) = state.otg_service.update_hid_functions(hid_functions).await {
|
|
||||||
tracing::warn!("Failed to apply HID functions during setup: {}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -879,8 +833,10 @@ pub async fn update_config(
|
|||||||
let new_config: AppConfig = serde_json::from_value(merged)
|
let new_config: AppConfig = serde_json::from_value(merged)
|
||||||
.map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?;
|
.map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?;
|
||||||
|
|
||||||
let mut new_config = new_config;
|
let new_config = new_config;
|
||||||
normalize_otg_profile_for_low_endpoint(&mut new_config);
|
new_config
|
||||||
|
.hid
|
||||||
|
.validate_otg_endpoint_budget(new_config.msd.enabled)?;
|
||||||
|
|
||||||
// Apply the validated config
|
// Apply the validated config
|
||||||
state.config.set(new_config.clone()).await?;
|
state.config.set(new_config.clone()).await?;
|
||||||
@@ -908,297 +864,76 @@ pub async fn update_config(
|
|||||||
// Get new config for device reloading
|
// Get new config for device reloading
|
||||||
let new_config = state.config.get();
|
let new_config = state.config.get();
|
||||||
|
|
||||||
// Video config processing - always reload if section was sent
|
|
||||||
if has_video {
|
if has_video {
|
||||||
tracing::info!("Video config sent, applying settings...");
|
if let Err(e) =
|
||||||
|
config::apply::apply_video_config(&state, &old_config.video, &new_config.video).await
|
||||||
let device = new_config
|
|
||||||
.video
|
|
||||||
.device
|
|
||||||
.clone()
|
|
||||||
.ok_or_else(|| AppError::BadRequest("video_device is required".to_string()))?;
|
|
||||||
|
|
||||||
// Map to PixelFormat/Resolution
|
|
||||||
let format = new_config
|
|
||||||
.video
|
|
||||||
.format
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|f| {
|
|
||||||
serde_json::from_value::<crate::video::format::PixelFormat>(
|
|
||||||
serde_json::Value::String(f.clone()),
|
|
||||||
)
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.unwrap_or(crate::video::format::PixelFormat::Mjpeg);
|
|
||||||
let resolution =
|
|
||||||
crate::video::format::Resolution::new(new_config.video.width, new_config.video.height);
|
|
||||||
|
|
||||||
// Step 1: Update WebRTC streamer config FIRST
|
|
||||||
// This stops the shared pipeline and closes existing sessions BEFORE capturer is recreated
|
|
||||||
// This ensures the pipeline won't be subscribed to a stale frame source
|
|
||||||
state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.update_video_config(resolution, format, new_config.video.fps)
|
|
||||||
.await;
|
|
||||||
tracing::info!("WebRTC streamer config updated (pipeline stopped, sessions closed)");
|
|
||||||
|
|
||||||
// Step 2: Apply video config to streamer (recreates capturer)
|
|
||||||
if let Err(e) = state
|
|
||||||
.stream_manager
|
|
||||||
.streamer()
|
|
||||||
.apply_video_config(&device, format, resolution, new_config.video.fps)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
tracing::error!("Failed to apply video config: {}", e);
|
tracing::error!("Failed to apply video config: {}", e);
|
||||||
// Rollback config on failure
|
|
||||||
state.config.set((*old_config).clone()).await?;
|
state.config.set((*old_config).clone()).await?;
|
||||||
return Ok(Json(LoginResponse {
|
return Ok(Json(LoginResponse {
|
||||||
success: false,
|
success: false,
|
||||||
message: Some(format!("Video configuration invalid: {}", e)),
|
message: Some(format!("Video configuration invalid: {}", e)),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
tracing::info!("Video config applied successfully");
|
|
||||||
|
|
||||||
// Step 3: Start the streamer to begin capturing frames (MJPEG mode only)
|
|
||||||
if !state.stream_manager.is_webrtc_enabled().await {
|
|
||||||
// This is necessary because apply_video_config only creates the capturer but doesn't start it
|
|
||||||
if let Err(e) = state.stream_manager.start().await {
|
|
||||||
tracing::error!("Failed to start streamer after config change: {}", e);
|
|
||||||
// Don't fail the request - the stream might start later when client connects
|
|
||||||
} else {
|
|
||||||
tracing::info!("Streamer started after config change");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure WebRTC direct capture (all modes)
|
|
||||||
let (device_path, _resolution, _format, _fps, jpeg_quality) = state
|
|
||||||
.stream_manager
|
|
||||||
.streamer()
|
|
||||||
.current_capture_config()
|
|
||||||
.await;
|
|
||||||
if let Some(device_path) = device_path {
|
|
||||||
state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.set_capture_device(device_path, jpeg_quality)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("No capture device configured for WebRTC");
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.stream_manager.is_webrtc_enabled().await {
|
|
||||||
use crate::video::encoder::VideoCodecType;
|
|
||||||
let codec = state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.current_video_codec()
|
|
||||||
.await;
|
|
||||||
let codec_str = match codec {
|
|
||||||
VideoCodecType::H264 => "h264",
|
|
||||||
VideoCodecType::H265 => "h265",
|
|
||||||
VideoCodecType::VP8 => "vp8",
|
|
||||||
VideoCodecType::VP9 => "vp9",
|
|
||||||
}
|
|
||||||
.to_string();
|
|
||||||
let is_hardware = state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.is_hardware_encoding()
|
|
||||||
.await;
|
|
||||||
state.events.publish(SystemEvent::WebRTCReady {
|
|
||||||
transition_id: None,
|
|
||||||
codec: codec_str,
|
|
||||||
hardware: is_hardware,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream config processing (encoder backend, bitrate, etc.)
|
|
||||||
if has_stream {
|
if has_stream {
|
||||||
tracing::info!("Stream config sent, applying encoder settings...");
|
if let Err(e) =
|
||||||
|
config::apply::apply_stream_config(&state, &old_config.stream, &new_config.stream).await
|
||||||
// Update WebRTC streamer encoder backend
|
{
|
||||||
let encoder_backend = new_config.stream.encoder.to_backend();
|
tracing::error!("Failed to apply stream config: {}", e);
|
||||||
tracing::info!(
|
state.config.set((*old_config).clone()).await?;
|
||||||
"Updating encoder backend to: {:?} (from config: {:?})",
|
return Ok(Json(LoginResponse {
|
||||||
encoder_backend,
|
success: false,
|
||||||
new_config.stream.encoder
|
message: Some(format!("Stream configuration invalid: {}", e)),
|
||||||
);
|
}));
|
||||||
|
}
|
||||||
state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.update_encoder_backend(encoder_backend)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Update bitrate if changed
|
|
||||||
state
|
|
||||||
.stream_manager
|
|
||||||
.webrtc_streamer()
|
|
||||||
.set_bitrate_preset(new_config.stream.bitrate_preset)
|
|
||||||
.await
|
|
||||||
.ok(); // Ignore error if no active stream
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"Stream config applied: encoder={:?}, bitrate={}",
|
|
||||||
new_config.stream.encoder,
|
|
||||||
new_config.stream.bitrate_preset
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HID config processing - always reload if section was sent
|
|
||||||
if has_hid {
|
if has_hid {
|
||||||
tracing::info!("HID config sent, reloading HID backend...");
|
if let Err(e) =
|
||||||
|
config::apply::apply_hid_config(&state, &old_config.hid, &new_config.hid).await
|
||||||
// Determine new backend type
|
{
|
||||||
let new_hid_backend = match new_config.hid.backend {
|
|
||||||
crate::config::HidBackend::Otg => crate::hid::HidBackendType::Otg,
|
|
||||||
crate::config::HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
|
|
||||||
port: new_config.hid.ch9329_port.clone(),
|
|
||||||
baud_rate: new_config.hid.ch9329_baudrate,
|
|
||||||
},
|
|
||||||
crate::config::HidBackend::None => crate::hid::HidBackendType::None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reload HID backend - return success=false on error
|
|
||||||
if let Err(e) = state.hid.reload(new_hid_backend).await {
|
|
||||||
tracing::error!("HID reload failed: {}", e);
|
tracing::error!("HID reload failed: {}", e);
|
||||||
// Rollback config on failure
|
|
||||||
state.config.set((*old_config).clone()).await?;
|
state.config.set((*old_config).clone()).await?;
|
||||||
return Ok(Json(LoginResponse {
|
return Ok(Json(LoginResponse {
|
||||||
success: false,
|
success: false,
|
||||||
message: Some(format!("HID configuration invalid: {}", e)),
|
message: Some(format!("HID configuration invalid: {}", e)),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"HID backend reloaded successfully: {:?}",
|
|
||||||
new_config.hid.backend
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio config processing - always reload if section was sent
|
|
||||||
if has_audio {
|
if has_audio {
|
||||||
tracing::info!("Audio config sent, applying settings...");
|
if let Err(e) =
|
||||||
|
config::apply::apply_audio_config(&state, &old_config.audio, &new_config.audio).await
|
||||||
// Create audio controller config from new config
|
|
||||||
let audio_config = crate::audio::AudioControllerConfig {
|
|
||||||
enabled: new_config.audio.enabled,
|
|
||||||
device: new_config.audio.device.clone(),
|
|
||||||
quality: crate::audio::AudioQuality::from_str(&new_config.audio.quality),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update audio controller
|
|
||||||
if let Err(e) = state.audio.update_config(audio_config).await {
|
|
||||||
tracing::error!("Audio config update failed: {}", e);
|
|
||||||
// Don't rollback config for audio errors - it's not critical
|
|
||||||
// Just log the error
|
|
||||||
} else {
|
|
||||||
tracing::info!(
|
|
||||||
"Audio config applied: enabled={}, device={}",
|
|
||||||
new_config.audio.enabled,
|
|
||||||
new_config.audio.device
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also update WebRTC audio enabled state
|
|
||||||
if let Err(e) = state
|
|
||||||
.stream_manager
|
|
||||||
.set_webrtc_audio_enabled(new_config.audio.enabled)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
tracing::warn!("Failed to update WebRTC audio state: {}", e);
|
tracing::warn!("Audio config update failed: {}", e);
|
||||||
} else {
|
|
||||||
tracing::info!("WebRTC audio enabled: {}", new_config.audio.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconnect audio sources for existing WebRTC sessions
|
|
||||||
// This is needed because the audio controller was restarted with new config
|
|
||||||
if new_config.audio.enabled {
|
|
||||||
state.stream_manager.reconnect_webrtc_audio_sources().await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MSD config processing - reload if enabled state or directory changed
|
|
||||||
if has_msd {
|
if has_msd {
|
||||||
tracing::info!("MSD config sent, checking if reload needed...");
|
if let Err(e) =
|
||||||
tracing::debug!("Old MSD config: {:?}", old_config.msd);
|
config::apply::apply_msd_config(&state, &old_config.msd, &new_config.msd).await
|
||||||
tracing::debug!("New MSD config: {:?}", new_config.msd);
|
{
|
||||||
|
|
||||||
let old_msd_enabled = old_config.msd.enabled;
|
|
||||||
let new_msd_enabled = new_config.msd.enabled;
|
|
||||||
let msd_dir_changed = old_config.msd.msd_dir != new_config.msd.msd_dir;
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"MSD enabled: old={}, new={}",
|
|
||||||
old_msd_enabled,
|
|
||||||
new_msd_enabled
|
|
||||||
);
|
|
||||||
if msd_dir_changed {
|
|
||||||
tracing::info!("MSD directory changed: {}", new_config.msd.msd_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure MSD directories exist (msd/images, msd/ventoy)
|
|
||||||
let msd_dir = new_config.msd.msd_dir_path();
|
|
||||||
if let Err(e) = std::fs::create_dir_all(msd_dir.join("images")) {
|
|
||||||
tracing::warn!("Failed to create MSD images directory: {}", e);
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::create_dir_all(msd_dir.join("ventoy")) {
|
|
||||||
tracing::warn!("Failed to create MSD ventoy directory: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
let needs_reload = old_msd_enabled != new_msd_enabled || msd_dir_changed;
|
|
||||||
if !needs_reload {
|
|
||||||
tracing::info!(
|
|
||||||
"MSD enabled state unchanged ({}) and directory unchanged, no reload needed",
|
|
||||||
new_msd_enabled
|
|
||||||
);
|
|
||||||
} else if new_msd_enabled {
|
|
||||||
tracing::info!("(Re)initializing MSD...");
|
|
||||||
|
|
||||||
// Shutdown existing controller if present
|
|
||||||
let mut msd_guard = state.msd.write().await;
|
|
||||||
if let Some(msd) = msd_guard.as_mut() {
|
|
||||||
if let Err(e) = msd.shutdown().await {
|
|
||||||
tracing::warn!("MSD shutdown failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*msd_guard = None;
|
|
||||||
drop(msd_guard);
|
|
||||||
|
|
||||||
let msd = crate::msd::MsdController::new(
|
|
||||||
state.otg_service.clone(),
|
|
||||||
new_config.msd.msd_dir_path(),
|
|
||||||
);
|
|
||||||
if let Err(e) = msd.init().await {
|
|
||||||
tracing::error!("MSD initialization failed: {}", e);
|
tracing::error!("MSD initialization failed: {}", e);
|
||||||
// Rollback config on failure
|
|
||||||
state.config.set((*old_config).clone()).await?;
|
state.config.set((*old_config).clone()).await?;
|
||||||
return Ok(Json(LoginResponse {
|
return Ok(Json(LoginResponse {
|
||||||
success: false,
|
success: false,
|
||||||
message: Some(format!("MSD initialization failed: {}", e)),
|
message: Some(format!("MSD initialization failed: {}", e)),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set event bus
|
|
||||||
let events = state.events.clone();
|
|
||||||
msd.set_event_bus(events).await;
|
|
||||||
|
|
||||||
// Store the initialized controller
|
|
||||||
*state.msd.write().await = Some(msd);
|
|
||||||
tracing::info!("MSD initialized successfully");
|
|
||||||
} else {
|
|
||||||
tracing::info!("MSD disabled in config, shutting down...");
|
|
||||||
|
|
||||||
let mut msd_guard = state.msd.write().await;
|
|
||||||
if let Some(msd) = msd_guard.as_mut() {
|
|
||||||
if let Err(e) = msd.shutdown().await {
|
|
||||||
tracing::warn!("MSD shutdown failed: {}", e);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
*msd_guard = None;
|
if has_atx {
|
||||||
tracing::info!("MSD shutdown complete");
|
if let Err(e) =
|
||||||
|
config::apply::apply_atx_config(&state, &old_config.atx, &new_config.atx).await
|
||||||
|
{
|
||||||
|
tracing::error!("ATX configuration invalid: {}", e);
|
||||||
|
state.config.set((*old_config).clone()).await?;
|
||||||
|
return Ok(Json(LoginResponse {
|
||||||
|
success: false,
|
||||||
|
message: Some(format!("ATX configuration invalid: {}", e)),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1798,7 +1533,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check H264 availability (supports software fallback)
|
// Check H264 availability (supports software fallback)
|
||||||
let h264_encoder = registry.best_encoder(VideoEncoderType::H264, false);
|
let h264_encoder = registry.best_available_encoder(VideoEncoderType::H264);
|
||||||
codecs.push(VideoCodecInfo {
|
codecs.push(VideoCodecInfo {
|
||||||
id: "h264".to_string(),
|
id: "h264".to_string(),
|
||||||
name: "H.264 / WebRTC".to_string(),
|
name: "H.264 / WebRTC".to_string(),
|
||||||
@@ -1809,7 +1544,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check H265 availability (now supports software too)
|
// Check H265 availability (now supports software too)
|
||||||
let h265_encoder = registry.best_encoder(VideoEncoderType::H265, false);
|
let h265_encoder = registry.best_available_encoder(VideoEncoderType::H265);
|
||||||
codecs.push(VideoCodecInfo {
|
codecs.push(VideoCodecInfo {
|
||||||
id: "h265".to_string(),
|
id: "h265".to_string(),
|
||||||
name: "H.265 / WebRTC".to_string(),
|
name: "H.265 / WebRTC".to_string(),
|
||||||
@@ -1820,7 +1555,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check VP8 availability (now supports software too)
|
// Check VP8 availability (now supports software too)
|
||||||
let vp8_encoder = registry.best_encoder(VideoEncoderType::VP8, false);
|
let vp8_encoder = registry.best_available_encoder(VideoEncoderType::VP8);
|
||||||
codecs.push(VideoCodecInfo {
|
codecs.push(VideoCodecInfo {
|
||||||
id: "vp8".to_string(),
|
id: "vp8".to_string(),
|
||||||
name: "VP8 / WebRTC".to_string(),
|
name: "VP8 / WebRTC".to_string(),
|
||||||
@@ -1831,7 +1566,7 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check VP9 availability (now supports software too)
|
// Check VP9 availability (now supports software too)
|
||||||
let vp9_encoder = registry.best_encoder(VideoEncoderType::VP9, false);
|
let vp9_encoder = registry.best_available_encoder(VideoEncoderType::VP9);
|
||||||
codecs.push(VideoCodecInfo {
|
codecs.push(VideoCodecInfo {
|
||||||
id: "vp9".to_string(),
|
id: "vp9".to_string(),
|
||||||
name: "VP9 / WebRTC".to_string(),
|
name: "VP9 / WebRTC".to_string(),
|
||||||
@@ -1848,6 +1583,15 @@ pub async fn stream_codecs_list() -> Json<AvailableCodecsResponse> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run hardware encoder smoke tests across common resolutions/codecs.
|
||||||
|
pub async fn video_encoder_self_check() -> Json<VideoEncoderSelfCheckResponse> {
|
||||||
|
let response = tokio::task::spawn_blocking(run_hardware_self_check)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| build_hardware_self_check_runtime_error());
|
||||||
|
|
||||||
|
Json(response)
|
||||||
|
}
|
||||||
|
|
||||||
/// Query parameters for MJPEG stream
|
/// Query parameters for MJPEG stream
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
pub struct MjpegStreamQuery {
|
pub struct MjpegStreamQuery {
|
||||||
@@ -2299,8 +2043,14 @@ pub struct HidStatus {
|
|||||||
pub available: bool,
|
pub available: bool,
|
||||||
pub backend: String,
|
pub backend: String,
|
||||||
pub initialized: bool,
|
pub initialized: bool,
|
||||||
|
pub online: bool,
|
||||||
pub supports_absolute_mouse: bool,
|
pub supports_absolute_mouse: bool,
|
||||||
|
pub keyboard_leds_enabled: bool,
|
||||||
|
pub led_state: crate::hid::LedState,
|
||||||
pub screen_resolution: Option<(u32, u32)>,
|
pub screen_resolution: Option<(u32, u32)>,
|
||||||
|
pub device: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub error_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
|
#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -3061,19 +2811,19 @@ pub async fn hid_otg_self_check(State(state): State<Arc<AppState>>) -> Json<OtgS
|
|||||||
|
|
||||||
/// Get HID status
|
/// Get HID status
|
||||||
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
|
pub async fn hid_status(State(state): State<Arc<AppState>>) -> Json<HidStatus> {
|
||||||
let info = state.hid.info().await;
|
let hid = state.hid.snapshot().await;
|
||||||
Json(HidStatus {
|
Json(HidStatus {
|
||||||
available: info.is_some(),
|
available: hid.available,
|
||||||
backend: info
|
backend: hid.backend,
|
||||||
.as_ref()
|
initialized: hid.initialized,
|
||||||
.map(|i| i.name.to_string())
|
online: hid.online,
|
||||||
.unwrap_or_else(|| "none".to_string()),
|
supports_absolute_mouse: hid.supports_absolute_mouse,
|
||||||
initialized: info.as_ref().map(|i| i.initialized).unwrap_or(false),
|
keyboard_leds_enabled: hid.keyboard_leds_enabled,
|
||||||
supports_absolute_mouse: info
|
led_state: hid.led_state,
|
||||||
.as_ref()
|
screen_resolution: hid.screen_resolution,
|
||||||
.map(|i| i.supports_absolute_mouse)
|
device: hid.device,
|
||||||
.unwrap_or(false),
|
error: hid.error,
|
||||||
screen_resolution: info.and_then(|i| i.screen_resolution),
|
error_code: hid.error_code,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/stream/bitrate", post(handlers::stream_set_bitrate))
|
.route("/stream/bitrate", post(handlers::stream_set_bitrate))
|
||||||
.route("/stream/codecs", get(handlers::stream_codecs_list))
|
.route("/stream/codecs", get(handlers::stream_codecs_list))
|
||||||
.route("/stream/constraints", get(handlers::stream_constraints_get))
|
.route("/stream/constraints", get(handlers::stream_constraints_get))
|
||||||
|
.route(
|
||||||
|
"/video/encoder/self-check",
|
||||||
|
get(handlers::video_encoder_self_check),
|
||||||
|
)
|
||||||
// WebRTC endpoints
|
// WebRTC endpoints
|
||||||
.route("/webrtc/session", post(handlers::webrtc_create_session))
|
.route("/webrtc/session", post(handlers::webrtc_create_session))
|
||||||
.route("/webrtc/offer", post(handlers::webrtc_offer))
|
.route("/webrtc/offer", post(handlers::webrtc_offer))
|
||||||
@@ -192,10 +196,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
"/extensions/ttyd/config",
|
"/extensions/ttyd/config",
|
||||||
patch(handlers::extensions::update_ttyd_config),
|
patch(handlers::extensions::update_ttyd_config),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/extensions/ttyd/status",
|
|
||||||
get(handlers::extensions::get_ttyd_status),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/extensions/gostc/config",
|
"/extensions/gostc/config",
|
||||||
patch(handlers::extensions::update_gostc_config),
|
patch(handlers::extensions::update_gostc_config),
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ pub fn placeholder_html() -> &'static str {
|
|||||||
<h1>One-KVM</h1>
|
<h1>One-KVM</h1>
|
||||||
<p>Frontend not built yet.</p>
|
<p>Frontend not built yet.</p>
|
||||||
<p>Please build the frontend or access the API directly.</p>
|
<p>Please build the frontend or access the API directly.</p>
|
||||||
<div class="version">v0.1.0</div>
|
<div class="version">v0.1.7</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"#
|
</html>"#
|
||||||
|
|||||||
239
src/web/ws.rs
239
src/web/ws.rs
@@ -16,12 +16,122 @@ use axum::{
|
|||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
use tokio::{sync::mpsc, task::JoinHandle};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::events::SystemEvent;
|
use crate::events::SystemEvent;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
enum BusMessage {
|
||||||
|
Event(SystemEvent),
|
||||||
|
Lagged { topic: String, count: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_topics(topics: &[String]) -> Vec<String> {
|
||||||
|
let mut normalized = topics.to_vec();
|
||||||
|
normalized.sort();
|
||||||
|
normalized.dedup();
|
||||||
|
|
||||||
|
if normalized.iter().any(|topic| topic == "*") {
|
||||||
|
return vec!["*".to_string()];
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized
|
||||||
|
.into_iter()
|
||||||
|
.filter(|topic| {
|
||||||
|
if topic.ends_with(".*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((prefix, _)) = topic.split_once('.') else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let wildcard = format!("{}.*", prefix);
|
||||||
|
!topics.iter().any(|candidate| candidate == &wildcard)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_device_info_topic(topic: &str) -> bool {
|
||||||
|
matches!(topic, "*" | "system.*" | "system.device_info")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_event_tasks(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
topics: &[String],
|
||||||
|
event_tx: &mpsc::UnboundedSender<BusMessage>,
|
||||||
|
event_tasks: &mut Vec<JoinHandle<()>>,
|
||||||
|
) {
|
||||||
|
for task in event_tasks.drain(..) {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
let topics = normalize_topics(topics);
|
||||||
|
let mut device_info_task_added = false;
|
||||||
|
for topic in topics {
|
||||||
|
if is_device_info_topic(&topic) && !device_info_task_added {
|
||||||
|
let state = state.clone();
|
||||||
|
let mut rx = state.subscribe_device_info();
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
event_tasks.push(tokio::spawn(async move {
|
||||||
|
let snapshot = state.get_device_info().await;
|
||||||
|
if event_tx.send(BusMessage::Event(snapshot)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if rx.changed().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(snapshot) = rx.borrow().clone() {
|
||||||
|
if event_tx.send(BusMessage::Event(snapshot)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
device_info_task_added = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_device_info_topic(&topic) && topic != "*" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(mut rx) = state.events.subscribe_topic(&topic) else {
|
||||||
|
warn!("Client subscribed to unknown topic: {}", topic);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let event_tx = event_tx.clone();
|
||||||
|
let topic_name = topic.clone();
|
||||||
|
event_tasks.push(tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(event) => {
|
||||||
|
if event_tx.send(BusMessage::Event(event)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
if event_tx
|
||||||
|
.send(BusMessage::Lagged {
|
||||||
|
topic: topic_name.clone(),
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Client-to-server message
|
/// Client-to-server message
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(tag = "type", content = "payload")]
|
#[serde(tag = "type", content = "payload")]
|
||||||
@@ -50,16 +160,12 @@ pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>
|
|||||||
/// Handle WebSocket connection
|
/// Handle WebSocket connection
|
||||||
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||||
let (mut sender, mut receiver) = socket.split();
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
|
||||||
// Subscribe to event bus
|
let mut event_tasks: Vec<JoinHandle<()>> = Vec::new();
|
||||||
let mut event_rx = state.events.subscribe();
|
|
||||||
|
|
||||||
// Track subscribed topics (default: none until client subscribes)
|
// Track subscribed topics (default: none until client subscribes)
|
||||||
let mut subscribed_topics: Vec<String> = vec![];
|
let mut subscribed_topics: Vec<String> = vec![];
|
||||||
|
|
||||||
// Flag to send device info after first subscribe
|
|
||||||
let mut device_info_sent = false;
|
|
||||||
|
|
||||||
info!("WebSocket client connected");
|
info!("WebSocket client connected");
|
||||||
|
|
||||||
// Heartbeat interval (30 seconds)
|
// Heartbeat interval (30 seconds)
|
||||||
@@ -73,18 +179,13 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await {
|
if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await {
|
||||||
warn!("Failed to handle client message: {}", e);
|
warn!("Failed to handle client message: {}", e);
|
||||||
}
|
} else {
|
||||||
|
rebuild_event_tasks(
|
||||||
// Send device info after first subscribe
|
&state,
|
||||||
if !device_info_sent && !subscribed_topics.is_empty() {
|
&subscribed_topics,
|
||||||
let device_info = state.get_device_info().await;
|
&event_tx,
|
||||||
if let Ok(json) = serialize_event(&device_info) {
|
&mut event_tasks,
|
||||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
);
|
||||||
warn!("Failed to send device info to client");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
device_info_sent = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Ping(_))) => {
|
Some(Ok(Message::Ping(_))) => {
|
||||||
@@ -109,9 +210,8 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
// Receive event from event bus
|
// Receive event from event bus
|
||||||
event = event_rx.recv() => {
|
event = event_rx.recv() => {
|
||||||
match event {
|
match event {
|
||||||
Ok(event) => {
|
Some(BusMessage::Event(event)) => {
|
||||||
// Filter event based on subscribed topics
|
// Filter event based on subscribed topics
|
||||||
if should_send_event(&event, &subscribed_topics) {
|
|
||||||
if let Ok(json) = serialize_event(&event) {
|
if let Ok(json) = serialize_event(&event) {
|
||||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||||
warn!("Failed to send event to client, disconnecting");
|
warn!("Failed to send event to client, disconnecting");
|
||||||
@@ -119,18 +219,20 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Some(BusMessage::Lagged { topic, count }) => {
|
||||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
warn!(
|
||||||
warn!("WebSocket client lagged by {} events", n);
|
"WebSocket client lagged by {} events on topic {}",
|
||||||
|
count, topic
|
||||||
|
);
|
||||||
// Send error notification to client using SystemEvent::Error
|
// Send error notification to client using SystemEvent::Error
|
||||||
let error_event = SystemEvent::Error {
|
let error_event = SystemEvent::Error {
|
||||||
message: format!("Lagged by {} events", n),
|
message: format!("Lagged by {} events", count),
|
||||||
};
|
};
|
||||||
if let Ok(json) = serialize_event(&error_event) {
|
if let Ok(json) = serialize_event(&error_event) {
|
||||||
let _ = sender.send(Message::Text(json.into())).await;
|
let _ = sender.send(Message::Text(json.into())).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
None => {
|
||||||
warn!("Event bus closed");
|
warn!("Event bus closed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -147,6 +249,10 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for task in event_tasks {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
info!("WebSocket handler exiting");
|
info!("WebSocket handler exiting");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,21 +282,6 @@ async fn handle_client_message(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an event should be sent based on subscribed topics
|
|
||||||
fn should_send_event(event: &SystemEvent, topics: &[String]) -> bool {
|
|
||||||
if topics.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fast path: check for wildcard subscription (avoid String allocation)
|
|
||||||
if topics.iter().any(|t| t == "*") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if event matches any subscribed topic
|
|
||||||
topics.iter().any(|topic| event.matches_topic(topic))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize event to JSON string
|
/// Serialize event to JSON string
|
||||||
fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
|
fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
|
||||||
serde_json::to_string(event)
|
serde_json::to_string(event)
|
||||||
@@ -199,53 +290,49 @@ fn serialize_event(event: &SystemEvent) -> Result<String, serde_json::Error> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::events::SystemEvent;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_send_event_wildcard() {
|
fn test_normalize_topics_dedupes_and_sorts() {
|
||||||
let event = SystemEvent::StreamStateChanged {
|
let topics = vec![
|
||||||
state: "streaming".to_string(),
|
"stream.state_changed".to_string(),
|
||||||
device: None,
|
"stream.state_changed".to_string(),
|
||||||
};
|
"system.device_info".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
assert!(should_send_event(&event, &["*".to_string()]));
|
assert_eq!(
|
||||||
|
normalize_topics(&topics),
|
||||||
|
vec![
|
||||||
|
"stream.state_changed".to_string(),
|
||||||
|
"system.device_info".to_string()
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_send_event_prefix() {
|
fn test_normalize_topics_wildcard_wins() {
|
||||||
let event = SystemEvent::StreamStateChanged {
|
let topics = vec!["*".to_string(), "stream.state_changed".to_string()];
|
||||||
state: "streaming".to_string(),
|
assert_eq!(normalize_topics(&topics), vec!["*".to_string()]);
|
||||||
device: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(should_send_event(&event, &["stream.*".to_string()]));
|
|
||||||
assert!(!should_send_event(&event, &["msd.*".to_string()]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_send_event_exact() {
|
fn test_normalize_topics_drops_exact_when_prefix_exists() {
|
||||||
let event = SystemEvent::StreamStateChanged {
|
let topics = vec![
|
||||||
state: "streaming".to_string(),
|
"stream.*".to_string(),
|
||||||
device: None,
|
"stream.state_changed".to_string(),
|
||||||
};
|
"system.device_info".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
assert!(should_send_event(
|
assert_eq!(
|
||||||
&event,
|
normalize_topics(&topics),
|
||||||
&["stream.state_changed".to_string()]
|
vec!["stream.*".to_string(), "system.device_info".to_string()]
|
||||||
));
|
);
|
||||||
assert!(!should_send_event(
|
|
||||||
&event,
|
|
||||||
&["stream.config_changed".to_string()]
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_send_event_empty_topics() {
|
fn test_is_device_info_topic_matches_expected_topics() {
|
||||||
let event = SystemEvent::StreamStateChanged {
|
assert!(is_device_info_topic("system.device_info"));
|
||||||
state: "streaming".to_string(),
|
assert!(is_device_info_topic("system.*"));
|
||||||
device: None,
|
assert!(is_device_info_topic("*"));
|
||||||
};
|
assert!(!is_device_info_topic("stream.*"));
|
||||||
|
|
||||||
assert!(!should_send_event(&event, &[]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,12 +37,6 @@ const H265_NAL_SPS: u8 = 33;
|
|||||||
const H265_NAL_PPS: u8 = 34;
|
const H265_NAL_PPS: u8 = 34;
|
||||||
const H265_NAL_AUD: u8 = 35;
|
const H265_NAL_AUD: u8 = 35;
|
||||||
const H265_NAL_FILLER: u8 = 38;
|
const H265_NAL_FILLER: u8 = 38;
|
||||||
#[allow(dead_code)]
|
|
||||||
const H265_NAL_SEI_PREFIX: u8 = 39; // PREFIX_SEI_NUT
|
|
||||||
#[allow(dead_code)]
|
|
||||||
const H265_NAL_SEI_SUFFIX: u8 = 40; // SUFFIX_SEI_NUT
|
|
||||||
#[allow(dead_code)]
|
|
||||||
const H265_NAL_AP: u8 = 48; // Aggregation Packet
|
|
||||||
const H265_NAL_FU: u8 = 49; // Fragmentation Unit
|
const H265_NAL_FU: u8 = 49; // Fragmentation Unit
|
||||||
|
|
||||||
/// H.265 NAL header size
|
/// H.265 NAL header size
|
||||||
@@ -51,11 +45,6 @@ const H265_NAL_HEADER_SIZE: usize = 2;
|
|||||||
/// FU header size (1 byte after NAL header)
|
/// FU header size (1 byte after NAL header)
|
||||||
const H265_FU_HEADER_SIZE: usize = 1;
|
const H265_FU_HEADER_SIZE: usize = 1;
|
||||||
|
|
||||||
/// Fixed PayloadHdr for FU packets: Type=49, LayerID=0, TID=1
|
|
||||||
/// This matches the rtp crate's FRAG_PAYLOAD_HDR
|
|
||||||
#[allow(dead_code)]
|
|
||||||
const FU_PAYLOAD_HDR: [u8; 2] = [0x62, 0x01];
|
|
||||||
|
|
||||||
/// Fixed PayloadHdr for AP packets: Type=48, LayerID=0, TID=1
|
/// Fixed PayloadHdr for AP packets: Type=48, LayerID=0, TID=1
|
||||||
/// This matches the rtp crate's AGGR_PAYLOAD_HDR
|
/// This matches the rtp crate's AGGR_PAYLOAD_HDR
|
||||||
const AP_PAYLOAD_HDR: [u8; 2] = [0x60, 0x01];
|
const AP_PAYLOAD_HDR: [u8; 2] = [0x60, 0x01];
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Architecture:
|
//! Architecture:
|
||||||
//! ```text
|
//! ```text
|
||||||
//! VideoCapturer (MJPEG/YUYV)
|
//! V4L2 capture
|
||||||
//! |
|
//! |
|
||||||
//! v
|
//! v
|
||||||
//! SharedVideoPipeline (decode -> convert -> encode)
|
//! SharedVideoPipeline (decode -> convert -> encode)
|
||||||
|
|||||||
@@ -262,8 +262,6 @@ impl Default for AudioTrackConfig {
|
|||||||
|
|
||||||
/// Audio track for WebRTC streaming
|
/// Audio track for WebRTC streaming
|
||||||
pub struct AudioTrack {
|
pub struct AudioTrack {
|
||||||
#[allow(dead_code)]
|
|
||||||
config: AudioTrackConfig,
|
|
||||||
/// RTP track
|
/// RTP track
|
||||||
track: Arc<TrackLocalStaticRTP>,
|
track: Arc<TrackLocalStaticRTP>,
|
||||||
/// Running flag
|
/// Running flag
|
||||||
@@ -284,7 +282,6 @@ impl AudioTrack {
|
|||||||
let (running_tx, _) = watch::channel(false);
|
let (running_tx, _) = watch::channel(false);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
|
||||||
track,
|
track,
|
||||||
running: Arc::new(running_tx),
|
running: Arc::new(running_tx),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,53 @@ use webrtc::ice_transport::ice_gatherer_state::RTCIceGathererState;
|
|||||||
/// H.265/HEVC MIME type (RFC 7798)
|
/// H.265/HEVC MIME type (RFC 7798)
|
||||||
const MIME_TYPE_H265: &str = "video/H265";
|
const MIME_TYPE_H265: &str = "video/H265";
|
||||||
|
|
||||||
|
fn h264_contains_parameter_sets(data: &[u8]) -> bool {
|
||||||
|
// Annex-B start code path
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 4 <= data.len() {
|
||||||
|
let sc_len = if i + 4 <= data.len()
|
||||||
|
&& data[i] == 0
|
||||||
|
&& data[i + 1] == 0
|
||||||
|
&& data[i + 2] == 0
|
||||||
|
&& data[i + 3] == 1
|
||||||
|
{
|
||||||
|
4
|
||||||
|
} else if i + 3 <= data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let nal_start = i + sc_len;
|
||||||
|
if nal_start < data.len() {
|
||||||
|
let nal_type = data[nal_start] & 0x1F;
|
||||||
|
if nal_type == 7 || nal_type == 8 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = nal_start.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length-prefixed fallback
|
||||||
|
let mut pos = 0usize;
|
||||||
|
while pos + 4 <= data.len() {
|
||||||
|
let nalu_len =
|
||||||
|
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
|
||||||
|
pos += 4;
|
||||||
|
if nalu_len == 0 || pos + nalu_len > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let nal_type = data[pos] & 0x1F;
|
||||||
|
if nal_type == 7 || nal_type == 8 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pos += nalu_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Universal WebRTC session configuration
|
/// Universal WebRTC session configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct UniversalSessionConfig {
|
pub struct UniversalSessionConfig {
|
||||||
@@ -649,6 +696,13 @@ impl UniversalSession {
|
|||||||
if gap_detected {
|
if gap_detected {
|
||||||
waiting_for_keyframe = true;
|
waiting_for_keyframe = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some H264 encoders output SPS/PPS in a separate non-keyframe AU
|
||||||
|
// before IDR. Keep this frame so browser can decode the next IDR.
|
||||||
|
let forward_h264_parameter_frame = waiting_for_keyframe
|
||||||
|
&& expected_codec == VideoEncoderType::H264
|
||||||
|
&& h264_contains_parameter_sets(encoded_frame.data.as_ref());
|
||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if now.duration_since(last_keyframe_request)
|
if now.duration_since(last_keyframe_request)
|
||||||
>= Duration::from_millis(200)
|
>= Duration::from_millis(200)
|
||||||
@@ -656,9 +710,11 @@ impl UniversalSession {
|
|||||||
request_keyframe();
|
request_keyframe();
|
||||||
last_keyframe_request = now;
|
last_keyframe_request = now;
|
||||||
}
|
}
|
||||||
|
if !forward_h264_parameter_frame {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _ = send_in_flight;
|
let _ = send_in_flight;
|
||||||
|
|
||||||
|
|||||||
@@ -221,23 +221,11 @@ impl WebRtcStreamer {
|
|||||||
use crate::video::encoder::registry::EncoderRegistry;
|
use crate::video::encoder::registry::EncoderRegistry;
|
||||||
|
|
||||||
let registry = EncoderRegistry::global();
|
let registry = EncoderRegistry::global();
|
||||||
let mut codecs = vec![];
|
VideoEncoderType::ordered()
|
||||||
|
.into_iter()
|
||||||
// H264 always available (has software fallback)
|
.filter(|codec| registry.is_codec_available(*codec))
|
||||||
codecs.push(VideoCodecType::H264);
|
.map(Self::encoder_type_to_codec_type)
|
||||||
|
.collect()
|
||||||
// Check hardware codecs
|
|
||||||
if registry.is_format_available(VideoEncoderType::H265, true) {
|
|
||||||
codecs.push(VideoCodecType::H265);
|
|
||||||
}
|
|
||||||
if registry.is_format_available(VideoEncoderType::VP8, true) {
|
|
||||||
codecs.push(VideoCodecType::VP8);
|
|
||||||
}
|
|
||||||
if registry.is_format_available(VideoEncoderType::VP9, true) {
|
|
||||||
codecs.push(VideoCodecType::VP9);
|
|
||||||
}
|
|
||||||
|
|
||||||
codecs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert VideoCodecType to VideoEncoderType
|
/// Convert VideoCodecType to VideoEncoderType
|
||||||
@@ -250,6 +238,15 @@ impl WebRtcStreamer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn encoder_type_to_codec_type(codec: VideoEncoderType) -> VideoCodecType {
|
||||||
|
match codec {
|
||||||
|
VideoEncoderType::H264 => VideoCodecType::H264,
|
||||||
|
VideoEncoderType::H265 => VideoCodecType::H265,
|
||||||
|
VideoEncoderType::VP8 => VideoCodecType::VP8,
|
||||||
|
VideoEncoderType::VP9 => VideoCodecType::VP9,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn should_stop_pipeline(session_count: usize, subscriber_count: usize) -> bool {
|
fn should_stop_pipeline(session_count: usize, subscriber_count: usize) -> bool {
|
||||||
session_count == 0 && subscriber_count == 0
|
session_count == 0 && subscriber_count == 0
|
||||||
}
|
}
|
||||||
@@ -577,7 +574,7 @@ impl WebRtcStreamer {
|
|||||||
VideoCodecType::VP9 => VideoEncoderType::VP9,
|
VideoCodecType::VP9 => VideoEncoderType::VP9,
|
||||||
};
|
};
|
||||||
EncoderRegistry::global()
|
EncoderRegistry::global()
|
||||||
.best_encoder(codec_type, false)
|
.best_available_encoder(codec_type)
|
||||||
.map(|e| e.is_hardware)
|
.map(|e| e.is_hardware)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"name": "web",
|
||||||
"version": "0.1.5",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.5",
|
"version": "0.1.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.5",
|
"version": "0.1.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, watch } from 'vue'
|
import { KeepAlive, onMounted, watch } from 'vue'
|
||||||
import { RouterView, useRouter } from 'vue-router'
|
import { RouterView, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useSystemStore } from '@/stores/system'
|
import { useSystemStore } from '@/stores/system'
|
||||||
@@ -56,5 +56,10 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView v-slot="{ Component, route }">
|
||||||
|
<KeepAlive v-if="authStore.isAuthenticated">
|
||||||
|
<component :is="Component" v-if="route.name === 'Console'" />
|
||||||
|
</KeepAlive>
|
||||||
|
<component :is="Component" v-if="route.name !== 'Console' || !authStore.isAuthenticated" />
|
||||||
|
</RouterView>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import type {
|
|||||||
GostcConfigUpdate,
|
GostcConfigUpdate,
|
||||||
EasytierConfig,
|
EasytierConfig,
|
||||||
EasytierConfigUpdate,
|
EasytierConfigUpdate,
|
||||||
TtydStatus,
|
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
|
|
||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
@@ -236,11 +235,6 @@ export const extensionsApi = {
|
|||||||
logs: (id: string, lines = 100) =>
|
logs: (id: string, lines = 100) =>
|
||||||
request<ExtensionLogs>(`/extensions/${id}/logs?lines=${lines}`),
|
request<ExtensionLogs>(`/extensions/${id}/logs?lines=${lines}`),
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 ttyd 状态(简化版,用于控制台)
|
|
||||||
*/
|
|
||||||
getTtydStatus: () => request<TtydStatus>('/extensions/ttyd/status'),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新 ttyd 配置
|
* 更新 ttyd 配置
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// API client for One-KVM backend
|
// API client for One-KVM backend
|
||||||
|
|
||||||
import { request, ApiError } from './request'
|
import { request, ApiError } from './request'
|
||||||
|
import type { CanonicalKey } from '@/types/generated'
|
||||||
|
|
||||||
const API_BASE = '/api'
|
const API_BASE = '/api'
|
||||||
|
|
||||||
@@ -85,6 +86,9 @@ export const systemApi = {
|
|||||||
hid_ch9329_baudrate?: number
|
hid_ch9329_baudrate?: number
|
||||||
hid_otg_udc?: string
|
hid_otg_udc?: string
|
||||||
hid_otg_profile?: string
|
hid_otg_profile?: string
|
||||||
|
hid_otg_endpoint_budget?: string
|
||||||
|
hid_otg_keyboard_leds?: boolean
|
||||||
|
msd_enabled?: boolean
|
||||||
encoder_backend?: string
|
encoder_backend?: string
|
||||||
audio_device?: string
|
audio_device?: string
|
||||||
ttyd_enabled?: boolean
|
ttyd_enabled?: boolean
|
||||||
@@ -177,6 +181,31 @@ export interface StreamConstraintsResponse {
|
|||||||
current_mode: string
|
current_mode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VideoEncoderSelfCheckCodec {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoEncoderSelfCheckCell {
|
||||||
|
codec_id: string
|
||||||
|
ok: boolean
|
||||||
|
elapsed_ms?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoEncoderSelfCheckRow {
|
||||||
|
resolution_id: string
|
||||||
|
resolution_label: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
cells: VideoEncoderSelfCheckCell[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoEncoderSelfCheckResponse {
|
||||||
|
current_hardware_encoder: string
|
||||||
|
codecs: VideoEncoderSelfCheckCodec[]
|
||||||
|
rows: VideoEncoderSelfCheckRow[]
|
||||||
|
}
|
||||||
|
|
||||||
export const streamApi = {
|
export const streamApi = {
|
||||||
status: () =>
|
status: () =>
|
||||||
request<{
|
request<{
|
||||||
@@ -217,6 +246,9 @@ export const streamApi = {
|
|||||||
getConstraints: () =>
|
getConstraints: () =>
|
||||||
request<StreamConstraintsResponse>('/stream/constraints'),
|
request<StreamConstraintsResponse>('/stream/constraints'),
|
||||||
|
|
||||||
|
encoderSelfCheck: () =>
|
||||||
|
request<VideoEncoderSelfCheckResponse>('/video/encoder/self-check'),
|
||||||
|
|
||||||
setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) =>
|
setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) =>
|
||||||
request<{ success: boolean; message?: string }>('/stream/bitrate', {
|
request<{ success: boolean; message?: string }>('/stream/bitrate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -299,8 +331,20 @@ export const hidApi = {
|
|||||||
available: boolean
|
available: boolean
|
||||||
backend: string
|
backend: string
|
||||||
initialized: boolean
|
initialized: boolean
|
||||||
|
online: boolean
|
||||||
supports_absolute_mouse: boolean
|
supports_absolute_mouse: boolean
|
||||||
|
keyboard_leds_enabled: boolean
|
||||||
|
led_state: {
|
||||||
|
num_lock: boolean
|
||||||
|
caps_lock: boolean
|
||||||
|
scroll_lock: boolean
|
||||||
|
compose: boolean
|
||||||
|
kana: boolean
|
||||||
|
}
|
||||||
screen_resolution: [number, number] | null
|
screen_resolution: [number, number] | null
|
||||||
|
device: string | null
|
||||||
|
error: string | null
|
||||||
|
error_code: string | null
|
||||||
}>('/hid/status'),
|
}>('/hid/status'),
|
||||||
|
|
||||||
otgSelfCheck: () =>
|
otgSelfCheck: () =>
|
||||||
@@ -325,7 +369,7 @@ export const hidApi = {
|
|||||||
}>
|
}>
|
||||||
}>('/hid/otg/self-check'),
|
}>('/hid/otg/self-check'),
|
||||||
|
|
||||||
keyboard: async (type: 'down' | 'up', key: number, modifier?: number) => {
|
keyboard: async (type: 'down' | 'up', key: CanonicalKey, modifier?: number) => {
|
||||||
await ensureHidConnection()
|
await ensureHidConnection()
|
||||||
const event: HidKeyboardEvent = {
|
const event: HidKeyboardEvent = {
|
||||||
type: type === 'down' ? 'keydown' : 'keyup',
|
type: type === 'down' ? 'keydown' : 'keyup',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Power, RotateCcw, CircleDot, Wifi, Send } from 'lucide-vue-next'
|
import { Power, RotateCcw, CircleDot, Wifi, Send } from 'lucide-vue-next'
|
||||||
|
import { atxApi } from '@/api'
|
||||||
import { atxConfigApi } from '@/api/config'
|
import { atxConfigApi } from '@/api/config'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -34,6 +35,7 @@ const activeTab = ref('atx')
|
|||||||
|
|
||||||
// ATX state
|
// ATX state
|
||||||
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
|
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
|
||||||
|
let powerStateTimer: number | null = null
|
||||||
// Decouple action data from dialog visibility to prevent race conditions
|
// Decouple action data from dialog visibility to prevent race conditions
|
||||||
const pendingAction = ref<'short' | 'long' | 'reset' | null>(null)
|
const pendingAction = ref<'short' | 'long' | 'reset' | null>(null)
|
||||||
const confirmDialogOpen = ref(false)
|
const confirmDialogOpen = ref(false)
|
||||||
@@ -71,6 +73,9 @@ function handleAction() {
|
|||||||
else if (pendingAction.value === 'long') emit('powerLong')
|
else if (pendingAction.value === 'long') emit('powerLong')
|
||||||
else if (pendingAction.value === 'reset') emit('reset')
|
else if (pendingAction.value === 'reset') emit('reset')
|
||||||
confirmDialogOpen.value = false
|
confirmDialogOpen.value = false
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshPowerState().catch(() => {})
|
||||||
|
}, 1200)
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmTitle = computed(() => {
|
const confirmTitle = computed(() => {
|
||||||
@@ -139,6 +144,29 @@ async function loadWolHistory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshPowerState() {
|
||||||
|
try {
|
||||||
|
const state = await atxApi.status()
|
||||||
|
powerState.value = state.power_status
|
||||||
|
} catch {
|
||||||
|
powerState.value = 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshPowerState().catch(() => {})
|
||||||
|
powerStateTimer = window.setInterval(() => {
|
||||||
|
refreshPowerState().catch(() => {})
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (powerStateTimer !== null) {
|
||||||
|
window.clearInterval(powerStateTimer)
|
||||||
|
powerStateTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => activeTab.value,
|
() => activeTab.value,
|
||||||
(tab) => {
|
(tab) => {
|
||||||
|
|||||||
@@ -123,8 +123,7 @@ async function applyConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await audioApi.start()
|
await audioApi.start()
|
||||||
// Note: handleAudioStateChanged in ConsoleView will handle the connection
|
// ConsoleView will react when system.device_info reflects streaming=true.
|
||||||
// when it receives the audio.state_changed event with streaming=true
|
|
||||||
} catch (startError) {
|
} catch (startError) {
|
||||||
// Audio start failed - config was saved but streaming not started
|
// Audio start failed - config was saved but streaming not started
|
||||||
console.info('[AudioConfig] Audio start failed:', startError)
|
console.info('[AudioConfig] Audio start failed:', startError)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { CanonicalKey } from '@/types/generated'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
pressedKeys?: string[]
|
pressedKeys?: CanonicalKey[]
|
||||||
capsLock?: boolean
|
capsLock?: boolean
|
||||||
|
numLock?: boolean
|
||||||
|
scrollLock?: boolean
|
||||||
|
keyboardLedEnabled?: boolean
|
||||||
mousePosition?: { x: number; y: number }
|
mousePosition?: { x: number; y: number }
|
||||||
debugMode?: boolean
|
debugMode?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
@@ -18,13 +22,14 @@ const keyNameMap: Record<string, string> = {
|
|||||||
MetaLeft: 'Win', MetaRight: 'Win',
|
MetaLeft: 'Win', MetaRight: 'Win',
|
||||||
ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
|
ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
|
||||||
ShiftLeft: 'Shift', ShiftRight: 'Shift',
|
ShiftLeft: 'Shift', ShiftRight: 'Shift',
|
||||||
AltLeft: 'Alt', AltRight: 'Alt',
|
AltLeft: 'Alt', AltRight: 'AltGr',
|
||||||
CapsLock: 'Caps', NumLock: 'Num', ScrollLock: 'Scroll',
|
CapsLock: 'Caps', NumLock: 'Num', ScrollLock: 'Scroll',
|
||||||
Backspace: 'Back', Delete: 'Del',
|
Backspace: 'Back', Delete: 'Del',
|
||||||
ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→',
|
ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→',
|
||||||
Escape: 'Esc', Enter: 'Enter', Tab: 'Tab', Space: 'Space',
|
Escape: 'Esc', Enter: 'Enter', Tab: 'Tab', Space: 'Space',
|
||||||
PageUp: 'PgUp', PageDown: 'PgDn',
|
PageUp: 'PgUp', PageDown: 'PgDn',
|
||||||
Insert: 'Ins', Home: 'Home', End: 'End',
|
Insert: 'Ins', Home: 'Home', End: 'End',
|
||||||
|
ContextMenu: 'Menu',
|
||||||
}
|
}
|
||||||
|
|
||||||
const keysDisplay = computed(() => {
|
const keysDisplay = computed(() => {
|
||||||
@@ -40,12 +45,21 @@ const keysDisplay = computed(() => {
|
|||||||
<!-- Compact mode for small screens -->
|
<!-- Compact mode for small screens -->
|
||||||
<div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5">
|
<div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5">
|
||||||
<!-- LED indicator only in compact mode -->
|
<!-- LED indicator only in compact mode -->
|
||||||
<div class="flex items-center gap-1">
|
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
v-if="capsLock"
|
v-if="capsLock"
|
||||||
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
|
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
|
||||||
>C</span>
|
>C</span>
|
||||||
<span v-else class="text-muted-foreground/40 text-[10px]">-</span>
|
<span v-else class="text-muted-foreground/40 text-[10px]">C</span>
|
||||||
|
<span
|
||||||
|
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
||||||
|
>N</span>
|
||||||
|
<span
|
||||||
|
:class="scrollLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
||||||
|
>S</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-[10px] text-muted-foreground/60">
|
||||||
|
{{ t('infobar.keyboardLedUnavailable') }}
|
||||||
</div>
|
</div>
|
||||||
<!-- Keys in compact mode -->
|
<!-- Keys in compact mode -->
|
||||||
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]">
|
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]">
|
||||||
@@ -70,8 +84,9 @@ const keysDisplay = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right side: Caps Lock LED state -->
|
<!-- Right side: Keyboard LED states -->
|
||||||
<div class="flex items-center shrink-0">
|
<div class="flex items-center shrink-0">
|
||||||
|
<template v-if="keyboardLedEnabled">
|
||||||
<div
|
<div
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'px-2 py-1 select-none transition-colors',
|
'px-2 py-1 select-none transition-colors',
|
||||||
@@ -81,6 +96,28 @@ const keysDisplay = computed(() => {
|
|||||||
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
|
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
|
||||||
<span class="sm:hidden">C</span>
|
<span class="sm:hidden">C</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
:class="cn(
|
||||||
|
'px-2 py-1 select-none transition-colors',
|
||||||
|
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">{{ t('infobar.num') }}</span>
|
||||||
|
<span class="sm:hidden">N</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="cn(
|
||||||
|
'px-2 py-1 select-none transition-colors',
|
||||||
|
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">{{ t('infobar.scroll') }}</span>
|
||||||
|
<span class="sm:hidden">S</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="px-3 py-1 text-muted-foreground/60">
|
||||||
|
{{ t('infobar.keyboardLedUnavailable') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,24 +69,24 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hidCode, shift } = mapping
|
const { key, shift } = mapping
|
||||||
const modifier = shift ? 0x02 : 0
|
const modifier = shift ? 0x02 : 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send keydown
|
// Send keydown
|
||||||
await hidApi.keyboard('down', hidCode, modifier)
|
await hidApi.keyboard('down', key, modifier)
|
||||||
|
|
||||||
// Small delay between down and up to ensure key is registered
|
// Small delay between down and up to ensure key is registered
|
||||||
await sleep(5)
|
await sleep(5)
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
// Even if aborted, still send keyup to release the key
|
// Even if aborted, still send keyup to release the key
|
||||||
await hidApi.keyboard('up', hidCode, modifier)
|
await hidApi.keyboard('up', key, modifier)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send keyup
|
// Send keyup
|
||||||
await hidApi.keyboard('up', hidCode, modifier)
|
await hidApi.keyboard('up', key, modifier)
|
||||||
|
|
||||||
// Additional small delay after keyup to ensure it's processed
|
// Additional small delay after keyup to ensure it's processed
|
||||||
await sleep(2)
|
await sleep(2)
|
||||||
@@ -96,7 +96,7 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
|
|||||||
console.error('[Paste] Failed to type character:', char, error)
|
console.error('[Paste] Failed to type character:', char, error)
|
||||||
// Try to release the key even on error
|
// Try to release the key even on error
|
||||||
try {
|
try {
|
||||||
await hidApi.keyboard('up', hidCode, modifier)
|
await hidApi.keyboard('up', key, modifier)
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore cleanup errors
|
// Ignore cleanup errors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type BitratePreset,
|
type BitratePreset,
|
||||||
type StreamConstraintsResponse,
|
type StreamConstraintsResponse,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
|
import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format-support'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -167,6 +168,12 @@ const isBrowserSupported = (codecId: string): boolean => {
|
|||||||
return browserSupportedCodecs.value.has(codecId)
|
return browserSupportedCodecs.value.has(codecId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFormatState = (formatName: string) =>
|
||||||
|
getVideoFormatState(formatName, props.videoMode, currentEncoderBackend.value)
|
||||||
|
|
||||||
|
const isFormatUnsupported = (formatName: string): boolean =>
|
||||||
|
getFormatState(formatName) === 'unsupported'
|
||||||
|
|
||||||
// Translate backend name for display
|
// Translate backend name for display
|
||||||
const translateBackendName = (backend: string | undefined): string => {
|
const translateBackendName = (backend: string | undefined): string => {
|
||||||
if (!backend) return ''
|
if (!backend) return ''
|
||||||
@@ -189,6 +196,10 @@ const hasHighFps = (format: { resolutions: { fps: number[] }[] }): boolean => {
|
|||||||
|
|
||||||
// Check if a format is recommended based on video mode
|
// Check if a format is recommended based on video mode
|
||||||
const isFormatRecommended = (formatName: string): boolean => {
|
const isFormatRecommended = (formatName: string): boolean => {
|
||||||
|
if (!isVideoFormatSelectable(formatName, props.videoMode, currentEncoderBackend.value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const formats = availableFormats.value
|
const formats = availableFormats.value
|
||||||
const upperFormat = formatName.toUpperCase()
|
const upperFormat = formatName.toUpperCase()
|
||||||
|
|
||||||
@@ -225,12 +236,7 @@ const isFormatRecommended = (formatName: string): boolean => {
|
|||||||
// Check if a format is not recommended for current video mode
|
// Check if a format is not recommended for current video mode
|
||||||
// In WebRTC mode, compressed formats (MJPEG/JPEG) are not recommended
|
// In WebRTC mode, compressed formats (MJPEG/JPEG) are not recommended
|
||||||
const isFormatNotRecommended = (formatName: string): boolean => {
|
const isFormatNotRecommended = (formatName: string): boolean => {
|
||||||
const upperFormat = formatName.toUpperCase()
|
return getFormatState(formatName) === 'not_recommended'
|
||||||
// WebRTC mode: MJPEG/JPEG are not recommended (require decoding before encoding)
|
|
||||||
if (props.videoMode !== 'mjpeg') {
|
|
||||||
return upperFormat === 'MJPEG' || upperFormat === 'JPEG'
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selected values (mode comes from props)
|
// Selected values (mode comes from props)
|
||||||
@@ -303,6 +309,14 @@ const availableFormats = computed(() => {
|
|||||||
return device?.formats || []
|
return device?.formats || []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const availableFormatOptions = computed(() => {
|
||||||
|
return availableFormats.value.map(format => ({
|
||||||
|
...format,
|
||||||
|
state: getFormatState(format.format),
|
||||||
|
disabled: isFormatUnsupported(format.format),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
const availableResolutions = computed(() => {
|
const availableResolutions = computed(() => {
|
||||||
const format = availableFormats.value.find(f => f.format === selectedFormat.value)
|
const format = availableFormats.value.find(f => f.format === selectedFormat.value)
|
||||||
return format?.resolutions || []
|
return format?.resolutions || []
|
||||||
@@ -317,8 +331,8 @@ const availableFps = computed(() => {
|
|||||||
|
|
||||||
// Get selected format description for display in trigger
|
// Get selected format description for display in trigger
|
||||||
const selectedFormatInfo = computed(() => {
|
const selectedFormatInfo = computed(() => {
|
||||||
const format = availableFormats.value.find(f => f.format === selectedFormat.value)
|
const format = availableFormatOptions.value.find(f => f.format === selectedFormat.value)
|
||||||
return format ? { description: format.description, format: format.format } : null
|
return format
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get selected codec info for display in trigger
|
// Get selected codec info for display in trigger
|
||||||
@@ -423,6 +437,37 @@ function handleVideoModeChange(mode: unknown) {
|
|||||||
emit('update:videoMode', mode as VideoMode)
|
emit('update:videoMode', mode as VideoMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findFirstSelectableFormat(
|
||||||
|
formats: VideoDevice['formats'],
|
||||||
|
): VideoDevice['formats'][number] | undefined {
|
||||||
|
return formats.find(format =>
|
||||||
|
isVideoFormatSelectable(format.format, props.videoMode, currentEncoderBackend.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFormatSelection() {
|
||||||
|
selectedFormat.value = ''
|
||||||
|
selectedResolution.value = ''
|
||||||
|
selectedFps.value = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFormatWithDefaults(format: string) {
|
||||||
|
if (isFormatUnsupported(format)) return
|
||||||
|
|
||||||
|
selectedFormat.value = format
|
||||||
|
|
||||||
|
const formatData = availableFormats.value.find(f => f.format === format)
|
||||||
|
const resolution = formatData?.resolutions[0]
|
||||||
|
if (!resolution) {
|
||||||
|
selectedResolution.value = ''
|
||||||
|
selectedFps.value = 30
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedResolution.value = `${resolution.width}x${resolution.height}`
|
||||||
|
selectedFps.value = resolution.fps[0] || 30
|
||||||
|
}
|
||||||
|
|
||||||
// Handle device change
|
// Handle device change
|
||||||
function handleDeviceChange(devicePath: unknown) {
|
function handleDeviceChange(devicePath: unknown) {
|
||||||
if (typeof devicePath !== 'string') return
|
if (typeof devicePath !== 'string') return
|
||||||
@@ -431,31 +476,22 @@ function handleDeviceChange(devicePath: unknown) {
|
|||||||
|
|
||||||
// Auto-select first format
|
// Auto-select first format
|
||||||
const device = devices.value.find(d => d.path === devicePath)
|
const device = devices.value.find(d => d.path === devicePath)
|
||||||
if (device?.formats[0]) {
|
const format = device ? findFirstSelectableFormat(device.formats) : undefined
|
||||||
selectedFormat.value = device.formats[0].format
|
if (!format) {
|
||||||
|
clearFormatSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-select first resolution
|
selectFormatWithDefaults(format.format)
|
||||||
const resolution = device.formats[0].resolutions[0]
|
|
||||||
if (resolution) {
|
|
||||||
selectedResolution.value = `${resolution.width}x${resolution.height}`
|
|
||||||
selectedFps.value = resolution.fps[0] || 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle format change
|
// Handle format change
|
||||||
function handleFormatChange(format: unknown) {
|
function handleFormatChange(format: unknown) {
|
||||||
if (typeof format !== 'string') return
|
if (typeof format !== 'string') return
|
||||||
selectedFormat.value = format
|
if (isFormatUnsupported(format)) return
|
||||||
isDirty.value = true
|
|
||||||
|
|
||||||
// Auto-select first resolution for this format
|
selectFormatWithDefaults(format)
|
||||||
const formatData = availableFormats.value.find(f => f.format === format)
|
isDirty.value = true
|
||||||
if (formatData?.resolutions[0]) {
|
|
||||||
const resolution = formatData.resolutions[0]
|
|
||||||
selectedResolution.value = `${resolution.width}x${resolution.height}`
|
|
||||||
selectedFps.value = resolution.fps[0] || 30
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle resolution change
|
// Handle resolution change
|
||||||
@@ -567,6 +603,29 @@ watch(currentConfig, () => {
|
|||||||
if (props.open && isDirty.value) return
|
if (props.open && isDirty.value) return
|
||||||
syncFromCurrentIfChanged()
|
syncFromCurrentIfChanged()
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[availableFormatOptions, () => props.videoMode, currentEncoderBackend],
|
||||||
|
() => {
|
||||||
|
if (!selectedDevice.value) return
|
||||||
|
|
||||||
|
const currentFormat = availableFormatOptions.value.find(
|
||||||
|
format => format.format === selectedFormat.value,
|
||||||
|
)
|
||||||
|
if (currentFormat && !currentFormat.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = availableFormatOptions.value.find(format => !format.disabled)
|
||||||
|
if (!fallback) {
|
||||||
|
clearFormatSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectFormatWithDefaults(fallback.format)
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -770,6 +829,12 @@ watch(currentConfig, () => {
|
|||||||
<SelectTrigger class="h-8 text-xs">
|
<SelectTrigger class="h-8 text-xs">
|
||||||
<div v-if="selectedFormatInfo" class="flex items-center gap-1.5 truncate">
|
<div v-if="selectedFormatInfo" class="flex items-center gap-1.5 truncate">
|
||||||
<span class="truncate">{{ selectedFormatInfo.description }}</span>
|
<span class="truncate">{{ selectedFormatInfo.description }}</span>
|
||||||
|
<span
|
||||||
|
v-if="selectedFormatInfo.state === 'unsupported'"
|
||||||
|
class="shrink-0 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('common.notSupportedYet') }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isFormatRecommended(selectedFormatInfo.format)"
|
v-if="isFormatRecommended(selectedFormatInfo.format)"
|
||||||
class="text-[10px] px-1 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 shrink-0"
|
class="text-[10px] px-1 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 shrink-0"
|
||||||
@@ -787,13 +852,20 @@ watch(currentConfig, () => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
v-for="format in availableFormats"
|
v-for="format in availableFormatOptions"
|
||||||
:key="format.format"
|
:key="format.format"
|
||||||
:value="format.format"
|
:value="format.format"
|
||||||
class="text-xs"
|
:disabled="format.disabled"
|
||||||
|
:class="['text-xs', { 'opacity-50': format.disabled }]"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span>{{ format.description }}</span>
|
<span>{{ format.description }}</span>
|
||||||
|
<span
|
||||||
|
v-if="format.state === 'unsupported'"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('common.notSupportedYet') }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isFormatRecommended(format.format)"
|
v-if="isFormatRecommended(format.format)"
|
||||||
class="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
class="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import Keyboard from 'simple-keyboard'
|
import Keyboard from 'simple-keyboard'
|
||||||
import 'simple-keyboard/build/css/index.css'
|
import 'simple-keyboard/build/css/index.css'
|
||||||
import { hidApi } from '@/api'
|
import { hidApi } from '@/api'
|
||||||
|
import { CanonicalKey } from '@/types/generated'
|
||||||
import {
|
import {
|
||||||
keys,
|
keys,
|
||||||
consumerKeys,
|
consumerKeys,
|
||||||
latchingKeys,
|
latchingKeys,
|
||||||
modifiers,
|
modifiers,
|
||||||
updateModifierMaskForHidKey,
|
updateModifierMaskForKey,
|
||||||
type KeyName,
|
type KeyName,
|
||||||
type ConsumerKeyName,
|
type ConsumerKeyName,
|
||||||
} from '@/lib/keyboardMappings'
|
} from '@/lib/keyboardMappings'
|
||||||
@@ -23,13 +24,16 @@ import {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
attached?: boolean
|
attached?: boolean
|
||||||
|
capsLock?: boolean
|
||||||
|
pressedKeys?: CanonicalKey[]
|
||||||
|
consumerEnabled?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:visible', value: boolean): void
|
(e: 'update:visible', value: boolean): void
|
||||||
(e: 'update:attached', value: boolean): void
|
(e: 'update:attached', value: boolean): void
|
||||||
(e: 'keyDown', key: string): void
|
(e: 'keyDown', key: CanonicalKey): void
|
||||||
(e: 'keyUp', key: string): void
|
(e: 'keyUp', key: CanonicalKey): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -45,13 +49,17 @@ const arrowsKeyboard = ref<Keyboard | null>(null)
|
|||||||
|
|
||||||
// Pressed keys tracking
|
// Pressed keys tracking
|
||||||
const pressedModifiers = ref<number>(0)
|
const pressedModifiers = ref<number>(0)
|
||||||
const keysDown = ref<string[]>([])
|
const keysDown = ref<CanonicalKey[]>([])
|
||||||
|
|
||||||
// Shift state for display
|
// Shift state for display
|
||||||
const isShiftActive = computed(() => {
|
const isShiftActive = computed(() => {
|
||||||
return (pressedModifiers.value & 0x22) !== 0
|
return (pressedModifiers.value & 0x22) !== 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const areLettersUppercase = computed(() => {
|
||||||
|
return Boolean(props.capsLock) !== isShiftActive.value
|
||||||
|
})
|
||||||
|
|
||||||
const layoutName = computed(() => {
|
const layoutName = computed(() => {
|
||||||
return isShiftActive.value ? 'shift' : 'default'
|
return isShiftActive.value ? 'shift' : 'default'
|
||||||
})
|
})
|
||||||
@@ -63,7 +71,12 @@ const keyNamesForDownKeys = computed(() => {
|
|||||||
.filter(([_, mask]) => (activeModifierMask & mask) !== 0)
|
.filter(([_, mask]) => (activeModifierMask & mask) !== 0)
|
||||||
.map(([name]) => name)
|
.map(([name]) => name)
|
||||||
|
|
||||||
return [...modifierNames, ...keysDown.value, ' ']
|
return Array.from(new Set([
|
||||||
|
...modifierNames,
|
||||||
|
...(props.pressedKeys ?? []),
|
||||||
|
...keysDown.value,
|
||||||
|
...(props.capsLock ? ['CapsLock'] : []),
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Dragging state (for floating mode)
|
// Dragging state (for floating mode)
|
||||||
@@ -88,7 +101,7 @@ const keyboardLayout = {
|
|||||||
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
|
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
|
||||||
'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter',
|
'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter',
|
||||||
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight',
|
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight',
|
||||||
'ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight',
|
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
|
||||||
],
|
],
|
||||||
shift: [
|
shift: [
|
||||||
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
|
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
|
||||||
@@ -97,7 +110,7 @@ const keyboardLayout = {
|
|||||||
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)',
|
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)',
|
||||||
'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter',
|
'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter',
|
||||||
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight',
|
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight',
|
||||||
'ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight',
|
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
control: {
|
control: {
|
||||||
@@ -148,11 +161,10 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
|
|||||||
ShiftLeft: 'Shift',
|
ShiftLeft: 'Shift',
|
||||||
ShiftRight: 'Shift',
|
ShiftRight: 'Shift',
|
||||||
AltLeft: 'Alt',
|
AltLeft: 'Alt',
|
||||||
AltRight: 'Alt',
|
AltRight: 'AltGr',
|
||||||
AltGr: 'AltGr',
|
|
||||||
MetaLeft: metaLabel,
|
MetaLeft: metaLabel,
|
||||||
MetaRight: metaLabel,
|
MetaRight: metaLabel,
|
||||||
Menu: 'Menu',
|
ContextMenu: 'Menu',
|
||||||
|
|
||||||
// Special keys
|
// Special keys
|
||||||
Escape: 'Esc',
|
Escape: 'Esc',
|
||||||
@@ -187,20 +199,60 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
|
|||||||
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
|
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
|
||||||
|
|
||||||
// Letters
|
// Letters
|
||||||
KeyA: 'a', KeyB: 'b', KeyC: 'c', KeyD: 'd', KeyE: 'e',
|
KeyA: areLettersUppercase.value ? 'A' : 'a',
|
||||||
KeyF: 'f', KeyG: 'g', KeyH: 'h', KeyI: 'i', KeyJ: 'j',
|
KeyB: areLettersUppercase.value ? 'B' : 'b',
|
||||||
KeyK: 'k', KeyL: 'l', KeyM: 'm', KeyN: 'n', KeyO: 'o',
|
KeyC: areLettersUppercase.value ? 'C' : 'c',
|
||||||
KeyP: 'p', KeyQ: 'q', KeyR: 'r', KeyS: 's', KeyT: 't',
|
KeyD: areLettersUppercase.value ? 'D' : 'd',
|
||||||
KeyU: 'u', KeyV: 'v', KeyW: 'w', KeyX: 'x', KeyY: 'y',
|
KeyE: areLettersUppercase.value ? 'E' : 'e',
|
||||||
KeyZ: 'z',
|
KeyF: areLettersUppercase.value ? 'F' : 'f',
|
||||||
|
KeyG: areLettersUppercase.value ? 'G' : 'g',
|
||||||
|
KeyH: areLettersUppercase.value ? 'H' : 'h',
|
||||||
|
KeyI: areLettersUppercase.value ? 'I' : 'i',
|
||||||
|
KeyJ: areLettersUppercase.value ? 'J' : 'j',
|
||||||
|
KeyK: areLettersUppercase.value ? 'K' : 'k',
|
||||||
|
KeyL: areLettersUppercase.value ? 'L' : 'l',
|
||||||
|
KeyM: areLettersUppercase.value ? 'M' : 'm',
|
||||||
|
KeyN: areLettersUppercase.value ? 'N' : 'n',
|
||||||
|
KeyO: areLettersUppercase.value ? 'O' : 'o',
|
||||||
|
KeyP: areLettersUppercase.value ? 'P' : 'p',
|
||||||
|
KeyQ: areLettersUppercase.value ? 'Q' : 'q',
|
||||||
|
KeyR: areLettersUppercase.value ? 'R' : 'r',
|
||||||
|
KeyS: areLettersUppercase.value ? 'S' : 's',
|
||||||
|
KeyT: areLettersUppercase.value ? 'T' : 't',
|
||||||
|
KeyU: areLettersUppercase.value ? 'U' : 'u',
|
||||||
|
KeyV: areLettersUppercase.value ? 'V' : 'v',
|
||||||
|
KeyW: areLettersUppercase.value ? 'W' : 'w',
|
||||||
|
KeyX: areLettersUppercase.value ? 'X' : 'x',
|
||||||
|
KeyY: areLettersUppercase.value ? 'Y' : 'y',
|
||||||
|
KeyZ: areLettersUppercase.value ? 'Z' : 'z',
|
||||||
|
|
||||||
// Capital letters
|
// Letter labels in the shifted layout follow CapsLock xor Shift too
|
||||||
'(KeyA)': 'A', '(KeyB)': 'B', '(KeyC)': 'C', '(KeyD)': 'D', '(KeyE)': 'E',
|
'(KeyA)': areLettersUppercase.value ? 'A' : 'a',
|
||||||
'(KeyF)': 'F', '(KeyG)': 'G', '(KeyH)': 'H', '(KeyI)': 'I', '(KeyJ)': 'J',
|
'(KeyB)': areLettersUppercase.value ? 'B' : 'b',
|
||||||
'(KeyK)': 'K', '(KeyL)': 'L', '(KeyM)': 'M', '(KeyN)': 'N', '(KeyO)': 'O',
|
'(KeyC)': areLettersUppercase.value ? 'C' : 'c',
|
||||||
'(KeyP)': 'P', '(KeyQ)': 'Q', '(KeyR)': 'R', '(KeyS)': 'S', '(KeyT)': 'T',
|
'(KeyD)': areLettersUppercase.value ? 'D' : 'd',
|
||||||
'(KeyU)': 'U', '(KeyV)': 'V', '(KeyW)': 'W', '(KeyX)': 'X', '(KeyY)': 'Y',
|
'(KeyE)': areLettersUppercase.value ? 'E' : 'e',
|
||||||
'(KeyZ)': 'Z',
|
'(KeyF)': areLettersUppercase.value ? 'F' : 'f',
|
||||||
|
'(KeyG)': areLettersUppercase.value ? 'G' : 'g',
|
||||||
|
'(KeyH)': areLettersUppercase.value ? 'H' : 'h',
|
||||||
|
'(KeyI)': areLettersUppercase.value ? 'I' : 'i',
|
||||||
|
'(KeyJ)': areLettersUppercase.value ? 'J' : 'j',
|
||||||
|
'(KeyK)': areLettersUppercase.value ? 'K' : 'k',
|
||||||
|
'(KeyL)': areLettersUppercase.value ? 'L' : 'l',
|
||||||
|
'(KeyM)': areLettersUppercase.value ? 'M' : 'm',
|
||||||
|
'(KeyN)': areLettersUppercase.value ? 'N' : 'n',
|
||||||
|
'(KeyO)': areLettersUppercase.value ? 'O' : 'o',
|
||||||
|
'(KeyP)': areLettersUppercase.value ? 'P' : 'p',
|
||||||
|
'(KeyQ)': areLettersUppercase.value ? 'Q' : 'q',
|
||||||
|
'(KeyR)': areLettersUppercase.value ? 'R' : 'r',
|
||||||
|
'(KeyS)': areLettersUppercase.value ? 'S' : 's',
|
||||||
|
'(KeyT)': areLettersUppercase.value ? 'T' : 't',
|
||||||
|
'(KeyU)': areLettersUppercase.value ? 'U' : 'u',
|
||||||
|
'(KeyV)': areLettersUppercase.value ? 'V' : 'v',
|
||||||
|
'(KeyW)': areLettersUppercase.value ? 'W' : 'w',
|
||||||
|
'(KeyX)': areLettersUppercase.value ? 'X' : 'x',
|
||||||
|
'(KeyY)': areLettersUppercase.value ? 'Y' : 'y',
|
||||||
|
'(KeyZ)': areLettersUppercase.value ? 'Z' : 'z',
|
||||||
|
|
||||||
// Numbers
|
// Numbers
|
||||||
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
|
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
|
||||||
@@ -303,47 +355,47 @@ async function onKeyDown(key: string) {
|
|||||||
const keyCode = keys[cleanKey as KeyName]
|
const keyCode = keys[cleanKey as KeyName]
|
||||||
|
|
||||||
// Handle latching keys (Caps Lock, etc.)
|
// Handle latching keys (Caps Lock, etc.)
|
||||||
if ((latchingKeys as readonly string[]).includes(cleanKey)) {
|
if (latchingKeys.some(latchingKey => latchingKey === keyCode)) {
|
||||||
emit('keyDown', cleanKey)
|
emit('keyDown', keyCode)
|
||||||
const currentMask = pressedModifiers.value & 0xff
|
const currentMask = pressedModifiers.value & 0xff
|
||||||
await sendKeyPress(keyCode, true, currentMask)
|
await sendKeyPress(keyCode, true, currentMask)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
sendKeyPress(keyCode, false, currentMask)
|
sendKeyPress(keyCode, false, currentMask)
|
||||||
emit('keyUp', cleanKey)
|
emit('keyUp', keyCode)
|
||||||
}, 100)
|
}, 100)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle modifier keys (toggle)
|
// Handle modifier keys (toggle)
|
||||||
if (cleanKey in modifiers) {
|
const mask = modifiers[keyCode] ?? 0
|
||||||
const mask = modifiers[cleanKey as keyof typeof modifiers]
|
if (mask !== 0) {
|
||||||
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
|
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
|
||||||
|
|
||||||
if (isCurrentlyDown) {
|
if (isCurrentlyDown) {
|
||||||
const nextMask = pressedModifiers.value & ~mask
|
const nextMask = pressedModifiers.value & ~mask
|
||||||
pressedModifiers.value = nextMask
|
pressedModifiers.value = nextMask
|
||||||
await sendKeyPress(keyCode, false, nextMask)
|
await sendKeyPress(keyCode, false, nextMask)
|
||||||
emit('keyUp', cleanKey)
|
emit('keyUp', keyCode)
|
||||||
} else {
|
} else {
|
||||||
const nextMask = pressedModifiers.value | mask
|
const nextMask = pressedModifiers.value | mask
|
||||||
pressedModifiers.value = nextMask
|
pressedModifiers.value = nextMask
|
||||||
await sendKeyPress(keyCode, true, nextMask)
|
await sendKeyPress(keyCode, true, nextMask)
|
||||||
emit('keyDown', cleanKey)
|
emit('keyDown', keyCode)
|
||||||
}
|
}
|
||||||
updateKeyboardButtonTheme()
|
updateKeyboardButtonTheme()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular key: press and release
|
// Regular key: press and release
|
||||||
keysDown.value.push(cleanKey)
|
keysDown.value.push(keyCode)
|
||||||
emit('keyDown', cleanKey)
|
emit('keyDown', keyCode)
|
||||||
const currentMask = pressedModifiers.value & 0xff
|
const currentMask = pressedModifiers.value & 0xff
|
||||||
await sendKeyPress(keyCode, true, currentMask)
|
await sendKeyPress(keyCode, true, currentMask)
|
||||||
updateKeyboardButtonTheme()
|
updateKeyboardButtonTheme()
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
keysDown.value = keysDown.value.filter(k => k !== cleanKey)
|
keysDown.value = keysDown.value.filter(k => k !== keyCode)
|
||||||
await sendKeyPress(keyCode, false, currentMask)
|
await sendKeyPress(keyCode, false, currentMask)
|
||||||
emit('keyUp', cleanKey)
|
emit('keyUp', keyCode)
|
||||||
updateKeyboardButtonTheme()
|
updateKeyboardButtonTheme()
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
@@ -352,7 +404,7 @@ async function onKeyUp() {
|
|||||||
// Not used for now - we handle up in onKeyDown with setTimeout
|
// Not used for now - we handle up in onKeyDown with setTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendKeyPress(keyCode: number, press: boolean, modifierMask: number) {
|
async function sendKeyPress(keyCode: CanonicalKey, press: boolean, modifierMask: number) {
|
||||||
try {
|
try {
|
||||||
await hidApi.keyboard(press ? 'down' : 'up', keyCode, modifierMask & 0xff)
|
await hidApi.keyboard(press ? 'down' : 'up', keyCode, modifierMask & 0xff)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -372,7 +424,7 @@ async function executeMacro(steps: MacroStep[]) {
|
|||||||
for (const mod of step.modifiers) {
|
for (const mod of step.modifiers) {
|
||||||
if (mod in keys) {
|
if (mod in keys) {
|
||||||
const modHid = keys[mod as KeyName]
|
const modHid = keys[mod as KeyName]
|
||||||
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, true)
|
macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, true)
|
||||||
await sendKeyPress(modHid, true, macroModifierMask)
|
await sendKeyPress(modHid, true, macroModifierMask)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +446,7 @@ async function executeMacro(steps: MacroStep[]) {
|
|||||||
for (const mod of step.modifiers) {
|
for (const mod of step.modifiers) {
|
||||||
if (mod in keys) {
|
if (mod in keys) {
|
||||||
const modHid = keys[mod as KeyName]
|
const modHid = keys[mod as KeyName]
|
||||||
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, false)
|
macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, false)
|
||||||
await sendKeyPress(modHid, false, macroModifierMask)
|
await sendKeyPress(modHid, false, macroModifierMask)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,8 +473,12 @@ function updateKeyboardButtonTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update layout when shift state changes
|
// Update layout when shift state changes
|
||||||
watch(layoutName, (name) => {
|
watch([layoutName, () => props.capsLock], ([name]) => {
|
||||||
mainKeyboard.value?.setOptions({ layoutName: name })
|
mainKeyboard.value?.setOptions({
|
||||||
|
layoutName: name,
|
||||||
|
display: keyDisplayMap.value,
|
||||||
|
})
|
||||||
|
updateKeyboardButtonTheme()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize keyboards with unique selectors
|
// Initialize keyboards with unique selectors
|
||||||
@@ -663,7 +719,7 @@ onUnmounted(() => {
|
|||||||
<!-- Keyboard body -->
|
<!-- Keyboard body -->
|
||||||
<div class="vkb-body">
|
<div class="vkb-body">
|
||||||
<!-- Media keys row -->
|
<!-- Media keys row -->
|
||||||
<div class="vkb-media-row">
|
<div v-if="props.consumerEnabled !== false" class="vkb-media-row">
|
||||||
<button
|
<button
|
||||||
v-for="key in mediaKeys"
|
v-for="key in mediaKeys"
|
||||||
:key="key"
|
:key="key"
|
||||||
@@ -835,12 +891,12 @@ onUnmounted(() => {
|
|||||||
min-width: 55px;
|
min-width: 55px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"] {
|
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"] {
|
||||||
flex-grow: 1.25;
|
flex-grow: 1.25;
|
||||||
min-width: 55px;
|
min-width: 55px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] {
|
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
|
||||||
flex-grow: 1.25;
|
flex-grow: 1.25;
|
||||||
min-width: 55px;
|
min-width: 55px;
|
||||||
}
|
}
|
||||||
@@ -1194,8 +1250,8 @@ html.dark .hg-theme-default .hg-button.down-key,
|
|||||||
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
|
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
|
||||||
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
|
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
|
||||||
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
|
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
|
||||||
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"],
|
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
|
||||||
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] {
|
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
|
||||||
min-width: 46px;
|
min-width: 46px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useSystemStore } from '@/stores/system'
|
import { useSystemStore } from '@/stores/system'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
|
||||||
|
|
||||||
export interface ConsoleEventHandlers {
|
export interface ConsoleEventHandlers {
|
||||||
onStreamConfigChanging?: (data: { reason?: string }) => void
|
onStreamConfigChanging?: (data: { reason?: string }) => void
|
||||||
@@ -20,119 +19,13 @@ export interface ConsoleEventHandlers {
|
|||||||
onStreamReconnecting?: (data: { device: string; attempt: number }) => void
|
onStreamReconnecting?: (data: { device: string; attempt: number }) => void
|
||||||
onStreamRecovered?: (data: { device: string }) => void
|
onStreamRecovered?: (data: { device: string }) => void
|
||||||
onDeviceInfo?: (data: any) => void
|
onDeviceInfo?: (data: any) => void
|
||||||
onAudioStateChanged?: (data: { streaming: boolean; device: string | null }) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
const { on, off, connect } = useWebSocket()
|
const { on, off, connect } = useWebSocket()
|
||||||
const unifiedAudio = getUnifiedAudio()
|
|
||||||
const noop = () => {}
|
const noop = () => {}
|
||||||
const HID_TOAST_DEDUPE_MS = 30_000
|
|
||||||
const hidLastToastAt = new Map<string, number>()
|
|
||||||
|
|
||||||
function hidErrorHint(errorCode?: string, backend?: string): string {
|
|
||||||
switch (errorCode) {
|
|
||||||
case 'udc_not_configured':
|
|
||||||
return t('hid.errorHints.udcNotConfigured')
|
|
||||||
case 'enoent':
|
|
||||||
return t('hid.errorHints.hidDeviceMissing')
|
|
||||||
case 'port_not_found':
|
|
||||||
case 'port_not_opened':
|
|
||||||
return t('hid.errorHints.portNotFound')
|
|
||||||
case 'no_response':
|
|
||||||
return t('hid.errorHints.noResponse')
|
|
||||||
case 'protocol_error':
|
|
||||||
case 'invalid_response':
|
|
||||||
return t('hid.errorHints.protocolError')
|
|
||||||
case 'health_check_failed':
|
|
||||||
case 'health_check_join_failed':
|
|
||||||
return t('hid.errorHints.healthCheckFailed')
|
|
||||||
case 'eio':
|
|
||||||
case 'epipe':
|
|
||||||
case 'eshutdown':
|
|
||||||
if (backend === 'otg') {
|
|
||||||
return t('hid.errorHints.otgIoError')
|
|
||||||
}
|
|
||||||
if (backend === 'ch9329') {
|
|
||||||
return t('hid.errorHints.ch9329IoError')
|
|
||||||
}
|
|
||||||
return t('hid.errorHints.ioError')
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHidReason(reason: string, errorCode?: string, backend?: string): string {
|
|
||||||
const hint = hidErrorHint(errorCode, backend)
|
|
||||||
if (!hint) return reason
|
|
||||||
return `${reason} (${hint})`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HID event handlers
|
|
||||||
function handleHidStateChanged(data: {
|
|
||||||
backend: string
|
|
||||||
initialized: boolean
|
|
||||||
error?: string | null
|
|
||||||
error_code?: string | null
|
|
||||||
}) {
|
|
||||||
systemStore.updateHidStateFromEvent({
|
|
||||||
backend: data.backend,
|
|
||||||
initialized: data.initialized,
|
|
||||||
error: data.error ?? null,
|
|
||||||
error_code: data.error_code ?? null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHidDeviceLost(data: { backend: string; device?: string; reason: string; error_code: string }) {
|
|
||||||
const temporaryErrors = ['eagain', 'eagain_retry']
|
|
||||||
if (temporaryErrors.includes(data.error_code)) return
|
|
||||||
|
|
||||||
systemStore.updateHidStateFromEvent({
|
|
||||||
backend: data.backend,
|
|
||||||
initialized: false,
|
|
||||||
error: data.reason,
|
|
||||||
error_code: data.error_code,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dedupeKey = `${data.backend}:${data.error_code}`
|
|
||||||
const now = Date.now()
|
|
||||||
const last = hidLastToastAt.get(dedupeKey) ?? 0
|
|
||||||
if (now - last < HID_TOAST_DEDUPE_MS) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hidLastToastAt.set(dedupeKey, now)
|
|
||||||
|
|
||||||
const reason = formatHidReason(data.reason, data.error_code, data.backend)
|
|
||||||
toast.error(t('hid.deviceLost'), {
|
|
||||||
description: t('hid.deviceLostDesc', { backend: data.backend, reason }),
|
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHidReconnecting(data: { backend: string; attempt: number }) {
|
|
||||||
if (data.attempt === 1 || data.attempt % 5 === 0) {
|
|
||||||
toast.info(t('hid.reconnecting'), {
|
|
||||||
description: t('hid.reconnectingDesc', { attempt: data.attempt }),
|
|
||||||
duration: 3000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHidRecovered(data: { backend: string }) {
|
|
||||||
systemStore.updateHidStateFromEvent({
|
|
||||||
backend: data.backend,
|
|
||||||
initialized: true,
|
|
||||||
error: null,
|
|
||||||
error_code: null,
|
|
||||||
})
|
|
||||||
toast.success(t('hid.recovered'), {
|
|
||||||
description: t('hid.recoveredDesc', { backend: data.backend }),
|
|
||||||
duration: 3000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream device monitoring handlers
|
// Stream device monitoring handlers
|
||||||
function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
||||||
if (systemStore.stream) {
|
if (systemStore.stream) {
|
||||||
@@ -177,93 +70,8 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
|||||||
handlers.onStreamStateChanged?.(data)
|
handlers.onStreamStateChanged?.(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio device monitoring handlers
|
|
||||||
function handleAudioDeviceLost(data: { device?: string; reason: string; error_code: string }) {
|
|
||||||
if (systemStore.audio) {
|
|
||||||
systemStore.audio.streaming = false
|
|
||||||
systemStore.audio.error = data.reason
|
|
||||||
}
|
|
||||||
toast.error(t('audio.deviceLost'), {
|
|
||||||
description: t('audio.deviceLostDesc', { device: data.device || 'default', reason: data.reason }),
|
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAudioReconnecting(data: { attempt: number }) {
|
|
||||||
if (data.attempt === 1 || data.attempt % 5 === 0) {
|
|
||||||
toast.info(t('audio.reconnecting'), {
|
|
||||||
description: t('audio.reconnectingDesc', { attempt: data.attempt }),
|
|
||||||
duration: 3000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAudioRecovered(data: { device?: string }) {
|
|
||||||
if (systemStore.audio) {
|
|
||||||
systemStore.audio.error = null
|
|
||||||
}
|
|
||||||
toast.success(t('audio.recovered'), {
|
|
||||||
description: t('audio.recoveredDesc', { device: data.device || 'default' }),
|
|
||||||
duration: 3000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAudioStateChanged(data: { streaming: boolean; device: string | null }) {
|
|
||||||
if (!data.streaming) {
|
|
||||||
unifiedAudio.disconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handlers.onAudioStateChanged?.(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MSD event handlers
|
|
||||||
function handleMsdStateChanged(_data: { mode: string; connected: boolean }) {
|
|
||||||
systemStore.fetchMsdState().catch(() => null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMsdImageMounted(data: { image_id: string; image_name: string; size: number; cdrom: boolean }) {
|
|
||||||
toast.success(t('msd.imageMounted', { name: data.image_name }), {
|
|
||||||
description: `${(data.size / 1024 / 1024).toFixed(2)} MB - ${data.cdrom ? 'CD-ROM' : 'Disk'}`,
|
|
||||||
duration: 3000,
|
|
||||||
})
|
|
||||||
systemStore.fetchMsdState().catch(() => null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMsdImageUnmounted() {
|
|
||||||
toast.info(t('msd.imageUnmounted'), {
|
|
||||||
duration: 2000,
|
|
||||||
})
|
|
||||||
systemStore.fetchMsdState().catch(() => null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMsdError(data: { reason: string; error_code: string }) {
|
|
||||||
if (systemStore.msd) {
|
|
||||||
systemStore.msd.error = data.reason
|
|
||||||
}
|
|
||||||
toast.error(t('msd.error'), {
|
|
||||||
description: t('msd.errorDesc', { reason: data.reason }),
|
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMsdRecovered() {
|
|
||||||
if (systemStore.msd) {
|
|
||||||
systemStore.msd.error = null
|
|
||||||
}
|
|
||||||
toast.success(t('msd.recovered'), {
|
|
||||||
description: t('msd.recoveredDesc'),
|
|
||||||
duration: 3000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to all events
|
// Subscribe to all events
|
||||||
function subscribe() {
|
function subscribe() {
|
||||||
// HID events
|
|
||||||
on('hid.state_changed', handleHidStateChanged)
|
|
||||||
on('hid.device_lost', handleHidDeviceLost)
|
|
||||||
on('hid.reconnecting', handleHidReconnecting)
|
|
||||||
on('hid.recovered', handleHidRecovered)
|
|
||||||
|
|
||||||
// Stream events
|
// Stream events
|
||||||
on('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
|
on('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
|
||||||
on('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
|
on('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
|
||||||
@@ -277,19 +85,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
|||||||
on('stream.reconnecting', handleStreamReconnecting)
|
on('stream.reconnecting', handleStreamReconnecting)
|
||||||
on('stream.recovered', handleStreamRecovered)
|
on('stream.recovered', handleStreamRecovered)
|
||||||
|
|
||||||
// Audio events
|
|
||||||
on('audio.state_changed', handleAudioStateChanged)
|
|
||||||
on('audio.device_lost', handleAudioDeviceLost)
|
|
||||||
on('audio.reconnecting', handleAudioReconnecting)
|
|
||||||
on('audio.recovered', handleAudioRecovered)
|
|
||||||
|
|
||||||
// MSD events
|
|
||||||
on('msd.state_changed', handleMsdStateChanged)
|
|
||||||
on('msd.image_mounted', handleMsdImageMounted)
|
|
||||||
on('msd.image_unmounted', handleMsdImageUnmounted)
|
|
||||||
on('msd.error', handleMsdError)
|
|
||||||
on('msd.recovered', handleMsdRecovered)
|
|
||||||
|
|
||||||
// System events
|
// System events
|
||||||
on('system.device_info', handlers.onDeviceInfo ?? noop)
|
on('system.device_info', handlers.onDeviceInfo ?? noop)
|
||||||
|
|
||||||
@@ -299,11 +94,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
|||||||
|
|
||||||
// Unsubscribe from all events
|
// Unsubscribe from all events
|
||||||
function unsubscribe() {
|
function unsubscribe() {
|
||||||
off('hid.state_changed', handleHidStateChanged)
|
|
||||||
off('hid.device_lost', handleHidDeviceLost)
|
|
||||||
off('hid.reconnecting', handleHidReconnecting)
|
|
||||||
off('hid.recovered', handleHidRecovered)
|
|
||||||
|
|
||||||
off('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
|
off('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
|
||||||
off('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
|
off('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
|
||||||
off('stream.stats_update', handlers.onStreamStatsUpdate ?? noop)
|
off('stream.stats_update', handlers.onStreamStatsUpdate ?? noop)
|
||||||
@@ -316,17 +106,6 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
|||||||
off('stream.reconnecting', handleStreamReconnecting)
|
off('stream.reconnecting', handleStreamReconnecting)
|
||||||
off('stream.recovered', handleStreamRecovered)
|
off('stream.recovered', handleStreamRecovered)
|
||||||
|
|
||||||
off('audio.state_changed', handleAudioStateChanged)
|
|
||||||
off('audio.device_lost', handleAudioDeviceLost)
|
|
||||||
off('audio.reconnecting', handleAudioReconnecting)
|
|
||||||
off('audio.recovered', handleAudioRecovered)
|
|
||||||
|
|
||||||
off('msd.state_changed', handleMsdStateChanged)
|
|
||||||
off('msd.image_mounted', handleMsdImageMounted)
|
|
||||||
off('msd.image_unmounted', handleMsdImageUnmounted)
|
|
||||||
off('msd.error', handleMsdError)
|
|
||||||
off('msd.recovered', handleMsdRecovered)
|
|
||||||
|
|
||||||
off('system.device_info', handlers.onDeviceInfo ?? noop)
|
off('system.device_info', handlers.onDeviceInfo ?? noop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 wsInstance: WebSocket | null = null
|
||||||
let handlers = new Map<string, EventHandler[]>()
|
let handlers = new Map<string, EventHandler[]>()
|
||||||
|
let subscribedTopics: string[] = []
|
||||||
const connected = ref(false)
|
const connected = ref(false)
|
||||||
const reconnectAttempts = ref(0)
|
const reconnectAttempts = ref(0)
|
||||||
const networkError = ref(false)
|
const networkError = ref(false)
|
||||||
const networkErrorMessage = ref<string | null>(null)
|
const networkErrorMessage = ref<string | null>(null)
|
||||||
|
|
||||||
|
function getSubscribedTopics(): string[] {
|
||||||
|
return Array.from(handlers.entries())
|
||||||
|
.filter(([, eventHandlers]) => eventHandlers.length > 0)
|
||||||
|
.map(([event]) => event)
|
||||||
|
.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function arraysEqual(a: string[], b: string[]): boolean {
|
||||||
|
return a.length === b.length && a.every((value, index) => value === b[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSubscriptions() {
|
||||||
|
const topics = getSubscribedTopics()
|
||||||
|
|
||||||
|
if (arraysEqual(topics, subscribedTopics)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribedTopics = topics
|
||||||
|
|
||||||
|
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
|
||||||
|
subscribe(topics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
|
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
|
||||||
|
syncSubscriptions()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +64,7 @@ function connect() {
|
|||||||
networkErrorMessage.value = null
|
networkErrorMessage.value = null
|
||||||
reconnectAttempts.value = 0
|
reconnectAttempts.value = 0
|
||||||
|
|
||||||
// Subscribe to all events by default
|
syncSubscriptions()
|
||||||
subscribe(['*'])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wsInstance.onmessage = (e) => {
|
wsInstance.onmessage = (e) => {
|
||||||
@@ -78,6 +104,7 @@ function disconnect() {
|
|||||||
wsInstance.close()
|
wsInstance.close()
|
||||||
wsInstance = null
|
wsInstance = null
|
||||||
}
|
}
|
||||||
|
subscribedTopics = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribe(topics: string[]) {
|
function subscribe(topics: string[]) {
|
||||||
@@ -94,6 +121,7 @@ function on(event: string, handler: EventHandler) {
|
|||||||
handlers.set(event, [])
|
handlers.set(event, [])
|
||||||
}
|
}
|
||||||
handlers.get(event)!.push(handler)
|
handlers.get(event)!.push(handler)
|
||||||
|
syncSubscriptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
function off(event: string, handler: EventHandler) {
|
function off(event: string, handler: EventHandler) {
|
||||||
@@ -103,8 +131,12 @@ function off(event: string, handler: EventHandler) {
|
|||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
eventHandlers.splice(index, 1)
|
eventHandlers.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
if (eventHandlers.length === 0) {
|
||||||
|
handlers.delete(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncSubscriptions()
|
||||||
|
}
|
||||||
|
|
||||||
function handleEvent(payload: WsEvent) {
|
function handleEvent(payload: WsEvent) {
|
||||||
const eventName = payload.event
|
const eventName = payload.event
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default {
|
|||||||
menu: 'Menu',
|
menu: 'Menu',
|
||||||
optional: 'Optional',
|
optional: 'Optional',
|
||||||
recommended: 'Recommended',
|
recommended: 'Recommended',
|
||||||
|
notSupportedYet: ' (Not Yet Supported)',
|
||||||
create: 'Create',
|
create: 'Create',
|
||||||
creating: 'Creating...',
|
creating: 'Creating...',
|
||||||
deleting: 'Deleting...',
|
deleting: 'Deleting...',
|
||||||
@@ -61,7 +62,6 @@ export default {
|
|||||||
password: 'Password',
|
password: 'Password',
|
||||||
enterUsername: 'Enter username',
|
enterUsername: 'Enter username',
|
||||||
enterPassword: 'Enter password',
|
enterPassword: 'Enter password',
|
||||||
loginPrompt: 'Enter your credentials to login',
|
|
||||||
loginFailed: 'Login failed',
|
loginFailed: 'Login failed',
|
||||||
invalidPassword: 'Invalid username or password',
|
invalidPassword: 'Invalid username or password',
|
||||||
changePassword: 'Change Password',
|
changePassword: 'Change Password',
|
||||||
@@ -169,6 +169,7 @@ export default {
|
|||||||
caps: 'Caps',
|
caps: 'Caps',
|
||||||
num: 'Num',
|
num: 'Num',
|
||||||
scroll: 'Scroll',
|
scroll: 'Scroll',
|
||||||
|
keyboardLedUnavailable: 'Keyboard LED status is disabled or unsupported',
|
||||||
},
|
},
|
||||||
paste: {
|
paste: {
|
||||||
title: 'Paste Text',
|
title: 'Paste Text',
|
||||||
@@ -270,7 +271,7 @@ export default {
|
|||||||
otgAdvanced: 'Advanced: OTG Preset',
|
otgAdvanced: 'Advanced: OTG Preset',
|
||||||
otgProfile: 'Initial HID Preset',
|
otgProfile: 'Initial HID Preset',
|
||||||
otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.',
|
otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.',
|
||||||
otgLowEndpointHint: 'Detected low-endpoint UDC; multimedia keys will be disabled automatically.',
|
otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.',
|
||||||
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
|
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
|
||||||
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
|
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
|
||||||
// Extensions
|
// Extensions
|
||||||
@@ -362,15 +363,22 @@ export default {
|
|||||||
recovered: 'HID Recovered',
|
recovered: 'HID Recovered',
|
||||||
recoveredDesc: '{backend} HID device reconnected successfully',
|
recoveredDesc: '{backend} HID device reconnected successfully',
|
||||||
errorHints: {
|
errorHints: {
|
||||||
udcNotConfigured: 'Target host has not finished USB enumeration yet',
|
udcNotConfigured: 'OTG is ready, waiting for the target host to connect and finish USB enumeration',
|
||||||
|
disabled: 'HID backend is disabled',
|
||||||
hidDeviceMissing: 'HID gadget device node is missing, try restarting HID service',
|
hidDeviceMissing: 'HID gadget device node is missing, try restarting HID service',
|
||||||
|
notOpened: 'HID device is not open, try restarting HID service',
|
||||||
portNotFound: 'Serial port not found, check CH9329 wiring and device path',
|
portNotFound: 'Serial port not found, check CH9329 wiring and device path',
|
||||||
noResponse: 'No response from CH9329, check baud rate and power',
|
noResponse: 'No response from CH9329, check baud rate and power',
|
||||||
|
noResponseWithCmd: 'No response from CH9329, check baud rate and power (cmd {cmd})',
|
||||||
|
invalidConfig: 'Serial port parameters are invalid, check device path and baud rate',
|
||||||
protocolError: 'CH9329 replied with invalid protocol data',
|
protocolError: 'CH9329 replied with invalid protocol data',
|
||||||
healthCheckFailed: 'Background health check failed',
|
deviceDisconnected: 'HID device disconnected, check cable and host port',
|
||||||
ioError: 'I/O communication error detected',
|
ioError: 'I/O communication error detected',
|
||||||
otgIoError: 'OTG link is unstable, check USB cable and host port',
|
otgIoError: 'OTG link is unstable, check USB cable and host port',
|
||||||
ch9329IoError: 'CH9329 serial link is unstable, check wiring and power',
|
ch9329IoError: 'CH9329 serial link is unstable, check wiring and power',
|
||||||
|
serialError: 'Serial communication error, check CH9329 wiring and config',
|
||||||
|
initFailed: 'CH9329 initialization failed, check serial settings and power',
|
||||||
|
shutdown: 'HID backend has stopped',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
@@ -644,28 +652,28 @@ export default {
|
|||||||
hidBackend: 'HID Backend',
|
hidBackend: 'HID Backend',
|
||||||
serialDevice: 'Serial Device',
|
serialDevice: 'Serial Device',
|
||||||
baudRate: 'Baud Rate',
|
baudRate: 'Baud Rate',
|
||||||
otgHidProfile: 'OTG HID Profile',
|
otgHidProfile: 'OTG HID Functions',
|
||||||
otgHidProfileDesc: 'Select which HID functions are exposed to the host',
|
otgHidProfileDesc: 'Select which HID functions are exposed to the host',
|
||||||
profile: 'Profile',
|
otgEndpointBudget: 'Max Endpoints',
|
||||||
otgProfileFull: 'Keyboard + relative mouse + absolute mouse + multimedia + MSD',
|
otgEndpointBudgetUnlimited: 'Unlimited',
|
||||||
otgProfileFullNoMsd: 'Keyboard + relative mouse + absolute mouse + multimedia (no MSD)',
|
otgEndpointBudgetHint: 'This is a hardware limit. If the OTG selection exceeds the real hardware endpoint count, OTG will fail.',
|
||||||
otgProfileFullNoConsumer: 'Keyboard + relative mouse + absolute mouse + MSD (no multimedia)',
|
otgEndpointUsage: 'Endpoint usage: {used} / {limit}',
|
||||||
otgProfileFullNoConsumerNoMsd: 'Keyboard + relative mouse + absolute mouse (no multimedia, no MSD)',
|
otgEndpointUsageUnlimited: 'Endpoint usage: {used} / unlimited',
|
||||||
otgProfileLegacyKeyboard: 'Keyboard only',
|
otgEndpointExceeded: 'The current OTG selection needs {used} endpoints, exceeding the limit {limit}.',
|
||||||
otgProfileLegacyMouseRelative: 'Relative mouse only',
|
|
||||||
otgProfileCustom: 'Custom',
|
|
||||||
otgFunctionKeyboard: 'Keyboard',
|
otgFunctionKeyboard: 'Keyboard',
|
||||||
otgFunctionKeyboardDesc: 'Standard HID keyboard device',
|
otgFunctionKeyboardDesc: 'Standard HID keyboard device',
|
||||||
|
otgKeyboardLeds: 'Keyboard LED Status',
|
||||||
|
otgKeyboardLedsDesc: 'Enable Caps/Num/Scroll LED feedback from the host',
|
||||||
otgFunctionMouseRelative: 'Relative Mouse',
|
otgFunctionMouseRelative: 'Relative Mouse',
|
||||||
otgFunctionMouseRelativeDesc: 'Traditional mouse movement (HID boot mouse)',
|
otgFunctionMouseRelativeDesc: 'Traditional mouse movement (HID boot mouse)',
|
||||||
otgFunctionMouseAbsolute: 'Absolute Mouse',
|
otgFunctionMouseAbsolute: 'Absolute Mouse',
|
||||||
otgFunctionMouseAbsoluteDesc: 'Absolute positioning (touchscreen-like)',
|
otgFunctionMouseAbsoluteDesc: 'Absolute positioning (touchscreen-like)',
|
||||||
otgFunctionConsumer: 'Consumer Control',
|
otgFunctionConsumer: 'Consumer Control Keyboard',
|
||||||
otgFunctionConsumerDesc: 'Media keys like volume/play/pause',
|
otgFunctionConsumerDesc: 'Consumer Control keys such as volume/play/pause',
|
||||||
otgFunctionMsd: 'Mass Storage (MSD)',
|
otgFunctionMsd: 'Mass Storage (MSD)',
|
||||||
otgFunctionMsdDesc: 'Expose USB storage to the host',
|
otgFunctionMsdDesc: 'Expose USB storage to the host',
|
||||||
otgProfileWarning: 'Changing HID functions will reconnect the USB device',
|
otgProfileWarning: 'Changing HID functions will reconnect the USB device',
|
||||||
otgLowEndpointHint: 'Low-endpoint UDC detected; multimedia keys will be disabled automatically.',
|
otgLowEndpointHint: 'Low-endpoint UDC detected; Consumer Control Keyboard will be disabled automatically.',
|
||||||
otgFunctionMinWarning: 'Enable at least one HID function before saving',
|
otgFunctionMinWarning: 'Enable at least one HID function before saving',
|
||||||
// OTG Descriptor
|
// OTG Descriptor
|
||||||
otgDescriptor: 'USB Device Descriptor',
|
otgDescriptor: 'USB Device Descriptor',
|
||||||
@@ -757,6 +765,15 @@ export default {
|
|||||||
udc_speed: 'Device may not be fully enumerated; try reconnecting USB',
|
udc_speed: 'Device may not be fully enumerated; try reconnecting USB',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
encoderSelfCheck: {
|
||||||
|
title: 'Hardware Encoding Capability Test',
|
||||||
|
desc: 'Test hardware encoding capability across 720p, 1080p, 2K, and 4K',
|
||||||
|
run: 'Start Test',
|
||||||
|
failed: 'Failed to run hardware encoding capability test',
|
||||||
|
resolution: 'Resolution',
|
||||||
|
currentHardwareEncoder: 'Current Hardware Encoder',
|
||||||
|
none: 'None',
|
||||||
|
},
|
||||||
// WebRTC / ICE
|
// WebRTC / ICE
|
||||||
webrtcSettings: 'WebRTC Settings',
|
webrtcSettings: 'WebRTC Settings',
|
||||||
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
|
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
|
||||||
@@ -783,7 +800,7 @@ export default {
|
|||||||
osWindows: 'Windows',
|
osWindows: 'Windows',
|
||||||
osMac: 'Mac',
|
osMac: 'Mac',
|
||||||
osAndroid: 'Android',
|
osAndroid: 'Android',
|
||||||
mediaKeys: 'Media Keys',
|
mediaKeys: 'Consumer Control Keyboard',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
applied: 'Configuration applied',
|
applied: 'Configuration applied',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default {
|
|||||||
menu: '菜单',
|
menu: '菜单',
|
||||||
optional: '可选',
|
optional: '可选',
|
||||||
recommended: '推荐',
|
recommended: '推荐',
|
||||||
|
notSupportedYet: '(尚未支持)',
|
||||||
create: '创建',
|
create: '创建',
|
||||||
creating: '创建中...',
|
creating: '创建中...',
|
||||||
deleting: '删除中...',
|
deleting: '删除中...',
|
||||||
@@ -61,7 +62,6 @@ export default {
|
|||||||
password: '密码',
|
password: '密码',
|
||||||
enterUsername: '请输入用户名',
|
enterUsername: '请输入用户名',
|
||||||
enterPassword: '请输入密码',
|
enterPassword: '请输入密码',
|
||||||
loginPrompt: '请输入您的账号和密码',
|
|
||||||
loginFailed: '登录失败',
|
loginFailed: '登录失败',
|
||||||
invalidPassword: '用户名或密码错误',
|
invalidPassword: '用户名或密码错误',
|
||||||
changePassword: '修改密码',
|
changePassword: '修改密码',
|
||||||
@@ -169,6 +169,7 @@ export default {
|
|||||||
caps: 'Caps',
|
caps: 'Caps',
|
||||||
num: 'Num',
|
num: 'Num',
|
||||||
scroll: 'Scroll',
|
scroll: 'Scroll',
|
||||||
|
keyboardLedUnavailable: '键盘状态灯功能未开启或不支持',
|
||||||
},
|
},
|
||||||
paste: {
|
paste: {
|
||||||
title: '粘贴文本',
|
title: '粘贴文本',
|
||||||
@@ -270,7 +271,7 @@ export default {
|
|||||||
otgAdvanced: '高级:OTG 预设',
|
otgAdvanced: '高级:OTG 预设',
|
||||||
otgProfile: '初始 HID 预设',
|
otgProfile: '初始 HID 预设',
|
||||||
otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。',
|
otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。',
|
||||||
otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键。',
|
otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键盘。',
|
||||||
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
|
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
|
||||||
videoFormatHelp: 'MJPEG 格式兼容性最好,H.264/H.265 带宽占用更低但需要编码支持。',
|
videoFormatHelp: 'MJPEG 格式兼容性最好,H.264/H.265 带宽占用更低但需要编码支持。',
|
||||||
// Extensions
|
// Extensions
|
||||||
@@ -362,15 +363,22 @@ export default {
|
|||||||
recovered: 'HID 已恢复',
|
recovered: 'HID 已恢复',
|
||||||
recoveredDesc: '{backend} HID 设备已成功重连',
|
recoveredDesc: '{backend} HID 设备已成功重连',
|
||||||
errorHints: {
|
errorHints: {
|
||||||
udcNotConfigured: '被控机尚未完成 USB 枚举',
|
udcNotConfigured: 'OTG 已就绪,等待被控机连接并完成 USB 枚举',
|
||||||
|
disabled: 'HID 后端已禁用',
|
||||||
hidDeviceMissing: '未找到 HID 设备节点,可尝试重启 HID 服务',
|
hidDeviceMissing: '未找到 HID 设备节点,可尝试重启 HID 服务',
|
||||||
|
notOpened: 'HID 设备尚未打开,可尝试重启 HID 服务',
|
||||||
portNotFound: '找不到串口设备,请检查 CH9329 接线与设备路径',
|
portNotFound: '找不到串口设备,请检查 CH9329 接线与设备路径',
|
||||||
noResponse: 'CH9329 无响应,请检查波特率与供电',
|
noResponse: 'CH9329 无响应,请检查波特率与供电',
|
||||||
|
noResponseWithCmd: 'CH9329 无响应,请检查波特率与供电(命令 {cmd})',
|
||||||
|
invalidConfig: '串口参数无效,请检查设备路径与波特率配置',
|
||||||
protocolError: 'CH9329 返回了无效协议数据',
|
protocolError: 'CH9329 返回了无效协议数据',
|
||||||
healthCheckFailed: '后台健康检查失败',
|
deviceDisconnected: 'HID 设备已断开,请检查线缆与接口',
|
||||||
ioError: '检测到 I/O 通信异常',
|
ioError: '检测到 I/O 通信异常',
|
||||||
otgIoError: 'OTG 链路不稳定,请检查 USB 线和被控机接口',
|
otgIoError: 'OTG 链路不稳定,请检查 USB 线和被控机接口',
|
||||||
ch9329IoError: 'CH9329 串口链路不稳定,请检查接线与供电',
|
ch9329IoError: 'CH9329 串口链路不稳定,请检查接线与供电',
|
||||||
|
serialError: '串口通信异常,请检查 CH9329 接线与配置',
|
||||||
|
initFailed: 'CH9329 初始化失败,请检查串口参数与供电',
|
||||||
|
shutdown: 'HID 后端已停止',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
@@ -644,28 +652,28 @@ export default {
|
|||||||
hidBackend: 'HID 后端',
|
hidBackend: 'HID 后端',
|
||||||
serialDevice: '串口设备',
|
serialDevice: '串口设备',
|
||||||
baudRate: '波特率',
|
baudRate: '波特率',
|
||||||
otgHidProfile: 'OTG HID 组合',
|
otgHidProfile: 'OTG HID 功能',
|
||||||
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能',
|
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能',
|
||||||
profile: '组合',
|
otgEndpointBudget: '最大端点数量',
|
||||||
otgProfileFull: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体 + 虚拟媒体',
|
otgEndpointBudgetUnlimited: '无限制',
|
||||||
otgProfileFullNoMsd: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体(不含虚拟媒体)',
|
otgEndpointBudgetHint: '此为硬件限制。若超出硬件端点数量,OTG 功能将无法使用。',
|
||||||
otgProfileFullNoConsumer: '键盘 + 相对鼠标 + 绝对鼠标 + 虚拟媒体(不含多媒体)',
|
otgEndpointUsage: '当前端点占用:{used} / {limit}',
|
||||||
otgProfileFullNoConsumerNoMsd: '键盘 + 相对鼠标 + 绝对鼠标(不含多媒体与虚拟媒体)',
|
otgEndpointUsageUnlimited: '当前端点占用:{used} / 不限',
|
||||||
otgProfileLegacyKeyboard: '仅键盘',
|
otgEndpointExceeded: '当前 OTG 组合需要 {used} 个端点,已超出上限 {limit}。',
|
||||||
otgProfileLegacyMouseRelative: '仅相对鼠标',
|
|
||||||
otgProfileCustom: '自定义',
|
|
||||||
otgFunctionKeyboard: '键盘',
|
otgFunctionKeyboard: '键盘',
|
||||||
otgFunctionKeyboardDesc: '标准 HID 键盘设备',
|
otgFunctionKeyboardDesc: '标准 HID 键盘设备',
|
||||||
|
otgKeyboardLeds: '键盘状态灯',
|
||||||
|
otgKeyboardLedsDesc: '启用 Caps/Num/Scroll 状态灯回读',
|
||||||
otgFunctionMouseRelative: '相对鼠标',
|
otgFunctionMouseRelative: '相对鼠标',
|
||||||
otgFunctionMouseRelativeDesc: '传统鼠标移动(HID 启动鼠标)',
|
otgFunctionMouseRelativeDesc: '传统鼠标移动(HID 启动鼠标)',
|
||||||
otgFunctionMouseAbsolute: '绝对鼠标',
|
otgFunctionMouseAbsolute: '绝对鼠标',
|
||||||
otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)',
|
otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)',
|
||||||
otgFunctionConsumer: '多媒体控制',
|
otgFunctionConsumer: '多媒体键盘',
|
||||||
otgFunctionConsumerDesc: '音量/播放/暂停等按键',
|
otgFunctionConsumerDesc: '音量/播放/暂停等多媒体按键',
|
||||||
otgFunctionMsd: '虚拟媒体(MSD)',
|
otgFunctionMsd: '虚拟媒体(MSD)',
|
||||||
otgFunctionMsdDesc: '向目标主机暴露 USB 存储',
|
otgFunctionMsdDesc: '向目标主机暴露 USB 存储',
|
||||||
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
|
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
|
||||||
otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键。',
|
otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键盘。',
|
||||||
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
|
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
|
||||||
// OTG Descriptor
|
// OTG Descriptor
|
||||||
otgDescriptor: 'USB 设备描述符',
|
otgDescriptor: 'USB 设备描述符',
|
||||||
@@ -757,6 +765,15 @@ export default {
|
|||||||
udc_speed: '设备可能未完成枚举,可尝试重插 USB',
|
udc_speed: '设备可能未完成枚举,可尝试重插 USB',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
encoderSelfCheck: {
|
||||||
|
title: '硬件编码能力测试',
|
||||||
|
desc: '按 720p、1080p、2K、4K 测试硬件编码能力',
|
||||||
|
run: '开始测试',
|
||||||
|
failed: '执行硬件编码能力测试失败',
|
||||||
|
resolution: '分辨率',
|
||||||
|
currentHardwareEncoder: '当前硬件编码器',
|
||||||
|
none: '无',
|
||||||
|
},
|
||||||
// WebRTC / ICE
|
// WebRTC / ICE
|
||||||
webrtcSettings: 'WebRTC 设置',
|
webrtcSettings: 'WebRTC 设置',
|
||||||
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
|
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
|
||||||
@@ -783,7 +800,7 @@ export default {
|
|||||||
osWindows: 'Windows',
|
osWindows: 'Windows',
|
||||||
osMac: 'Mac',
|
osMac: 'Mac',
|
||||||
osAndroid: 'Android',
|
osAndroid: 'Android',
|
||||||
mediaKeys: '多媒体键',
|
mediaKeys: '多媒体键盘',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
applied: '配置已应用',
|
applied: '配置已应用',
|
||||||
|
|||||||
@@ -1,129 +1,130 @@
|
|||||||
// Character to HID usage mapping for text paste functionality.
|
// Character to HID usage mapping for text paste functionality.
|
||||||
// The table follows US QWERTY layout semantics.
|
// The table follows US QWERTY layout semantics.
|
||||||
|
|
||||||
|
import { type CanonicalKey } from '@/types/generated'
|
||||||
import { keys } from '@/lib/keyboardMappings'
|
import { keys } from '@/lib/keyboardMappings'
|
||||||
|
|
||||||
export interface CharKeyMapping {
|
export interface CharKeyMapping {
|
||||||
hidCode: number // USB HID usage code
|
key: CanonicalKey
|
||||||
shift: boolean // Whether Shift modifier is needed
|
shift: boolean // Whether Shift modifier is needed
|
||||||
}
|
}
|
||||||
|
|
||||||
const charToKeyMap: Record<string, CharKeyMapping> = {
|
const charToKeyMap: Record<string, CharKeyMapping> = {
|
||||||
// Lowercase letters
|
// Lowercase letters
|
||||||
a: { hidCode: keys.KeyA, shift: false },
|
a: { key: keys.KeyA, shift: false },
|
||||||
b: { hidCode: keys.KeyB, shift: false },
|
b: { key: keys.KeyB, shift: false },
|
||||||
c: { hidCode: keys.KeyC, shift: false },
|
c: { key: keys.KeyC, shift: false },
|
||||||
d: { hidCode: keys.KeyD, shift: false },
|
d: { key: keys.KeyD, shift: false },
|
||||||
e: { hidCode: keys.KeyE, shift: false },
|
e: { key: keys.KeyE, shift: false },
|
||||||
f: { hidCode: keys.KeyF, shift: false },
|
f: { key: keys.KeyF, shift: false },
|
||||||
g: { hidCode: keys.KeyG, shift: false },
|
g: { key: keys.KeyG, shift: false },
|
||||||
h: { hidCode: keys.KeyH, shift: false },
|
h: { key: keys.KeyH, shift: false },
|
||||||
i: { hidCode: keys.KeyI, shift: false },
|
i: { key: keys.KeyI, shift: false },
|
||||||
j: { hidCode: keys.KeyJ, shift: false },
|
j: { key: keys.KeyJ, shift: false },
|
||||||
k: { hidCode: keys.KeyK, shift: false },
|
k: { key: keys.KeyK, shift: false },
|
||||||
l: { hidCode: keys.KeyL, shift: false },
|
l: { key: keys.KeyL, shift: false },
|
||||||
m: { hidCode: keys.KeyM, shift: false },
|
m: { key: keys.KeyM, shift: false },
|
||||||
n: { hidCode: keys.KeyN, shift: false },
|
n: { key: keys.KeyN, shift: false },
|
||||||
o: { hidCode: keys.KeyO, shift: false },
|
o: { key: keys.KeyO, shift: false },
|
||||||
p: { hidCode: keys.KeyP, shift: false },
|
p: { key: keys.KeyP, shift: false },
|
||||||
q: { hidCode: keys.KeyQ, shift: false },
|
q: { key: keys.KeyQ, shift: false },
|
||||||
r: { hidCode: keys.KeyR, shift: false },
|
r: { key: keys.KeyR, shift: false },
|
||||||
s: { hidCode: keys.KeyS, shift: false },
|
s: { key: keys.KeyS, shift: false },
|
||||||
t: { hidCode: keys.KeyT, shift: false },
|
t: { key: keys.KeyT, shift: false },
|
||||||
u: { hidCode: keys.KeyU, shift: false },
|
u: { key: keys.KeyU, shift: false },
|
||||||
v: { hidCode: keys.KeyV, shift: false },
|
v: { key: keys.KeyV, shift: false },
|
||||||
w: { hidCode: keys.KeyW, shift: false },
|
w: { key: keys.KeyW, shift: false },
|
||||||
x: { hidCode: keys.KeyX, shift: false },
|
x: { key: keys.KeyX, shift: false },
|
||||||
y: { hidCode: keys.KeyY, shift: false },
|
y: { key: keys.KeyY, shift: false },
|
||||||
z: { hidCode: keys.KeyZ, shift: false },
|
z: { key: keys.KeyZ, shift: false },
|
||||||
|
|
||||||
// Uppercase letters
|
// Uppercase letters
|
||||||
A: { hidCode: keys.KeyA, shift: true },
|
A: { key: keys.KeyA, shift: true },
|
||||||
B: { hidCode: keys.KeyB, shift: true },
|
B: { key: keys.KeyB, shift: true },
|
||||||
C: { hidCode: keys.KeyC, shift: true },
|
C: { key: keys.KeyC, shift: true },
|
||||||
D: { hidCode: keys.KeyD, shift: true },
|
D: { key: keys.KeyD, shift: true },
|
||||||
E: { hidCode: keys.KeyE, shift: true },
|
E: { key: keys.KeyE, shift: true },
|
||||||
F: { hidCode: keys.KeyF, shift: true },
|
F: { key: keys.KeyF, shift: true },
|
||||||
G: { hidCode: keys.KeyG, shift: true },
|
G: { key: keys.KeyG, shift: true },
|
||||||
H: { hidCode: keys.KeyH, shift: true },
|
H: { key: keys.KeyH, shift: true },
|
||||||
I: { hidCode: keys.KeyI, shift: true },
|
I: { key: keys.KeyI, shift: true },
|
||||||
J: { hidCode: keys.KeyJ, shift: true },
|
J: { key: keys.KeyJ, shift: true },
|
||||||
K: { hidCode: keys.KeyK, shift: true },
|
K: { key: keys.KeyK, shift: true },
|
||||||
L: { hidCode: keys.KeyL, shift: true },
|
L: { key: keys.KeyL, shift: true },
|
||||||
M: { hidCode: keys.KeyM, shift: true },
|
M: { key: keys.KeyM, shift: true },
|
||||||
N: { hidCode: keys.KeyN, shift: true },
|
N: { key: keys.KeyN, shift: true },
|
||||||
O: { hidCode: keys.KeyO, shift: true },
|
O: { key: keys.KeyO, shift: true },
|
||||||
P: { hidCode: keys.KeyP, shift: true },
|
P: { key: keys.KeyP, shift: true },
|
||||||
Q: { hidCode: keys.KeyQ, shift: true },
|
Q: { key: keys.KeyQ, shift: true },
|
||||||
R: { hidCode: keys.KeyR, shift: true },
|
R: { key: keys.KeyR, shift: true },
|
||||||
S: { hidCode: keys.KeyS, shift: true },
|
S: { key: keys.KeyS, shift: true },
|
||||||
T: { hidCode: keys.KeyT, shift: true },
|
T: { key: keys.KeyT, shift: true },
|
||||||
U: { hidCode: keys.KeyU, shift: true },
|
U: { key: keys.KeyU, shift: true },
|
||||||
V: { hidCode: keys.KeyV, shift: true },
|
V: { key: keys.KeyV, shift: true },
|
||||||
W: { hidCode: keys.KeyW, shift: true },
|
W: { key: keys.KeyW, shift: true },
|
||||||
X: { hidCode: keys.KeyX, shift: true },
|
X: { key: keys.KeyX, shift: true },
|
||||||
Y: { hidCode: keys.KeyY, shift: true },
|
Y: { key: keys.KeyY, shift: true },
|
||||||
Z: { hidCode: keys.KeyZ, shift: true },
|
Z: { key: keys.KeyZ, shift: true },
|
||||||
|
|
||||||
// Number row
|
// Number row
|
||||||
'0': { hidCode: keys.Digit0, shift: false },
|
'0': { key: keys.Digit0, shift: false },
|
||||||
'1': { hidCode: keys.Digit1, shift: false },
|
'1': { key: keys.Digit1, shift: false },
|
||||||
'2': { hidCode: keys.Digit2, shift: false },
|
'2': { key: keys.Digit2, shift: false },
|
||||||
'3': { hidCode: keys.Digit3, shift: false },
|
'3': { key: keys.Digit3, shift: false },
|
||||||
'4': { hidCode: keys.Digit4, shift: false },
|
'4': { key: keys.Digit4, shift: false },
|
||||||
'5': { hidCode: keys.Digit5, shift: false },
|
'5': { key: keys.Digit5, shift: false },
|
||||||
'6': { hidCode: keys.Digit6, shift: false },
|
'6': { key: keys.Digit6, shift: false },
|
||||||
'7': { hidCode: keys.Digit7, shift: false },
|
'7': { key: keys.Digit7, shift: false },
|
||||||
'8': { hidCode: keys.Digit8, shift: false },
|
'8': { key: keys.Digit8, shift: false },
|
||||||
'9': { hidCode: keys.Digit9, shift: false },
|
'9': { key: keys.Digit9, shift: false },
|
||||||
|
|
||||||
// Shifted number row symbols
|
// Shifted number row symbols
|
||||||
')': { hidCode: keys.Digit0, shift: true },
|
')': { key: keys.Digit0, shift: true },
|
||||||
'!': { hidCode: keys.Digit1, shift: true },
|
'!': { key: keys.Digit1, shift: true },
|
||||||
'@': { hidCode: keys.Digit2, shift: true },
|
'@': { key: keys.Digit2, shift: true },
|
||||||
'#': { hidCode: keys.Digit3, shift: true },
|
'#': { key: keys.Digit3, shift: true },
|
||||||
'$': { hidCode: keys.Digit4, shift: true },
|
'$': { key: keys.Digit4, shift: true },
|
||||||
'%': { hidCode: keys.Digit5, shift: true },
|
'%': { key: keys.Digit5, shift: true },
|
||||||
'^': { hidCode: keys.Digit6, shift: true },
|
'^': { key: keys.Digit6, shift: true },
|
||||||
'&': { hidCode: keys.Digit7, shift: true },
|
'&': { key: keys.Digit7, shift: true },
|
||||||
'*': { hidCode: keys.Digit8, shift: true },
|
'*': { key: keys.Digit8, shift: true },
|
||||||
'(': { hidCode: keys.Digit9, shift: true },
|
'(': { key: keys.Digit9, shift: true },
|
||||||
|
|
||||||
// Punctuation and symbols
|
// Punctuation and symbols
|
||||||
'-': { hidCode: keys.Minus, shift: false },
|
'-': { key: keys.Minus, shift: false },
|
||||||
'=': { hidCode: keys.Equal, shift: false },
|
'=': { key: keys.Equal, shift: false },
|
||||||
'[': { hidCode: keys.BracketLeft, shift: false },
|
'[': { key: keys.BracketLeft, shift: false },
|
||||||
']': { hidCode: keys.BracketRight, shift: false },
|
']': { key: keys.BracketRight, shift: false },
|
||||||
'\\': { hidCode: keys.Backslash, shift: false },
|
'\\': { key: keys.Backslash, shift: false },
|
||||||
';': { hidCode: keys.Semicolon, shift: false },
|
';': { key: keys.Semicolon, shift: false },
|
||||||
"'": { hidCode: keys.Quote, shift: false },
|
"'": { key: keys.Quote, shift: false },
|
||||||
'`': { hidCode: keys.Backquote, shift: false },
|
'`': { key: keys.Backquote, shift: false },
|
||||||
',': { hidCode: keys.Comma, shift: false },
|
',': { key: keys.Comma, shift: false },
|
||||||
'.': { hidCode: keys.Period, shift: false },
|
'.': { key: keys.Period, shift: false },
|
||||||
'/': { hidCode: keys.Slash, shift: false },
|
'/': { key: keys.Slash, shift: false },
|
||||||
|
|
||||||
// Shifted punctuation and symbols
|
// Shifted punctuation and symbols
|
||||||
_: { hidCode: keys.Minus, shift: true },
|
_: { key: keys.Minus, shift: true },
|
||||||
'+': { hidCode: keys.Equal, shift: true },
|
'+': { key: keys.Equal, shift: true },
|
||||||
'{': { hidCode: keys.BracketLeft, shift: true },
|
'{': { key: keys.BracketLeft, shift: true },
|
||||||
'}': { hidCode: keys.BracketRight, shift: true },
|
'}': { key: keys.BracketRight, shift: true },
|
||||||
'|': { hidCode: keys.Backslash, shift: true },
|
'|': { key: keys.Backslash, shift: true },
|
||||||
':': { hidCode: keys.Semicolon, shift: true },
|
':': { key: keys.Semicolon, shift: true },
|
||||||
'"': { hidCode: keys.Quote, shift: true },
|
'"': { key: keys.Quote, shift: true },
|
||||||
'~': { hidCode: keys.Backquote, shift: true },
|
'~': { key: keys.Backquote, shift: true },
|
||||||
'<': { hidCode: keys.Comma, shift: true },
|
'<': { key: keys.Comma, shift: true },
|
||||||
'>': { hidCode: keys.Period, shift: true },
|
'>': { key: keys.Period, shift: true },
|
||||||
'?': { hidCode: keys.Slash, shift: true },
|
'?': { key: keys.Slash, shift: true },
|
||||||
|
|
||||||
// Whitespace and control
|
// Whitespace and control
|
||||||
' ': { hidCode: keys.Space, shift: false },
|
' ': { key: keys.Space, shift: false },
|
||||||
'\t': { hidCode: keys.Tab, shift: false },
|
'\t': { key: keys.Tab, shift: false },
|
||||||
'\n': { hidCode: keys.Enter, shift: false },
|
'\n': { key: keys.Enter, shift: false },
|
||||||
'\r': { hidCode: keys.Enter, shift: false },
|
'\r': { key: keys.Enter, shift: false },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get HID usage code and modifier state for a character
|
* Get canonical key and modifier state for a character
|
||||||
* @param char - Single character to convert
|
* @param char - Single character to convert
|
||||||
* @returns CharKeyMapping or null if character is not mappable
|
* @returns CharKeyMapping or null if character is not mappable
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,77 +1,15 @@
|
|||||||
// Keyboard layout definitions for virtual keyboard
|
// Virtual keyboard layout data shared by the on-screen keyboard.
|
||||||
|
|
||||||
export interface KeyboardLayout {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
// Key display labels
|
|
||||||
keyLabels: Record<string, string>
|
|
||||||
// Shift variant labels (key in parentheses)
|
|
||||||
shiftLabels: Record<string, string>
|
|
||||||
// Virtual keyboard layout rows
|
|
||||||
layout: {
|
|
||||||
main: {
|
|
||||||
macros: string[]
|
|
||||||
functionRow: string[]
|
|
||||||
default: string[][]
|
|
||||||
shift: string[][]
|
|
||||||
}
|
|
||||||
control: string[][]
|
|
||||||
arrows: string[][]
|
|
||||||
media: string[] // Media keys row
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OS-specific keyboard layout type
|
|
||||||
export type KeyboardOsType = 'windows' | 'mac' | 'android'
|
export type KeyboardOsType = 'windows' | 'mac' | 'android'
|
||||||
|
|
||||||
// Bottom row layouts for different OS
|
|
||||||
export const osBottomRows: Record<KeyboardOsType, string[]> = {
|
export const osBottomRows: Record<KeyboardOsType, string[]> = {
|
||||||
// Windows: Ctrl - Win - Alt - Space - Alt - Win - Menu - Ctrl
|
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'],
|
||||||
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
|
|
||||||
// Mac: Ctrl - Option - Cmd - Space - Cmd - Option - Ctrl
|
|
||||||
mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'],
|
mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'],
|
||||||
// Android: simplified layout
|
|
||||||
android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'],
|
android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'],
|
||||||
}
|
}
|
||||||
|
|
||||||
// OS-specific modifier display names
|
|
||||||
export const osModifierLabels: Record<KeyboardOsType, Record<string, string>> = {
|
|
||||||
windows: {
|
|
||||||
ControlLeft: '^Ctrl',
|
|
||||||
ControlRight: 'Ctrl^',
|
|
||||||
MetaLeft: '⊞Win',
|
|
||||||
MetaRight: 'Win⊞',
|
|
||||||
AltLeft: 'Alt',
|
|
||||||
AltRight: 'Alt',
|
|
||||||
AltGr: 'AltGr',
|
|
||||||
Menu: 'Menu',
|
|
||||||
},
|
|
||||||
mac: {
|
|
||||||
ControlLeft: '^Ctrl',
|
|
||||||
ControlRight: 'Ctrl^',
|
|
||||||
MetaLeft: '⌘Cmd',
|
|
||||||
MetaRight: 'Cmd⌘',
|
|
||||||
AltLeft: '⌥Opt',
|
|
||||||
AltRight: 'Opt⌥',
|
|
||||||
AltGr: '⌥Opt',
|
|
||||||
Menu: 'Menu',
|
|
||||||
},
|
|
||||||
android: {
|
|
||||||
ControlLeft: 'Ctrl',
|
|
||||||
ControlRight: 'Ctrl',
|
|
||||||
MetaLeft: 'Meta',
|
|
||||||
MetaRight: 'Meta',
|
|
||||||
AltLeft: 'Alt',
|
|
||||||
AltRight: 'Alt',
|
|
||||||
AltGr: 'Alt',
|
|
||||||
Menu: 'Menu',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Media keys (Consumer Control)
|
|
||||||
export const mediaKeys = ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp']
|
export const mediaKeys = ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp']
|
||||||
|
|
||||||
// Media key display names
|
|
||||||
export const mediaKeyLabels: Record<string, string> = {
|
export const mediaKeyLabels: Record<string, string> = {
|
||||||
PlayPause: '⏯',
|
PlayPause: '⏯',
|
||||||
Stop: '⏹',
|
Stop: '⏹',
|
||||||
@@ -81,158 +19,3 @@ export const mediaKeyLabels: Record<string, string> = {
|
|||||||
VolumeUp: '🔊',
|
VolumeUp: '🔊',
|
||||||
VolumeDown: '🔉',
|
VolumeDown: '🔉',
|
||||||
}
|
}
|
||||||
|
|
||||||
// English US Layout
|
|
||||||
export const enUSLayout: KeyboardLayout = {
|
|
||||||
id: 'en-US',
|
|
||||||
name: 'English (US)',
|
|
||||||
keyLabels: {
|
|
||||||
// Macros
|
|
||||||
CtrlAltDelete: 'Ctrl+Alt+Del',
|
|
||||||
AltMetaEscape: 'Alt+Meta+Esc',
|
|
||||||
CtrlAltBackspace: 'Ctrl+Alt+Back',
|
|
||||||
|
|
||||||
// Modifiers
|
|
||||||
ControlLeft: 'Ctrl',
|
|
||||||
ControlRight: 'Ctrl',
|
|
||||||
ShiftLeft: 'Shift',
|
|
||||||
ShiftRight: 'Shift',
|
|
||||||
AltLeft: 'Alt',
|
|
||||||
AltRight: 'Alt',
|
|
||||||
AltGr: 'AltGr',
|
|
||||||
MetaLeft: 'Meta',
|
|
||||||
MetaRight: 'Meta',
|
|
||||||
|
|
||||||
// Special keys
|
|
||||||
Escape: 'Esc',
|
|
||||||
Backspace: 'Back',
|
|
||||||
Tab: 'Tab',
|
|
||||||
CapsLock: 'Caps',
|
|
||||||
Enter: 'Enter',
|
|
||||||
Space: ' ',
|
|
||||||
Menu: 'Menu',
|
|
||||||
|
|
||||||
// Navigation
|
|
||||||
Insert: 'Ins',
|
|
||||||
Delete: 'Del',
|
|
||||||
Home: 'Home',
|
|
||||||
End: 'End',
|
|
||||||
PageUp: 'PgUp',
|
|
||||||
PageDown: 'PgDn',
|
|
||||||
|
|
||||||
// Arrows
|
|
||||||
ArrowUp: '\u2191',
|
|
||||||
ArrowDown: '\u2193',
|
|
||||||
ArrowLeft: '\u2190',
|
|
||||||
ArrowRight: '\u2192',
|
|
||||||
|
|
||||||
// Control cluster
|
|
||||||
PrintScreen: 'PrtSc',
|
|
||||||
ScrollLock: 'ScrLk',
|
|
||||||
Pause: 'Pause',
|
|
||||||
|
|
||||||
// Function keys
|
|
||||||
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4',
|
|
||||||
F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8',
|
|
||||||
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
|
|
||||||
|
|
||||||
// Letters
|
|
||||||
KeyA: 'a', KeyB: 'b', KeyC: 'c', KeyD: 'd', KeyE: 'e',
|
|
||||||
KeyF: 'f', KeyG: 'g', KeyH: 'h', KeyI: 'i', KeyJ: 'j',
|
|
||||||
KeyK: 'k', KeyL: 'l', KeyM: 'm', KeyN: 'n', KeyO: 'o',
|
|
||||||
KeyP: 'p', KeyQ: 'q', KeyR: 'r', KeyS: 's', KeyT: 't',
|
|
||||||
KeyU: 'u', KeyV: 'v', KeyW: 'w', KeyX: 'x', KeyY: 'y',
|
|
||||||
KeyZ: 'z',
|
|
||||||
|
|
||||||
// Numbers
|
|
||||||
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
|
|
||||||
Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0',
|
|
||||||
|
|
||||||
// Symbols
|
|
||||||
Minus: '-',
|
|
||||||
Equal: '=',
|
|
||||||
BracketLeft: '[',
|
|
||||||
BracketRight: ']',
|
|
||||||
Backslash: '\\',
|
|
||||||
Semicolon: ';',
|
|
||||||
Quote: "'",
|
|
||||||
Backquote: '`',
|
|
||||||
Comma: ',',
|
|
||||||
Period: '.',
|
|
||||||
Slash: '/',
|
|
||||||
},
|
|
||||||
shiftLabels: {
|
|
||||||
// Capital letters
|
|
||||||
KeyA: 'A', KeyB: 'B', KeyC: 'C', KeyD: 'D', KeyE: 'E',
|
|
||||||
KeyF: 'F', KeyG: 'G', KeyH: 'H', KeyI: 'I', KeyJ: 'J',
|
|
||||||
KeyK: 'K', KeyL: 'L', KeyM: 'M', KeyN: 'N', KeyO: 'O',
|
|
||||||
KeyP: 'P', KeyQ: 'Q', KeyR: 'R', KeyS: 'S', KeyT: 'T',
|
|
||||||
KeyU: 'U', KeyV: 'V', KeyW: 'W', KeyX: 'X', KeyY: 'Y',
|
|
||||||
KeyZ: 'Z',
|
|
||||||
|
|
||||||
// Shifted numbers
|
|
||||||
Digit1: '!', Digit2: '@', Digit3: '#', Digit4: '$', Digit5: '%',
|
|
||||||
Digit6: '^', Digit7: '&', Digit8: '*', Digit9: '(', Digit0: ')',
|
|
||||||
|
|
||||||
// Shifted symbols
|
|
||||||
Minus: '_',
|
|
||||||
Equal: '+',
|
|
||||||
BracketLeft: '{',
|
|
||||||
BracketRight: '}',
|
|
||||||
Backslash: '|',
|
|
||||||
Semicolon: ':',
|
|
||||||
Quote: '"',
|
|
||||||
Backquote: '~',
|
|
||||||
Comma: '<',
|
|
||||||
Period: '>',
|
|
||||||
Slash: '?',
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
main: {
|
|
||||||
macros: ['CtrlAltDelete', 'AltMetaEscape', 'CtrlAltBackspace'],
|
|
||||||
functionRow: ['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'],
|
|
||||||
default: [
|
|
||||||
['Backquote', 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0', 'Minus', 'Equal', 'Backspace'],
|
|
||||||
['Tab', 'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP', 'BracketLeft', 'BracketRight', 'Backslash'],
|
|
||||||
['CapsLock', 'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL', 'Semicolon', 'Quote', 'Enter'],
|
|
||||||
['ShiftLeft', 'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM', 'Comma', 'Period', 'Slash', 'ShiftRight'],
|
|
||||||
['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
|
|
||||||
],
|
|
||||||
shift: [
|
|
||||||
['Backquote', 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0', 'Minus', 'Equal', 'Backspace'],
|
|
||||||
['Tab', 'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP', 'BracketLeft', 'BracketRight', 'Backslash'],
|
|
||||||
['CapsLock', 'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL', 'Semicolon', 'Quote', 'Enter'],
|
|
||||||
['ShiftLeft', 'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM', 'Comma', 'Period', 'Slash', 'ShiftRight'],
|
|
||||||
['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
control: [
|
|
||||||
['PrintScreen', 'ScrollLock', 'Pause'],
|
|
||||||
['Insert', 'Home', 'PageUp'],
|
|
||||||
['Delete', 'End', 'PageDown'],
|
|
||||||
],
|
|
||||||
arrows: [
|
|
||||||
['ArrowUp'],
|
|
||||||
['ArrowLeft', 'ArrowDown', 'ArrowRight'],
|
|
||||||
],
|
|
||||||
media: ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// All available layouts
|
|
||||||
export const keyboardLayouts: Record<string, KeyboardLayout> = {
|
|
||||||
'en-US': enUSLayout,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get layout by ID or return default
|
|
||||||
export function getKeyboardLayout(id: string): KeyboardLayout {
|
|
||||||
return keyboardLayouts[id] || enUSLayout
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get key label for display
|
|
||||||
export function getKeyLabel(layout: KeyboardLayout, keyName: string, isShift: boolean): string {
|
|
||||||
if (isShift && layout.shiftLabels[keyName]) {
|
|
||||||
return layout.shiftLabels[keyName]
|
|
||||||
}
|
|
||||||
return layout.keyLabels[keyName] || keyName
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,157 +1,128 @@
|
|||||||
// Key codes and modifiers correspond to definitions in the
|
import { CanonicalKey } from '@/types/generated'
|
||||||
// [Linux USB HID gadget driver](https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt)
|
|
||||||
// [Universal Serial Bus HID Usage Tables: Section 10](https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf)
|
|
||||||
|
|
||||||
export const keys = {
|
export const keys = {
|
||||||
// Letters
|
KeyA: CanonicalKey.KeyA,
|
||||||
KeyA: 0x04,
|
KeyB: CanonicalKey.KeyB,
|
||||||
KeyB: 0x05,
|
KeyC: CanonicalKey.KeyC,
|
||||||
KeyC: 0x06,
|
KeyD: CanonicalKey.KeyD,
|
||||||
KeyD: 0x07,
|
KeyE: CanonicalKey.KeyE,
|
||||||
KeyE: 0x08,
|
KeyF: CanonicalKey.KeyF,
|
||||||
KeyF: 0x09,
|
KeyG: CanonicalKey.KeyG,
|
||||||
KeyG: 0x0a,
|
KeyH: CanonicalKey.KeyH,
|
||||||
KeyH: 0x0b,
|
KeyI: CanonicalKey.KeyI,
|
||||||
KeyI: 0x0c,
|
KeyJ: CanonicalKey.KeyJ,
|
||||||
KeyJ: 0x0d,
|
KeyK: CanonicalKey.KeyK,
|
||||||
KeyK: 0x0e,
|
KeyL: CanonicalKey.KeyL,
|
||||||
KeyL: 0x0f,
|
KeyM: CanonicalKey.KeyM,
|
||||||
KeyM: 0x10,
|
KeyN: CanonicalKey.KeyN,
|
||||||
KeyN: 0x11,
|
KeyO: CanonicalKey.KeyO,
|
||||||
KeyO: 0x12,
|
KeyP: CanonicalKey.KeyP,
|
||||||
KeyP: 0x13,
|
KeyQ: CanonicalKey.KeyQ,
|
||||||
KeyQ: 0x14,
|
KeyR: CanonicalKey.KeyR,
|
||||||
KeyR: 0x15,
|
KeyS: CanonicalKey.KeyS,
|
||||||
KeyS: 0x16,
|
KeyT: CanonicalKey.KeyT,
|
||||||
KeyT: 0x17,
|
KeyU: CanonicalKey.KeyU,
|
||||||
KeyU: 0x18,
|
KeyV: CanonicalKey.KeyV,
|
||||||
KeyV: 0x19,
|
KeyW: CanonicalKey.KeyW,
|
||||||
KeyW: 0x1a,
|
KeyX: CanonicalKey.KeyX,
|
||||||
KeyX: 0x1b,
|
KeyY: CanonicalKey.KeyY,
|
||||||
KeyY: 0x1c,
|
KeyZ: CanonicalKey.KeyZ,
|
||||||
KeyZ: 0x1d,
|
Digit1: CanonicalKey.Digit1,
|
||||||
|
Digit2: CanonicalKey.Digit2,
|
||||||
// Numbers
|
Digit3: CanonicalKey.Digit3,
|
||||||
Digit1: 0x1e,
|
Digit4: CanonicalKey.Digit4,
|
||||||
Digit2: 0x1f,
|
Digit5: CanonicalKey.Digit5,
|
||||||
Digit3: 0x20,
|
Digit6: CanonicalKey.Digit6,
|
||||||
Digit4: 0x21,
|
Digit7: CanonicalKey.Digit7,
|
||||||
Digit5: 0x22,
|
Digit8: CanonicalKey.Digit8,
|
||||||
Digit6: 0x23,
|
Digit9: CanonicalKey.Digit9,
|
||||||
Digit7: 0x24,
|
Digit0: CanonicalKey.Digit0,
|
||||||
Digit8: 0x25,
|
Enter: CanonicalKey.Enter,
|
||||||
Digit9: 0x26,
|
Escape: CanonicalKey.Escape,
|
||||||
Digit0: 0x27,
|
Backspace: CanonicalKey.Backspace,
|
||||||
|
Tab: CanonicalKey.Tab,
|
||||||
// Control keys
|
Space: CanonicalKey.Space,
|
||||||
Enter: 0x28,
|
Minus: CanonicalKey.Minus,
|
||||||
Escape: 0x29,
|
Equal: CanonicalKey.Equal,
|
||||||
Backspace: 0x2a,
|
BracketLeft: CanonicalKey.BracketLeft,
|
||||||
Tab: 0x2b,
|
BracketRight: CanonicalKey.BracketRight,
|
||||||
Space: 0x2c,
|
Backslash: CanonicalKey.Backslash,
|
||||||
|
Semicolon: CanonicalKey.Semicolon,
|
||||||
// Symbols
|
Quote: CanonicalKey.Quote,
|
||||||
Minus: 0x2d,
|
Backquote: CanonicalKey.Backquote,
|
||||||
Equal: 0x2e,
|
Comma: CanonicalKey.Comma,
|
||||||
BracketLeft: 0x2f,
|
Period: CanonicalKey.Period,
|
||||||
BracketRight: 0x30,
|
Slash: CanonicalKey.Slash,
|
||||||
Backslash: 0x31,
|
CapsLock: CanonicalKey.CapsLock,
|
||||||
Semicolon: 0x33,
|
F1: CanonicalKey.F1,
|
||||||
Quote: 0x34,
|
F2: CanonicalKey.F2,
|
||||||
Backquote: 0x35,
|
F3: CanonicalKey.F3,
|
||||||
Comma: 0x36,
|
F4: CanonicalKey.F4,
|
||||||
Period: 0x37,
|
F5: CanonicalKey.F5,
|
||||||
Slash: 0x38,
|
F6: CanonicalKey.F6,
|
||||||
|
F7: CanonicalKey.F7,
|
||||||
// Lock keys
|
F8: CanonicalKey.F8,
|
||||||
CapsLock: 0x39,
|
F9: CanonicalKey.F9,
|
||||||
|
F10: CanonicalKey.F10,
|
||||||
// Function keys
|
F11: CanonicalKey.F11,
|
||||||
F1: 0x3a,
|
F12: CanonicalKey.F12,
|
||||||
F2: 0x3b,
|
PrintScreen: CanonicalKey.PrintScreen,
|
||||||
F3: 0x3c,
|
ScrollLock: CanonicalKey.ScrollLock,
|
||||||
F4: 0x3d,
|
Pause: CanonicalKey.Pause,
|
||||||
F5: 0x3e,
|
Insert: CanonicalKey.Insert,
|
||||||
F6: 0x3f,
|
Home: CanonicalKey.Home,
|
||||||
F7: 0x40,
|
PageUp: CanonicalKey.PageUp,
|
||||||
F8: 0x41,
|
Delete: CanonicalKey.Delete,
|
||||||
F9: 0x42,
|
End: CanonicalKey.End,
|
||||||
F10: 0x43,
|
PageDown: CanonicalKey.PageDown,
|
||||||
F11: 0x44,
|
ArrowRight: CanonicalKey.ArrowRight,
|
||||||
F12: 0x45,
|
ArrowLeft: CanonicalKey.ArrowLeft,
|
||||||
|
ArrowDown: CanonicalKey.ArrowDown,
|
||||||
// Control cluster
|
ArrowUp: CanonicalKey.ArrowUp,
|
||||||
PrintScreen: 0x46,
|
NumLock: CanonicalKey.NumLock,
|
||||||
ScrollLock: 0x47,
|
NumpadDivide: CanonicalKey.NumpadDivide,
|
||||||
Pause: 0x48,
|
NumpadMultiply: CanonicalKey.NumpadMultiply,
|
||||||
Insert: 0x49,
|
NumpadSubtract: CanonicalKey.NumpadSubtract,
|
||||||
Home: 0x4a,
|
NumpadAdd: CanonicalKey.NumpadAdd,
|
||||||
PageUp: 0x4b,
|
NumpadEnter: CanonicalKey.NumpadEnter,
|
||||||
Delete: 0x4c,
|
Numpad1: CanonicalKey.Numpad1,
|
||||||
End: 0x4d,
|
Numpad2: CanonicalKey.Numpad2,
|
||||||
PageDown: 0x4e,
|
Numpad3: CanonicalKey.Numpad3,
|
||||||
|
Numpad4: CanonicalKey.Numpad4,
|
||||||
// Arrow keys
|
Numpad5: CanonicalKey.Numpad5,
|
||||||
ArrowRight: 0x4f,
|
Numpad6: CanonicalKey.Numpad6,
|
||||||
ArrowLeft: 0x50,
|
Numpad7: CanonicalKey.Numpad7,
|
||||||
ArrowDown: 0x51,
|
Numpad8: CanonicalKey.Numpad8,
|
||||||
ArrowUp: 0x52,
|
Numpad9: CanonicalKey.Numpad9,
|
||||||
|
Numpad0: CanonicalKey.Numpad0,
|
||||||
// Numpad
|
NumpadDecimal: CanonicalKey.NumpadDecimal,
|
||||||
NumLock: 0x53,
|
IntlBackslash: CanonicalKey.IntlBackslash,
|
||||||
NumpadDivide: 0x54,
|
ContextMenu: CanonicalKey.ContextMenu,
|
||||||
NumpadMultiply: 0x55,
|
F13: CanonicalKey.F13,
|
||||||
NumpadSubtract: 0x56,
|
F14: CanonicalKey.F14,
|
||||||
NumpadAdd: 0x57,
|
F15: CanonicalKey.F15,
|
||||||
NumpadEnter: 0x58,
|
F16: CanonicalKey.F16,
|
||||||
Numpad1: 0x59,
|
F17: CanonicalKey.F17,
|
||||||
Numpad2: 0x5a,
|
F18: CanonicalKey.F18,
|
||||||
Numpad3: 0x5b,
|
F19: CanonicalKey.F19,
|
||||||
Numpad4: 0x5c,
|
F20: CanonicalKey.F20,
|
||||||
Numpad5: 0x5d,
|
F21: CanonicalKey.F21,
|
||||||
Numpad6: 0x5e,
|
F22: CanonicalKey.F22,
|
||||||
Numpad7: 0x5f,
|
F23: CanonicalKey.F23,
|
||||||
Numpad8: 0x60,
|
F24: CanonicalKey.F24,
|
||||||
Numpad9: 0x61,
|
ControlLeft: CanonicalKey.ControlLeft,
|
||||||
Numpad0: 0x62,
|
ShiftLeft: CanonicalKey.ShiftLeft,
|
||||||
NumpadDecimal: 0x63,
|
AltLeft: CanonicalKey.AltLeft,
|
||||||
|
MetaLeft: CanonicalKey.MetaLeft,
|
||||||
// Non-US keys
|
ControlRight: CanonicalKey.ControlRight,
|
||||||
IntlBackslash: 0x64,
|
ShiftRight: CanonicalKey.ShiftRight,
|
||||||
ContextMenu: 0x65,
|
AltRight: CanonicalKey.AltRight,
|
||||||
Menu: 0x65,
|
MetaRight: CanonicalKey.MetaRight,
|
||||||
Application: 0x65,
|
|
||||||
|
|
||||||
// Extended function keys
|
|
||||||
F13: 0x68,
|
|
||||||
F14: 0x69,
|
|
||||||
F15: 0x6a,
|
|
||||||
F16: 0x6b,
|
|
||||||
F17: 0x6c,
|
|
||||||
F18: 0x6d,
|
|
||||||
F19: 0x6e,
|
|
||||||
F20: 0x6f,
|
|
||||||
F21: 0x70,
|
|
||||||
F22: 0x71,
|
|
||||||
F23: 0x72,
|
|
||||||
F24: 0x73,
|
|
||||||
|
|
||||||
// Modifiers (these are special - HID codes 0xE0-0xE7)
|
|
||||||
ControlLeft: 0xe0,
|
|
||||||
ShiftLeft: 0xe1,
|
|
||||||
AltLeft: 0xe2,
|
|
||||||
MetaLeft: 0xe3,
|
|
||||||
ControlRight: 0xe4,
|
|
||||||
ShiftRight: 0xe5,
|
|
||||||
AltRight: 0xe6,
|
|
||||||
AltGr: 0xe6,
|
|
||||||
MetaRight: 0xe7,
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type KeyName = keyof typeof keys
|
export type KeyName = keyof typeof keys
|
||||||
|
|
||||||
// Consumer Control Usage codes (for multimedia keys)
|
// Consumer Control Usage codes (for multimedia keys)
|
||||||
// These are sent via a separate Consumer Control HID report
|
|
||||||
export const consumerKeys = {
|
export const consumerKeys = {
|
||||||
PlayPause: 0x00cd,
|
PlayPause: 0x00cd,
|
||||||
Stop: 0x00b7,
|
Stop: 0x00b7,
|
||||||
@@ -164,69 +135,153 @@ export const consumerKeys = {
|
|||||||
|
|
||||||
export type ConsumerKeyName = keyof typeof consumerKeys
|
export type ConsumerKeyName = keyof typeof consumerKeys
|
||||||
|
|
||||||
// Modifier bitmasks for HID report byte 0
|
export const modifiers: Partial<Record<CanonicalKey, number>> = {
|
||||||
export const modifiers = {
|
[CanonicalKey.ControlLeft]: 0x01,
|
||||||
ControlLeft: 0x01,
|
[CanonicalKey.ShiftLeft]: 0x02,
|
||||||
ShiftLeft: 0x02,
|
[CanonicalKey.AltLeft]: 0x04,
|
||||||
AltLeft: 0x04,
|
[CanonicalKey.MetaLeft]: 0x08,
|
||||||
MetaLeft: 0x08,
|
[CanonicalKey.ControlRight]: 0x10,
|
||||||
ControlRight: 0x10,
|
[CanonicalKey.ShiftRight]: 0x20,
|
||||||
ShiftRight: 0x20,
|
[CanonicalKey.AltRight]: 0x40,
|
||||||
AltRight: 0x40,
|
[CanonicalKey.MetaRight]: 0x80,
|
||||||
AltGr: 0x40,
|
|
||||||
MetaRight: 0x80,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type ModifierName = keyof typeof modifiers
|
|
||||||
|
|
||||||
// Map HID key codes to modifier bitmasks
|
|
||||||
export const hidKeyToModifierMask: Record<number, number> = {
|
|
||||||
0xe0: 0x01, // ControlLeft
|
|
||||||
0xe1: 0x02, // ShiftLeft
|
|
||||||
0xe2: 0x04, // AltLeft
|
|
||||||
0xe3: 0x08, // MetaLeft
|
|
||||||
0xe4: 0x10, // ControlRight
|
|
||||||
0xe5: 0x20, // ShiftRight
|
|
||||||
0xe6: 0x40, // AltRight
|
|
||||||
0xe7: 0x80, // MetaRight
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update modifier mask when a HID modifier key is pressed/released.
|
export const keyToHidUsage = {
|
||||||
export function updateModifierMaskForHidKey(mask: number, hidKey: number, press: boolean): number {
|
[CanonicalKey.KeyA]: 0x04,
|
||||||
const bit = hidKeyToModifierMask[hidKey] ?? 0
|
[CanonicalKey.KeyB]: 0x05,
|
||||||
|
[CanonicalKey.KeyC]: 0x06,
|
||||||
|
[CanonicalKey.KeyD]: 0x07,
|
||||||
|
[CanonicalKey.KeyE]: 0x08,
|
||||||
|
[CanonicalKey.KeyF]: 0x09,
|
||||||
|
[CanonicalKey.KeyG]: 0x0a,
|
||||||
|
[CanonicalKey.KeyH]: 0x0b,
|
||||||
|
[CanonicalKey.KeyI]: 0x0c,
|
||||||
|
[CanonicalKey.KeyJ]: 0x0d,
|
||||||
|
[CanonicalKey.KeyK]: 0x0e,
|
||||||
|
[CanonicalKey.KeyL]: 0x0f,
|
||||||
|
[CanonicalKey.KeyM]: 0x10,
|
||||||
|
[CanonicalKey.KeyN]: 0x11,
|
||||||
|
[CanonicalKey.KeyO]: 0x12,
|
||||||
|
[CanonicalKey.KeyP]: 0x13,
|
||||||
|
[CanonicalKey.KeyQ]: 0x14,
|
||||||
|
[CanonicalKey.KeyR]: 0x15,
|
||||||
|
[CanonicalKey.KeyS]: 0x16,
|
||||||
|
[CanonicalKey.KeyT]: 0x17,
|
||||||
|
[CanonicalKey.KeyU]: 0x18,
|
||||||
|
[CanonicalKey.KeyV]: 0x19,
|
||||||
|
[CanonicalKey.KeyW]: 0x1a,
|
||||||
|
[CanonicalKey.KeyX]: 0x1b,
|
||||||
|
[CanonicalKey.KeyY]: 0x1c,
|
||||||
|
[CanonicalKey.KeyZ]: 0x1d,
|
||||||
|
[CanonicalKey.Digit1]: 0x1e,
|
||||||
|
[CanonicalKey.Digit2]: 0x1f,
|
||||||
|
[CanonicalKey.Digit3]: 0x20,
|
||||||
|
[CanonicalKey.Digit4]: 0x21,
|
||||||
|
[CanonicalKey.Digit5]: 0x22,
|
||||||
|
[CanonicalKey.Digit6]: 0x23,
|
||||||
|
[CanonicalKey.Digit7]: 0x24,
|
||||||
|
[CanonicalKey.Digit8]: 0x25,
|
||||||
|
[CanonicalKey.Digit9]: 0x26,
|
||||||
|
[CanonicalKey.Digit0]: 0x27,
|
||||||
|
[CanonicalKey.Enter]: 0x28,
|
||||||
|
[CanonicalKey.Escape]: 0x29,
|
||||||
|
[CanonicalKey.Backspace]: 0x2a,
|
||||||
|
[CanonicalKey.Tab]: 0x2b,
|
||||||
|
[CanonicalKey.Space]: 0x2c,
|
||||||
|
[CanonicalKey.Minus]: 0x2d,
|
||||||
|
[CanonicalKey.Equal]: 0x2e,
|
||||||
|
[CanonicalKey.BracketLeft]: 0x2f,
|
||||||
|
[CanonicalKey.BracketRight]: 0x30,
|
||||||
|
[CanonicalKey.Backslash]: 0x31,
|
||||||
|
[CanonicalKey.Semicolon]: 0x33,
|
||||||
|
[CanonicalKey.Quote]: 0x34,
|
||||||
|
[CanonicalKey.Backquote]: 0x35,
|
||||||
|
[CanonicalKey.Comma]: 0x36,
|
||||||
|
[CanonicalKey.Period]: 0x37,
|
||||||
|
[CanonicalKey.Slash]: 0x38,
|
||||||
|
[CanonicalKey.CapsLock]: 0x39,
|
||||||
|
[CanonicalKey.F1]: 0x3a,
|
||||||
|
[CanonicalKey.F2]: 0x3b,
|
||||||
|
[CanonicalKey.F3]: 0x3c,
|
||||||
|
[CanonicalKey.F4]: 0x3d,
|
||||||
|
[CanonicalKey.F5]: 0x3e,
|
||||||
|
[CanonicalKey.F6]: 0x3f,
|
||||||
|
[CanonicalKey.F7]: 0x40,
|
||||||
|
[CanonicalKey.F8]: 0x41,
|
||||||
|
[CanonicalKey.F9]: 0x42,
|
||||||
|
[CanonicalKey.F10]: 0x43,
|
||||||
|
[CanonicalKey.F11]: 0x44,
|
||||||
|
[CanonicalKey.F12]: 0x45,
|
||||||
|
[CanonicalKey.PrintScreen]: 0x46,
|
||||||
|
[CanonicalKey.ScrollLock]: 0x47,
|
||||||
|
[CanonicalKey.Pause]: 0x48,
|
||||||
|
[CanonicalKey.Insert]: 0x49,
|
||||||
|
[CanonicalKey.Home]: 0x4a,
|
||||||
|
[CanonicalKey.PageUp]: 0x4b,
|
||||||
|
[CanonicalKey.Delete]: 0x4c,
|
||||||
|
[CanonicalKey.End]: 0x4d,
|
||||||
|
[CanonicalKey.PageDown]: 0x4e,
|
||||||
|
[CanonicalKey.ArrowRight]: 0x4f,
|
||||||
|
[CanonicalKey.ArrowLeft]: 0x50,
|
||||||
|
[CanonicalKey.ArrowDown]: 0x51,
|
||||||
|
[CanonicalKey.ArrowUp]: 0x52,
|
||||||
|
[CanonicalKey.NumLock]: 0x53,
|
||||||
|
[CanonicalKey.NumpadDivide]: 0x54,
|
||||||
|
[CanonicalKey.NumpadMultiply]: 0x55,
|
||||||
|
[CanonicalKey.NumpadSubtract]: 0x56,
|
||||||
|
[CanonicalKey.NumpadAdd]: 0x57,
|
||||||
|
[CanonicalKey.NumpadEnter]: 0x58,
|
||||||
|
[CanonicalKey.Numpad1]: 0x59,
|
||||||
|
[CanonicalKey.Numpad2]: 0x5a,
|
||||||
|
[CanonicalKey.Numpad3]: 0x5b,
|
||||||
|
[CanonicalKey.Numpad4]: 0x5c,
|
||||||
|
[CanonicalKey.Numpad5]: 0x5d,
|
||||||
|
[CanonicalKey.Numpad6]: 0x5e,
|
||||||
|
[CanonicalKey.Numpad7]: 0x5f,
|
||||||
|
[CanonicalKey.Numpad8]: 0x60,
|
||||||
|
[CanonicalKey.Numpad9]: 0x61,
|
||||||
|
[CanonicalKey.Numpad0]: 0x62,
|
||||||
|
[CanonicalKey.NumpadDecimal]: 0x63,
|
||||||
|
[CanonicalKey.IntlBackslash]: 0x64,
|
||||||
|
[CanonicalKey.ContextMenu]: 0x65,
|
||||||
|
[CanonicalKey.F13]: 0x68,
|
||||||
|
[CanonicalKey.F14]: 0x69,
|
||||||
|
[CanonicalKey.F15]: 0x6a,
|
||||||
|
[CanonicalKey.F16]: 0x6b,
|
||||||
|
[CanonicalKey.F17]: 0x6c,
|
||||||
|
[CanonicalKey.F18]: 0x6d,
|
||||||
|
[CanonicalKey.F19]: 0x6e,
|
||||||
|
[CanonicalKey.F20]: 0x6f,
|
||||||
|
[CanonicalKey.F21]: 0x70,
|
||||||
|
[CanonicalKey.F22]: 0x71,
|
||||||
|
[CanonicalKey.F23]: 0x72,
|
||||||
|
[CanonicalKey.F24]: 0x73,
|
||||||
|
[CanonicalKey.ControlLeft]: 0xe0,
|
||||||
|
[CanonicalKey.ShiftLeft]: 0xe1,
|
||||||
|
[CanonicalKey.AltLeft]: 0xe2,
|
||||||
|
[CanonicalKey.MetaLeft]: 0xe3,
|
||||||
|
[CanonicalKey.ControlRight]: 0xe4,
|
||||||
|
[CanonicalKey.ShiftRight]: 0xe5,
|
||||||
|
[CanonicalKey.AltRight]: 0xe6,
|
||||||
|
[CanonicalKey.MetaRight]: 0xe7,
|
||||||
|
} as const satisfies Record<CanonicalKey, number>
|
||||||
|
|
||||||
|
export function canonicalKeyToHidUsage(key: CanonicalKey): number {
|
||||||
|
return keyToHidUsage[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateModifierMaskForKey(mask: number, key: CanonicalKey, press: boolean): number {
|
||||||
|
const bit = modifiers[key] ?? 0
|
||||||
if (bit === 0) return mask
|
if (bit === 0) return mask
|
||||||
return press ? (mask | bit) : (mask & ~bit)
|
return press ? (mask | bit) : (mask & ~bit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keys that latch (toggle state) instead of being held
|
export const latchingKeys = [
|
||||||
export const latchingKeys = ['CapsLock', 'ScrollLock', 'NumLock'] as const
|
CanonicalKey.CapsLock,
|
||||||
|
CanonicalKey.ScrollLock,
|
||||||
// Modifier key names
|
CanonicalKey.NumLock,
|
||||||
export const modifierKeyNames = [
|
|
||||||
'ControlLeft',
|
|
||||||
'ControlRight',
|
|
||||||
'ShiftLeft',
|
|
||||||
'ShiftRight',
|
|
||||||
'AltLeft',
|
|
||||||
'AltRight',
|
|
||||||
'AltGr',
|
|
||||||
'MetaLeft',
|
|
||||||
'MetaRight',
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
// Check if a key is a modifier
|
|
||||||
export function isModifierKey(keyName: string): keyName is ModifierName {
|
|
||||||
return keyName in modifiers
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get modifier bitmask for a key name
|
|
||||||
export function getModifierMask(keyName: string): number {
|
|
||||||
if (keyName in modifiers) {
|
|
||||||
return modifiers[keyName as ModifierName]
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize browser-specific KeyboardEvent.code variants.
|
// Normalize browser-specific KeyboardEvent.code variants.
|
||||||
export function normalizeKeyboardCode(code: string, key: string): string {
|
export function normalizeKeyboardCode(code: string, key: string): string {
|
||||||
if (code === 'IntlBackslash' && (key === '`' || key === '~')) return 'Backquote'
|
if (code === 'IntlBackslash' && (key === '`' || key === '~')) return 'Backquote'
|
||||||
@@ -238,18 +293,10 @@ export function normalizeKeyboardCode(code: string, key: string): string {
|
|||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert KeyboardEvent.code/key to USB HID usage code.
|
export function keyboardEventToCanonicalKey(code: string, key: string): CanonicalKey | undefined {
|
||||||
export function keyboardEventToHidCode(code: string, key: string): number | undefined {
|
|
||||||
const normalizedCode = normalizeKeyboardCode(code, key)
|
const normalizedCode = normalizeKeyboardCode(code, key)
|
||||||
|
if (normalizedCode in keys) {
|
||||||
return keys[normalizedCode as KeyName]
|
return keys[normalizedCode as KeyName]
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
// Decode modifier byte into individual states
|
|
||||||
export function decodeModifiers(modifier: number) {
|
|
||||||
return {
|
|
||||||
isShiftActive: (modifier & 0x22) !== 0, // ShiftLeft | ShiftRight
|
|
||||||
isControlActive: (modifier & 0x11) !== 0, // ControlLeft | ControlRight
|
|
||||||
isAltActive: (modifier & 0x44) !== 0, // AltLeft | AltRight
|
|
||||||
isMetaActive: (modifier & 0x88) !== 0, // MetaLeft | MetaRight
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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_ch9329_baudrate?: number
|
||||||
hid_otg_udc?: string
|
hid_otg_udc?: string
|
||||||
hid_otg_profile?: string
|
hid_otg_profile?: string
|
||||||
|
hid_otg_endpoint_budget?: string
|
||||||
|
hid_otg_keyboard_leds?: boolean
|
||||||
|
msd_enabled?: boolean
|
||||||
encoder_backend?: string
|
encoder_backend?: string
|
||||||
audio_device?: string
|
audio_device?: string
|
||||||
ttyd_enabled?: boolean
|
ttyd_enabled?: boolean
|
||||||
|
|||||||
@@ -32,7 +32,14 @@ interface HidState {
|
|||||||
available: boolean
|
available: boolean
|
||||||
backend: string
|
backend: string
|
||||||
initialized: boolean
|
initialized: boolean
|
||||||
|
online: boolean
|
||||||
supportsAbsoluteMouse: boolean
|
supportsAbsoluteMouse: boolean
|
||||||
|
keyboardLedsEnabled: boolean
|
||||||
|
ledState: {
|
||||||
|
numLock: boolean
|
||||||
|
capsLock: boolean
|
||||||
|
scrollLock: boolean
|
||||||
|
}
|
||||||
device: string | null
|
device: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
errorCode: string | null
|
errorCode: string | null
|
||||||
@@ -86,9 +93,19 @@ export interface HidDeviceInfo {
|
|||||||
available: boolean
|
available: boolean
|
||||||
backend: string
|
backend: string
|
||||||
initialized: boolean
|
initialized: boolean
|
||||||
|
online: boolean
|
||||||
supports_absolute_mouse: boolean
|
supports_absolute_mouse: boolean
|
||||||
|
keyboard_leds_enabled: boolean
|
||||||
|
led_state: {
|
||||||
|
num_lock: boolean
|
||||||
|
caps_lock: boolean
|
||||||
|
scroll_lock: boolean
|
||||||
|
compose: boolean
|
||||||
|
kana: boolean
|
||||||
|
}
|
||||||
device: string | null
|
device: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
|
error_code?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MsdDeviceInfo {
|
export interface MsdDeviceInfo {
|
||||||
@@ -115,12 +132,18 @@ export interface AudioDeviceInfo {
|
|||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TtydDeviceInfo {
|
||||||
|
available: boolean
|
||||||
|
running: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeviceInfoEvent {
|
export interface DeviceInfoEvent {
|
||||||
video: VideoDeviceInfo
|
video: VideoDeviceInfo
|
||||||
hid: HidDeviceInfo
|
hid: HidDeviceInfo
|
||||||
msd: MsdDeviceInfo | null
|
msd: MsdDeviceInfo | null
|
||||||
atx: AtxDeviceInfo | null
|
atx: AtxDeviceInfo | null
|
||||||
audio: AudioDeviceInfo | null
|
audio: AudioDeviceInfo | null
|
||||||
|
ttyd: TtydDeviceInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSystemStore = defineStore('system', () => {
|
export const useSystemStore = defineStore('system', () => {
|
||||||
@@ -183,10 +206,17 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
available: state.available,
|
available: state.available,
|
||||||
backend: state.backend,
|
backend: state.backend,
|
||||||
initialized: state.initialized,
|
initialized: state.initialized,
|
||||||
|
online: state.online,
|
||||||
supportsAbsoluteMouse: state.supports_absolute_mouse,
|
supportsAbsoluteMouse: state.supports_absolute_mouse,
|
||||||
device: null,
|
keyboardLedsEnabled: state.keyboard_leds_enabled,
|
||||||
error: null,
|
ledState: {
|
||||||
errorCode: null,
|
numLock: state.led_state.num_lock,
|
||||||
|
capsLock: state.led_state.caps_lock,
|
||||||
|
scrollLock: state.led_state.scroll_lock,
|
||||||
|
},
|
||||||
|
device: state.device ?? null,
|
||||||
|
error: state.error ?? null,
|
||||||
|
errorCode: state.error_code ?? null,
|
||||||
}
|
}
|
||||||
return state
|
return state
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -286,11 +316,17 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
available: data.hid.available,
|
available: data.hid.available,
|
||||||
backend: data.hid.backend,
|
backend: data.hid.backend,
|
||||||
initialized: data.hid.initialized,
|
initialized: data.hid.initialized,
|
||||||
|
online: data.hid.online,
|
||||||
supportsAbsoluteMouse: data.hid.supports_absolute_mouse,
|
supportsAbsoluteMouse: data.hid.supports_absolute_mouse,
|
||||||
|
keyboardLedsEnabled: data.hid.keyboard_leds_enabled,
|
||||||
|
ledState: {
|
||||||
|
numLock: data.hid.led_state.num_lock,
|
||||||
|
capsLock: data.hid.led_state.caps_lock,
|
||||||
|
scrollLock: data.hid.led_state.scroll_lock,
|
||||||
|
},
|
||||||
device: data.hid.device,
|
device: data.hid.device,
|
||||||
error: data.hid.error,
|
error: data.hid.error,
|
||||||
// system.device_info does not include HID error_code, keep latest one when error still exists.
|
errorCode: data.hid.error_code ?? null,
|
||||||
errorCode: data.hid.error ? (hid.value?.errorCode ?? null) : null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update MSD state (optional)
|
// Update MSD state (optional)
|
||||||
@@ -360,28 +396,6 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update HID state from hid.state_changed / hid.device_lost events.
|
|
||||||
*/
|
|
||||||
function updateHidStateFromEvent(data: {
|
|
||||||
backend: string
|
|
||||||
initialized: boolean
|
|
||||||
error?: string | null
|
|
||||||
error_code?: string | null
|
|
||||||
}) {
|
|
||||||
const current = hid.value
|
|
||||||
const nextBackend = data.backend || current?.backend || 'unknown'
|
|
||||||
hid.value = {
|
|
||||||
available: nextBackend !== 'none',
|
|
||||||
backend: nextBackend,
|
|
||||||
initialized: data.initialized,
|
|
||||||
supportsAbsoluteMouse: current?.supportsAbsoluteMouse ?? false,
|
|
||||||
device: current?.device ?? null,
|
|
||||||
error: data.error ?? null,
|
|
||||||
errorCode: data.error_code ?? null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
buildDate,
|
buildDate,
|
||||||
@@ -406,7 +420,6 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
updateWsConnection,
|
updateWsConnection,
|
||||||
updateHidWsConnection,
|
updateHidWsConnection,
|
||||||
updateFromDeviceInfo,
|
updateFromDeviceInfo,
|
||||||
updateHidStateFromEvent,
|
|
||||||
updateStreamClients,
|
updateStreamClients,
|
||||||
setStreamOnline,
|
setStreamOnline,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,12 +58,8 @@ export interface OtgDescriptorConfig {
|
|||||||
export enum OtgHidProfile {
|
export enum OtgHidProfile {
|
||||||
/** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */
|
/** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */
|
||||||
Full = "full",
|
Full = "full",
|
||||||
/** Full HID device set without MSD */
|
|
||||||
FullNoMsd = "full_no_msd",
|
|
||||||
/** Full HID device set without consumer control */
|
/** Full HID device set without consumer control */
|
||||||
FullNoConsumer = "full_no_consumer",
|
FullNoConsumer = "full_no_consumer",
|
||||||
/** Full HID device set without consumer control and MSD */
|
|
||||||
FullNoConsumerNoMsd = "full_no_consumer_no_msd",
|
|
||||||
/** Legacy profile: only keyboard */
|
/** Legacy profile: only keyboard */
|
||||||
LegacyKeyboard = "legacy_keyboard",
|
LegacyKeyboard = "legacy_keyboard",
|
||||||
/** Legacy profile: only relative mouse */
|
/** Legacy profile: only relative mouse */
|
||||||
@@ -72,6 +68,18 @@ export enum OtgHidProfile {
|
|||||||
Custom = "custom",
|
Custom = "custom",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** OTG endpoint budget policy. */
|
||||||
|
export enum OtgEndpointBudget {
|
||||||
|
/** Derive a safe default from the selected UDC. */
|
||||||
|
Auto = "auto",
|
||||||
|
/** Limit OTG gadget functions to 5 endpoints. */
|
||||||
|
Five = "five",
|
||||||
|
/** Limit OTG gadget functions to 6 endpoints. */
|
||||||
|
Six = "six",
|
||||||
|
/** Do not impose a software endpoint budget. */
|
||||||
|
Unlimited = "unlimited",
|
||||||
|
}
|
||||||
|
|
||||||
/** OTG HID function selection (used when profile is Custom) */
|
/** OTG HID function selection (used when profile is Custom) */
|
||||||
export interface OtgHidFunctions {
|
export interface OtgHidFunctions {
|
||||||
keyboard: boolean;
|
keyboard: boolean;
|
||||||
@@ -84,18 +92,18 @@ export interface OtgHidFunctions {
|
|||||||
export interface HidConfig {
|
export interface HidConfig {
|
||||||
/** HID backend type */
|
/** HID backend type */
|
||||||
backend: HidBackend;
|
backend: HidBackend;
|
||||||
/** OTG keyboard device path */
|
|
||||||
otg_keyboard: string;
|
|
||||||
/** OTG mouse device path */
|
|
||||||
otg_mouse: string;
|
|
||||||
/** OTG UDC (USB Device Controller) name */
|
/** OTG UDC (USB Device Controller) name */
|
||||||
otg_udc?: string;
|
otg_udc?: string;
|
||||||
/** OTG USB device descriptor configuration */
|
/** OTG USB device descriptor configuration */
|
||||||
otg_descriptor?: OtgDescriptorConfig;
|
otg_descriptor?: OtgDescriptorConfig;
|
||||||
/** OTG HID function profile */
|
/** OTG HID function profile */
|
||||||
otg_profile?: OtgHidProfile;
|
otg_profile?: OtgHidProfile;
|
||||||
|
/** OTG endpoint budget policy */
|
||||||
|
otg_endpoint_budget?: OtgEndpointBudget;
|
||||||
/** OTG HID function selection (used when profile is Custom) */
|
/** OTG HID function selection (used when profile is Custom) */
|
||||||
otg_functions?: OtgHidFunctions;
|
otg_functions?: OtgHidFunctions;
|
||||||
|
/** Enable keyboard LED/status feedback for OTG keyboard */
|
||||||
|
otg_keyboard_leds?: boolean;
|
||||||
/** CH9329 serial port */
|
/** CH9329 serial port */
|
||||||
ch9329_port: string;
|
ch9329_port: string;
|
||||||
/** CH9329 baud rate */
|
/** CH9329 baud rate */
|
||||||
@@ -118,7 +126,7 @@ export enum AtxDriverType {
|
|||||||
Gpio = "gpio",
|
Gpio = "gpio",
|
||||||
/** USB HID relay module */
|
/** USB HID relay module */
|
||||||
UsbRelay = "usbrelay",
|
UsbRelay = "usbrelay",
|
||||||
/** Serial/COM port relay (LCUS type) */
|
/** Serial/COM port relay (taobao LCUS type) */
|
||||||
Serial = "serial",
|
Serial = "serial",
|
||||||
/** Disabled / Not configured */
|
/** Disabled / Not configured */
|
||||||
None = "none",
|
None = "none",
|
||||||
@@ -149,6 +157,7 @@ export interface AtxKeyConfig {
|
|||||||
* Pin or channel number:
|
* Pin or channel number:
|
||||||
* - For GPIO: GPIO pin number
|
* - For GPIO: GPIO pin number
|
||||||
* - For USB Relay: relay channel (0-based)
|
* - For USB Relay: relay channel (0-based)
|
||||||
|
* - For Serial Relay (LCUS): relay channel (1-based)
|
||||||
*/
|
*/
|
||||||
pin: number;
|
pin: number;
|
||||||
/** Active level (only applicable to GPIO, ignored for USB Relay) */
|
/** Active level (only applicable to GPIO, ignored for USB Relay) */
|
||||||
@@ -444,11 +453,11 @@ export interface AtxConfigUpdate {
|
|||||||
/** Available ATX devices for discovery */
|
/** Available ATX devices for discovery */
|
||||||
export interface AtxDevices {
|
export interface AtxDevices {
|
||||||
/** Available GPIO chips (/dev/gpiochip*) */
|
/** Available GPIO chips (/dev/gpiochip*) */
|
||||||
/** Available Serial ports (/dev/ttyUSB*) */
|
|
||||||
serial_ports: string[];
|
|
||||||
gpio_chips: string[];
|
gpio_chips: string[];
|
||||||
/** Available USB HID relay devices (/dev/hidraw*) */
|
/** Available USB HID relay devices (/dev/hidraw*) */
|
||||||
usb_relays: string[];
|
usb_relays: string[];
|
||||||
|
/** Available Serial ports (/dev/ttyUSB*) */
|
||||||
|
serial_ports: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudioConfigUpdate {
|
export interface AudioConfigUpdate {
|
||||||
@@ -579,7 +588,9 @@ export interface HidConfigUpdate {
|
|||||||
otg_udc?: string;
|
otg_udc?: string;
|
||||||
otg_descriptor?: OtgDescriptorConfigUpdate;
|
otg_descriptor?: OtgDescriptorConfigUpdate;
|
||||||
otg_profile?: OtgHidProfile;
|
otg_profile?: OtgHidProfile;
|
||||||
|
otg_endpoint_budget?: OtgEndpointBudget;
|
||||||
otg_functions?: OtgHidFunctionsUpdate;
|
otg_functions?: OtgHidFunctionsUpdate;
|
||||||
|
otg_keyboard_leds?: boolean;
|
||||||
mouse_absolute?: boolean;
|
mouse_absolute?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,19 +634,19 @@ export interface RustDeskConfigUpdate {
|
|||||||
device_password?: string;
|
device_password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stream 配置响应(包含 has_turn_password 字段) */
|
/** Stream configuration response (includes has_turn_password) */
|
||||||
export interface StreamConfigResponse {
|
export interface StreamConfigResponse {
|
||||||
mode: StreamMode;
|
mode: StreamMode;
|
||||||
encoder: EncoderType;
|
encoder: EncoderType;
|
||||||
bitrate_preset: BitratePreset;
|
bitrate_preset: BitratePreset;
|
||||||
/** 是否有公共 ICE 服务器可用(编译时确定) */
|
/** Whether public ICE servers are available (compile-time decision) */
|
||||||
has_public_ice_servers: boolean;
|
has_public_ice_servers: boolean;
|
||||||
/** 当前是否正在使用公共 ICE 服务器(STUN/TURN 都为空时) */
|
/** Whether public ICE servers are currently in use (when STUN/TURN are unset) */
|
||||||
using_public_ice_servers: boolean;
|
using_public_ice_servers: boolean;
|
||||||
stun_server?: string;
|
stun_server?: string;
|
||||||
turn_server?: string;
|
turn_server?: string;
|
||||||
turn_username?: string;
|
turn_username?: string;
|
||||||
/** 指示是否已设置 TURN 密码(实际密码不返回) */
|
/** Indicates whether TURN password has been configured (password is not returned) */
|
||||||
has_turn_password: boolean;
|
has_turn_password: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,12 +677,6 @@ export interface TtydConfigUpdate {
|
|||||||
shell?: string;
|
shell?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Simple ttyd status for console view */
|
|
||||||
export interface TtydStatus {
|
|
||||||
available: boolean;
|
|
||||||
running: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoConfigUpdate {
|
export interface VideoConfigUpdate {
|
||||||
device?: string;
|
device?: string;
|
||||||
format?: string;
|
format?: string;
|
||||||
@@ -688,3 +693,130 @@ export interface WebConfigUpdate {
|
|||||||
bind_address?: string;
|
bind_address?: string;
|
||||||
https_enabled?: boolean;
|
https_enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared canonical keyboard key identifiers used across frontend and backend.
|
||||||
|
*
|
||||||
|
* The enum names intentionally mirror `KeyboardEvent.code` style values so the
|
||||||
|
* browser, virtual keyboard, and HID backend can all speak the same language.
|
||||||
|
*/
|
||||||
|
export enum CanonicalKey {
|
||||||
|
KeyA = "KeyA",
|
||||||
|
KeyB = "KeyB",
|
||||||
|
KeyC = "KeyC",
|
||||||
|
KeyD = "KeyD",
|
||||||
|
KeyE = "KeyE",
|
||||||
|
KeyF = "KeyF",
|
||||||
|
KeyG = "KeyG",
|
||||||
|
KeyH = "KeyH",
|
||||||
|
KeyI = "KeyI",
|
||||||
|
KeyJ = "KeyJ",
|
||||||
|
KeyK = "KeyK",
|
||||||
|
KeyL = "KeyL",
|
||||||
|
KeyM = "KeyM",
|
||||||
|
KeyN = "KeyN",
|
||||||
|
KeyO = "KeyO",
|
||||||
|
KeyP = "KeyP",
|
||||||
|
KeyQ = "KeyQ",
|
||||||
|
KeyR = "KeyR",
|
||||||
|
KeyS = "KeyS",
|
||||||
|
KeyT = "KeyT",
|
||||||
|
KeyU = "KeyU",
|
||||||
|
KeyV = "KeyV",
|
||||||
|
KeyW = "KeyW",
|
||||||
|
KeyX = "KeyX",
|
||||||
|
KeyY = "KeyY",
|
||||||
|
KeyZ = "KeyZ",
|
||||||
|
Digit1 = "Digit1",
|
||||||
|
Digit2 = "Digit2",
|
||||||
|
Digit3 = "Digit3",
|
||||||
|
Digit4 = "Digit4",
|
||||||
|
Digit5 = "Digit5",
|
||||||
|
Digit6 = "Digit6",
|
||||||
|
Digit7 = "Digit7",
|
||||||
|
Digit8 = "Digit8",
|
||||||
|
Digit9 = "Digit9",
|
||||||
|
Digit0 = "Digit0",
|
||||||
|
Enter = "Enter",
|
||||||
|
Escape = "Escape",
|
||||||
|
Backspace = "Backspace",
|
||||||
|
Tab = "Tab",
|
||||||
|
Space = "Space",
|
||||||
|
Minus = "Minus",
|
||||||
|
Equal = "Equal",
|
||||||
|
BracketLeft = "BracketLeft",
|
||||||
|
BracketRight = "BracketRight",
|
||||||
|
Backslash = "Backslash",
|
||||||
|
Semicolon = "Semicolon",
|
||||||
|
Quote = "Quote",
|
||||||
|
Backquote = "Backquote",
|
||||||
|
Comma = "Comma",
|
||||||
|
Period = "Period",
|
||||||
|
Slash = "Slash",
|
||||||
|
CapsLock = "CapsLock",
|
||||||
|
F1 = "F1",
|
||||||
|
F2 = "F2",
|
||||||
|
F3 = "F3",
|
||||||
|
F4 = "F4",
|
||||||
|
F5 = "F5",
|
||||||
|
F6 = "F6",
|
||||||
|
F7 = "F7",
|
||||||
|
F8 = "F8",
|
||||||
|
F9 = "F9",
|
||||||
|
F10 = "F10",
|
||||||
|
F11 = "F11",
|
||||||
|
F12 = "F12",
|
||||||
|
PrintScreen = "PrintScreen",
|
||||||
|
ScrollLock = "ScrollLock",
|
||||||
|
Pause = "Pause",
|
||||||
|
Insert = "Insert",
|
||||||
|
Home = "Home",
|
||||||
|
PageUp = "PageUp",
|
||||||
|
Delete = "Delete",
|
||||||
|
End = "End",
|
||||||
|
PageDown = "PageDown",
|
||||||
|
ArrowRight = "ArrowRight",
|
||||||
|
ArrowLeft = "ArrowLeft",
|
||||||
|
ArrowDown = "ArrowDown",
|
||||||
|
ArrowUp = "ArrowUp",
|
||||||
|
NumLock = "NumLock",
|
||||||
|
NumpadDivide = "NumpadDivide",
|
||||||
|
NumpadMultiply = "NumpadMultiply",
|
||||||
|
NumpadSubtract = "NumpadSubtract",
|
||||||
|
NumpadAdd = "NumpadAdd",
|
||||||
|
NumpadEnter = "NumpadEnter",
|
||||||
|
Numpad1 = "Numpad1",
|
||||||
|
Numpad2 = "Numpad2",
|
||||||
|
Numpad3 = "Numpad3",
|
||||||
|
Numpad4 = "Numpad4",
|
||||||
|
Numpad5 = "Numpad5",
|
||||||
|
Numpad6 = "Numpad6",
|
||||||
|
Numpad7 = "Numpad7",
|
||||||
|
Numpad8 = "Numpad8",
|
||||||
|
Numpad9 = "Numpad9",
|
||||||
|
Numpad0 = "Numpad0",
|
||||||
|
NumpadDecimal = "NumpadDecimal",
|
||||||
|
IntlBackslash = "IntlBackslash",
|
||||||
|
ContextMenu = "ContextMenu",
|
||||||
|
F13 = "F13",
|
||||||
|
F14 = "F14",
|
||||||
|
F15 = "F15",
|
||||||
|
F16 = "F16",
|
||||||
|
F17 = "F17",
|
||||||
|
F18 = "F18",
|
||||||
|
F19 = "F19",
|
||||||
|
F20 = "F20",
|
||||||
|
F21 = "F21",
|
||||||
|
F22 = "F22",
|
||||||
|
F23 = "F23",
|
||||||
|
F24 = "F24",
|
||||||
|
ControlLeft = "ControlLeft",
|
||||||
|
ShiftLeft = "ShiftLeft",
|
||||||
|
AltLeft = "AltLeft",
|
||||||
|
MetaLeft = "MetaLeft",
|
||||||
|
ControlRight = "ControlRight",
|
||||||
|
ShiftRight = "ShiftRight",
|
||||||
|
AltRight = "AltRight",
|
||||||
|
MetaRight = "MetaRight",
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// HID (Human Interface Device) type definitions
|
// HID (Human Interface Device) type definitions
|
||||||
// Shared between WebRTC DataChannel and WebSocket HID channels
|
// Shared between WebRTC DataChannel and WebSocket HID channels
|
||||||
|
|
||||||
|
import { type CanonicalKey } from '@/types/generated'
|
||||||
|
import { canonicalKeyToHidUsage } from '@/lib/keyboardMappings'
|
||||||
|
|
||||||
/** Keyboard event for HID input */
|
/** Keyboard event for HID input */
|
||||||
export interface HidKeyboardEvent {
|
export interface HidKeyboardEvent {
|
||||||
type: 'keydown' | 'keyup'
|
type: 'keydown' | 'keyup'
|
||||||
key: number
|
key: CanonicalKey
|
||||||
/** Raw HID modifier byte (bit0..bit7 = LCtrl..RMeta) */
|
/** Raw HID modifier byte (bit0..bit7 = LCtrl..RMeta) */
|
||||||
modifier?: number
|
modifier?: number
|
||||||
}
|
}
|
||||||
@@ -51,7 +54,7 @@ export function encodeKeyboardEvent(event: HidKeyboardEvent): ArrayBuffer {
|
|||||||
|
|
||||||
view.setUint8(0, MSG_KEYBOARD)
|
view.setUint8(0, MSG_KEYBOARD)
|
||||||
view.setUint8(1, event.type === 'keydown' ? KB_EVENT_DOWN : KB_EVENT_UP)
|
view.setUint8(1, event.type === 'keydown' ? KB_EVENT_DOWN : KB_EVENT_UP)
|
||||||
view.setUint8(2, event.key & 0xff)
|
view.setUint8(2, canonicalKeyToHidUsage(event.key) & 0xff)
|
||||||
|
|
||||||
view.setUint8(3, (event.modifier ?? 0) & 0xff)
|
view.setUint8(3, (event.modifier ?? 0) & 0xff)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, computed, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useSystemStore } from '@/stores/system'
|
import { useSystemStore } from '@/stores/system'
|
||||||
@@ -11,9 +11,10 @@ import { useHidWebSocket } from '@/composables/useHidWebSocket'
|
|||||||
import { useWebRTC } from '@/composables/useWebRTC'
|
import { useWebRTC } from '@/composables/useWebRTC'
|
||||||
import { useVideoSession } from '@/composables/useVideoSession'
|
import { useVideoSession } from '@/composables/useVideoSession'
|
||||||
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
||||||
import { streamApi, hidApi, atxApi, extensionsApi, atxConfigApi, authApi } from '@/api'
|
import { streamApi, hidApi, atxApi, atxConfigApi, authApi } from '@/api'
|
||||||
|
import { CanonicalKey, HidBackend } from '@/types/generated'
|
||||||
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
|
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
|
||||||
import { keyboardEventToHidCode, updateModifierMaskForHidKey } from '@/lib/keyboardMappings'
|
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { generateUUID } from '@/lib/utils'
|
import { generateUUID } from '@/lib/utils'
|
||||||
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
||||||
@@ -81,7 +82,6 @@ const consoleEvents = useConsoleEvents({
|
|||||||
onStreamDeviceLost: handleStreamDeviceLost,
|
onStreamDeviceLost: handleStreamDeviceLost,
|
||||||
onStreamRecovered: handleStreamRecovered,
|
onStreamRecovered: handleStreamRecovered,
|
||||||
onDeviceInfo: handleDeviceInfo,
|
onDeviceInfo: handleDeviceInfo,
|
||||||
onAudioStateChanged: handleAudioStateChanged,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Video mode state
|
// Video mode state
|
||||||
@@ -118,10 +118,13 @@ const myClientId = generateUUID()
|
|||||||
|
|
||||||
// HID state
|
// HID state
|
||||||
const mouseMode = ref<'absolute' | 'relative'>('absolute')
|
const mouseMode = ref<'absolute' | 'relative'>('absolute')
|
||||||
const pressedKeys = ref<string[]>([])
|
const pressedKeys = ref<CanonicalKey[]>([])
|
||||||
const keyboardLed = ref({
|
const keyboardLed = computed(() => ({
|
||||||
capsLock: false,
|
capsLock: systemStore.hid?.ledState.capsLock ?? false,
|
||||||
})
|
numLock: systemStore.hid?.ledState.numLock ?? false,
|
||||||
|
scrollLock: systemStore.hid?.ledState.scrollLock ?? false,
|
||||||
|
}))
|
||||||
|
const keyboardLedEnabled = computed(() => systemStore.hid?.keyboardLedsEnabled ?? false)
|
||||||
const activeModifierMask = ref(0)
|
const activeModifierMask = ref(0)
|
||||||
const mousePosition = ref({ x: 0, y: 0 })
|
const mousePosition = ref({ x: 0, y: 0 })
|
||||||
const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode
|
const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode
|
||||||
@@ -137,6 +140,8 @@ let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas be
|
|||||||
|
|
||||||
// Cursor visibility (from localStorage, updated via storage event)
|
// Cursor visibility (from localStorage, updated via storage event)
|
||||||
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
|
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
|
||||||
|
let interactionListenersBound = false
|
||||||
|
const isConsoleActive = ref(false)
|
||||||
|
|
||||||
function syncMouseModeFromConfig() {
|
function syncMouseModeFromConfig() {
|
||||||
const mouseAbsolute = configStore.hid?.mouse_absolute
|
const mouseAbsolute = configStore.hid?.mouse_absolute
|
||||||
@@ -151,6 +156,12 @@ function syncMouseModeFromConfig() {
|
|||||||
const virtualKeyboardVisible = ref(false)
|
const virtualKeyboardVisible = ref(false)
|
||||||
const virtualKeyboardAttached = ref(true)
|
const virtualKeyboardAttached = ref(true)
|
||||||
const statsSheetOpen = ref(false)
|
const statsSheetOpen = ref(false)
|
||||||
|
const virtualKeyboardConsumerEnabled = computed(() => {
|
||||||
|
const hid = configStore.hid
|
||||||
|
if (!hid) return true
|
||||||
|
if (hid.backend !== HidBackend.Otg) return true
|
||||||
|
return hid.otg_functions?.consumer !== false
|
||||||
|
})
|
||||||
|
|
||||||
// Change password dialog state
|
// Change password dialog state
|
||||||
const changePasswordDialogOpen = ref(false)
|
const changePasswordDialogOpen = ref(false)
|
||||||
@@ -162,7 +173,6 @@ const changingPassword = ref(false)
|
|||||||
// ttyd (web terminal) state
|
// ttyd (web terminal) state
|
||||||
const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
|
const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
|
||||||
const showTerminalDialog = ref(false)
|
const showTerminalDialog = ref(false)
|
||||||
let ttydPollInterval: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||||
@@ -174,6 +184,10 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
|
|||||||
|
|
||||||
if (videoError.value) return 'error'
|
if (videoError.value) return 'error'
|
||||||
if (videoLoading.value) return 'connecting'
|
if (videoLoading.value) return 'connecting'
|
||||||
|
if (videoMode.value !== 'mjpeg') {
|
||||||
|
if (webrtc.isConnecting.value) return 'connecting'
|
||||||
|
if (webrtc.isConnected.value) return 'connected'
|
||||||
|
}
|
||||||
if (systemStore.stream?.online) return 'connected'
|
if (systemStore.stream?.online) return 'connected'
|
||||||
return 'disconnected'
|
return 'disconnected'
|
||||||
})
|
})
|
||||||
@@ -227,6 +241,7 @@ const videoDetails = computed<StatusDetail[]>(() => {
|
|||||||
|
|
||||||
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||||
const hid = systemStore.hid
|
const hid = systemStore.hid
|
||||||
|
if (hid?.errorCode === 'udc_not_configured') return 'disconnected'
|
||||||
if (hid?.error) return 'error'
|
if (hid?.error) return 'error'
|
||||||
|
|
||||||
// In WebRTC mode, check DataChannel status first
|
// In WebRTC mode, check DataChannel status first
|
||||||
@@ -251,8 +266,8 @@ const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'
|
|||||||
if (hidWs.hidUnavailable.value) return 'disconnected'
|
if (hidWs.hidUnavailable.value) return 'disconnected'
|
||||||
|
|
||||||
// Normal status based on system state
|
// Normal status based on system state
|
||||||
if (hid?.available && hid.initialized) return 'connected'
|
if (hid?.available && hid.online) return 'connected'
|
||||||
if (hid?.available && !hid.initialized) return 'connecting'
|
if (hid?.available && hid.initialized) return 'connecting'
|
||||||
return 'disconnected'
|
return 'disconnected'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -264,29 +279,54 @@ const hidQuickInfo = computed(() => {
|
|||||||
return mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative')
|
return mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative')
|
||||||
})
|
})
|
||||||
|
|
||||||
function hidErrorHint(errorCode?: string | null, backend?: string | null): string {
|
function extractCh9329Command(reason?: string | null): string | null {
|
||||||
|
if (!reason) return null
|
||||||
|
const match = reason.match(/cmd 0x([0-9a-f]{2})/i)
|
||||||
|
const cmd = match?.[1]
|
||||||
|
return cmd ? `0x${cmd.toUpperCase()}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidErrorHint(errorCode?: string | null, backend?: string | null, reason?: string | null): string {
|
||||||
|
const ch9329Command = extractCh9329Command(reason)
|
||||||
|
|
||||||
switch (errorCode) {
|
switch (errorCode) {
|
||||||
case 'udc_not_configured':
|
case 'udc_not_configured':
|
||||||
return t('hid.errorHints.udcNotConfigured')
|
return t('hid.errorHints.udcNotConfigured')
|
||||||
|
case 'disabled':
|
||||||
|
return t('hid.errorHints.disabled')
|
||||||
case 'enoent':
|
case 'enoent':
|
||||||
return t('hid.errorHints.hidDeviceMissing')
|
return t('hid.errorHints.hidDeviceMissing')
|
||||||
|
case 'not_opened':
|
||||||
|
return t('hid.errorHints.notOpened')
|
||||||
case 'port_not_found':
|
case 'port_not_found':
|
||||||
case 'port_not_opened':
|
|
||||||
return t('hid.errorHints.portNotFound')
|
return t('hid.errorHints.portNotFound')
|
||||||
|
case 'invalid_config':
|
||||||
|
return t('hid.errorHints.invalidConfig')
|
||||||
case 'no_response':
|
case 'no_response':
|
||||||
return t('hid.errorHints.noResponse')
|
return t(ch9329Command ? 'hid.errorHints.noResponseWithCmd' : 'hid.errorHints.noResponse', {
|
||||||
|
cmd: ch9329Command ?? '',
|
||||||
|
})
|
||||||
case 'protocol_error':
|
case 'protocol_error':
|
||||||
case 'invalid_response':
|
case 'invalid_response':
|
||||||
return t('hid.errorHints.protocolError')
|
return t('hid.errorHints.protocolError')
|
||||||
case 'health_check_failed':
|
case 'enxio':
|
||||||
case 'health_check_join_failed':
|
case 'enodev':
|
||||||
return t('hid.errorHints.healthCheckFailed')
|
return t('hid.errorHints.deviceDisconnected')
|
||||||
case 'eio':
|
case 'eio':
|
||||||
case 'epipe':
|
case 'epipe':
|
||||||
case 'eshutdown':
|
case 'eshutdown':
|
||||||
|
case 'io_error':
|
||||||
|
case 'write_failed':
|
||||||
|
case 'read_failed':
|
||||||
if (backend === 'otg') return t('hid.errorHints.otgIoError')
|
if (backend === 'otg') return t('hid.errorHints.otgIoError')
|
||||||
if (backend === 'ch9329') return t('hid.errorHints.ch9329IoError')
|
if (backend === 'ch9329') return t('hid.errorHints.ch9329IoError')
|
||||||
return t('hid.errorHints.ioError')
|
return t('hid.errorHints.ioError')
|
||||||
|
case 'serial_error':
|
||||||
|
return t('hid.errorHints.serialError')
|
||||||
|
case 'init_failed':
|
||||||
|
return t('hid.errorHints.initFailed')
|
||||||
|
case 'shutdown':
|
||||||
|
return t('hid.errorHints.shutdown')
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -294,8 +334,8 @@ function hidErrorHint(errorCode?: string | null, backend?: string | null): strin
|
|||||||
|
|
||||||
function buildHidErrorMessage(reason?: string | null, errorCode?: string | null, backend?: string | null): string {
|
function buildHidErrorMessage(reason?: string | null, errorCode?: string | null, backend?: string | null): string {
|
||||||
if (!reason && !errorCode) return ''
|
if (!reason && !errorCode) return ''
|
||||||
const hint = hidErrorHint(errorCode, backend)
|
const hint = hidErrorHint(errorCode, backend, reason)
|
||||||
if (reason && hint) return `${reason} (${hint})`
|
if (hint) return hint
|
||||||
if (reason) return reason
|
if (reason) return reason
|
||||||
return hint || t('common.error')
|
return hint || t('common.error')
|
||||||
}
|
}
|
||||||
@@ -309,19 +349,29 @@ const hidDetails = computed<StatusDetail[]>(() => {
|
|||||||
const hid = systemStore.hid
|
const hid = systemStore.hid
|
||||||
if (!hid) return []
|
if (!hid) return []
|
||||||
const errorMessage = buildHidErrorMessage(hid.error, hid.errorCode, hid.backend)
|
const errorMessage = buildHidErrorMessage(hid.error, hid.errorCode, hid.backend)
|
||||||
|
const hidErrorStatus: StatusDetail['status'] =
|
||||||
|
hid.errorCode === 'udc_not_configured' ? 'warning' : 'error'
|
||||||
|
|
||||||
const details: StatusDetail[] = [
|
const details: StatusDetail[] = [
|
||||||
{ label: t('statusCard.device'), value: hid.device || '-' },
|
{ label: t('statusCard.device'), value: hid.device || '-' },
|
||||||
{ label: t('statusCard.backend'), value: hid.backend || t('common.unknown') },
|
{ label: t('statusCard.backend'), value: hid.backend || t('common.unknown') },
|
||||||
{ label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error ? 'error' : hid.initialized ? 'ok' : 'warning' },
|
{ label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error && hid.errorCode !== 'udc_not_configured' ? 'error' : hid.initialized ? 'ok' : 'warning' },
|
||||||
|
{ label: t('statusCard.online'), value: hid.online ? t('statusCard.yes') : t('statusCard.no'), status: hid.online ? 'ok' : hid.initialized ? 'warning' : 'error' },
|
||||||
{ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' },
|
{ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' },
|
||||||
|
{
|
||||||
|
label: t('settings.otgKeyboardLeds'),
|
||||||
|
value: hid.keyboardLedsEnabled
|
||||||
|
? `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`
|
||||||
|
: t('infobar.keyboardLedUnavailable'),
|
||||||
|
status: hid.keyboardLedsEnabled ? 'ok' : undefined,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
if (hid.errorCode) {
|
if (hid.errorCode) {
|
||||||
details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: 'error' })
|
details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: hidErrorStatus })
|
||||||
}
|
}
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
details.push({ label: t('common.error'), value: errorMessage, status: 'error' })
|
details.push({ label: t('common.error'), value: errorMessage, status: hidErrorStatus })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add HID channel info based on video mode
|
// Add HID channel info based on video mode
|
||||||
@@ -581,6 +631,7 @@ function waitForVideoFirstFrame(el: HTMLVideoElement, timeoutMs = 2000): Promise
|
|||||||
|
|
||||||
function shouldSuppressAutoReconnect(): boolean {
|
function shouldSuppressAutoReconnect(): boolean {
|
||||||
return videoMode.value === 'mjpeg'
|
return videoMode.value === 'mjpeg'
|
||||||
|
|| !isConsoleActive.value
|
||||||
|| videoSession.localSwitching.value
|
|| videoSession.localSwitching.value
|
||||||
|| videoSession.backendSwitching.value
|
|| videoSession.backendSwitching.value
|
||||||
|| videoRestarting.value
|
|| videoRestarting.value
|
||||||
@@ -932,7 +983,22 @@ async function restoreInitialMode(serverMode: VideoMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDeviceInfo(data: any) {
|
function handleDeviceInfo(data: any) {
|
||||||
|
const prevAudioStreaming = systemStore.audio?.streaming ?? false
|
||||||
|
const prevAudioDevice = systemStore.audio?.device ?? null
|
||||||
systemStore.updateFromDeviceInfo(data)
|
systemStore.updateFromDeviceInfo(data)
|
||||||
|
ttydStatus.value = data.ttyd ?? null
|
||||||
|
|
||||||
|
const nextAudioStreaming = systemStore.audio?.streaming ?? false
|
||||||
|
const nextAudioDevice = systemStore.audio?.device ?? null
|
||||||
|
if (
|
||||||
|
prevAudioStreaming !== nextAudioStreaming ||
|
||||||
|
prevAudioDevice !== nextAudioDevice
|
||||||
|
) {
|
||||||
|
void handleAudioStateChanged({
|
||||||
|
streaming: nextAudioStreaming,
|
||||||
|
device: nextAudioDevice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Skip mode sync if video config is being changed
|
// Skip mode sync if video config is being changed
|
||||||
// This prevents false-positive mode changes during config switching
|
// This prevents false-positive mode changes during config switching
|
||||||
@@ -1440,14 +1506,6 @@ async function handleChangePassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ttyd (web terminal) functions
|
// ttyd (web terminal) functions
|
||||||
async function fetchTtydStatus() {
|
|
||||||
try {
|
|
||||||
ttydStatus.value = await extensionsApi.getTtydStatus()
|
|
||||||
} catch {
|
|
||||||
ttydStatus.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openTerminal() {
|
function openTerminal() {
|
||||||
if (!ttydStatus.value?.running) return
|
if (!ttydStatus.value?.running) return
|
||||||
showTerminalDialog.value = true
|
showTerminalDialog.value = true
|
||||||
@@ -1500,7 +1558,7 @@ function handleHidError(_error: any, _operation: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HID channel selection: use WebRTC DataChannel when available, fallback to WebSocket
|
// HID channel selection: use WebRTC DataChannel when available, fallback to WebSocket
|
||||||
function sendKeyboardEvent(type: 'down' | 'up', key: number, modifier?: number) {
|
function sendKeyboardEvent(type: 'down' | 'up', key: CanonicalKey, modifier?: number) {
|
||||||
// In WebRTC mode with DataChannel ready, use DataChannel for lower latency
|
// In WebRTC mode with DataChannel ready, use DataChannel for lower latency
|
||||||
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
|
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
|
||||||
const event: HidKeyboardEvent = {
|
const event: HidKeyboardEvent = {
|
||||||
@@ -1579,22 +1637,19 @@ function handleKeyDown(e: KeyboardEvent) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyName = e.key === ' ' ? 'Space' : e.key
|
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
|
||||||
if (!pressedKeys.value.includes(keyName)) {
|
if (canonicalKey === undefined) {
|
||||||
pressedKeys.value = [...pressedKeys.value, keyName]
|
|
||||||
}
|
|
||||||
|
|
||||||
keyboardLed.value.capsLock = e.getModifierState('CapsLock')
|
|
||||||
|
|
||||||
const hidKey = keyboardEventToHidCode(e.code, e.key)
|
|
||||||
if (hidKey === undefined) {
|
|
||||||
console.warn(`[HID] Unmapped key down: code=${e.code}, key=${e.key}`)
|
console.warn(`[HID] Unmapped key down: code=${e.code}, key=${e.key}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, true)
|
if (!pressedKeys.value.includes(canonicalKey)) {
|
||||||
|
pressedKeys.value = [...pressedKeys.value, canonicalKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, true)
|
||||||
activeModifierMask.value = modifierMask
|
activeModifierMask.value = modifierMask
|
||||||
sendKeyboardEvent('down', hidKey, modifierMask)
|
sendKeyboardEvent('down', canonicalKey, modifierMask)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyUp(e: KeyboardEvent) {
|
function handleKeyUp(e: KeyboardEvent) {
|
||||||
@@ -1610,30 +1665,99 @@ function handleKeyUp(e: KeyboardEvent) {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyName = e.key === ' ' ? 'Space' : e.key
|
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
|
||||||
pressedKeys.value = pressedKeys.value.filter(k => k !== keyName)
|
if (canonicalKey === undefined) {
|
||||||
|
|
||||||
const hidKey = keyboardEventToHidCode(e.code, e.key)
|
|
||||||
if (hidKey === undefined) {
|
|
||||||
console.warn(`[HID] Unmapped key up: code=${e.code}, key=${e.key}`)
|
console.warn(`[HID] Unmapped key up: code=${e.code}, key=${e.key}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, false)
|
pressedKeys.value = pressedKeys.value.filter(k => k !== canonicalKey)
|
||||||
|
|
||||||
|
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, false)
|
||||||
activeModifierMask.value = modifierMask
|
activeModifierMask.value = modifierMask
|
||||||
sendKeyboardEvent('up', hidKey, modifierMask)
|
sendKeyboardEvent('up', canonicalKey, modifierMask)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveVideoElement(): HTMLImageElement | HTMLVideoElement | null {
|
||||||
|
return videoMode.value !== 'mjpeg' ? webrtcVideoRef.value : videoRef.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveVideoAspectRatio(): number | null {
|
||||||
|
if (videoMode.value !== 'mjpeg') {
|
||||||
|
const video = webrtcVideoRef.value
|
||||||
|
if (video?.videoWidth && video.videoHeight) {
|
||||||
|
return video.videoWidth / video.videoHeight
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const image = videoRef.value
|
||||||
|
if (image?.naturalWidth && image.naturalHeight) {
|
||||||
|
return image.naturalWidth / image.naturalHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoAspectRatio.value) return null
|
||||||
|
const [width, height] = videoAspectRatio.value.split('/').map(Number)
|
||||||
|
if (!width || !height) return null
|
||||||
|
return width / height
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRenderedVideoRect() {
|
||||||
|
const videoElement = getActiveVideoElement()
|
||||||
|
if (!videoElement) return null
|
||||||
|
|
||||||
|
const rect = videoElement.getBoundingClientRect()
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) return null
|
||||||
|
|
||||||
|
const contentAspectRatio = getActiveVideoAspectRatio()
|
||||||
|
if (!contentAspectRatio) {
|
||||||
|
return rect
|
||||||
|
}
|
||||||
|
|
||||||
|
const boxAspectRatio = rect.width / rect.height
|
||||||
|
if (!Number.isFinite(boxAspectRatio) || boxAspectRatio <= 0) {
|
||||||
|
return rect
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boxAspectRatio > contentAspectRatio) {
|
||||||
|
const width = rect.height * contentAspectRatio
|
||||||
|
return {
|
||||||
|
left: rect.left + (rect.width - width) / 2,
|
||||||
|
top: rect.top,
|
||||||
|
width,
|
||||||
|
height: rect.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const height = rect.width / contentAspectRatio
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top + (rect.height - height) / 2,
|
||||||
|
width: rect.width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAbsoluteMousePosition(e: MouseEvent) {
|
||||||
|
const rect = getRenderedVideoRect()
|
||||||
|
if (!rect) return null
|
||||||
|
|
||||||
|
const normalizedX = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
|
const normalizedY = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.round(normalizedX * 32767),
|
||||||
|
y: Math.round(normalizedY * 32767),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseMove(e: MouseEvent) {
|
function handleMouseMove(e: MouseEvent) {
|
||||||
// Use the appropriate video element based on current mode (WebRTC for h264/h265/vp8/vp9, MJPEG for mjpeg)
|
const videoElement = getActiveVideoElement()
|
||||||
const videoElement = videoMode.value !== 'mjpeg' ? webrtcVideoRef.value : videoRef.value
|
|
||||||
if (!videoElement) return
|
if (!videoElement) return
|
||||||
|
|
||||||
if (mouseMode.value === 'absolute') {
|
if (mouseMode.value === 'absolute') {
|
||||||
// Absolute mode: send absolute coordinates (0-32767 range)
|
const absolutePosition = getAbsoluteMousePosition(e)
|
||||||
const rect = videoElement.getBoundingClientRect()
|
if (!absolutePosition) return
|
||||||
const x = Math.round((e.clientX - rect.left) / rect.width * 32767)
|
const { x, y } = absolutePosition
|
||||||
const y = Math.round((e.clientY - rect.top) / rect.height * 32767)
|
|
||||||
|
|
||||||
mousePosition.value = { x, y }
|
mousePosition.value = { x, y }
|
||||||
// Queue for throttled sending (absolute mode: just update pending position)
|
// Queue for throttled sending (absolute mode: just update pending position)
|
||||||
@@ -1758,6 +1882,15 @@ function handleMouseDown(e: MouseEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mouseMode.value === 'absolute') {
|
||||||
|
const absolutePosition = getAbsoluteMousePosition(e)
|
||||||
|
if (absolutePosition) {
|
||||||
|
mousePosition.value = absolutePosition
|
||||||
|
sendMouseEvent({ type: 'move_abs', ...absolutePosition })
|
||||||
|
pendingMouseMove = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const button = e.button === 0 ? 'left' : e.button === 2 ? 'right' : 'middle'
|
const button = e.button === 0 ? 'left' : e.button === 2 ? 'right' : 'middle'
|
||||||
pressedMouseButton.value = button
|
pressedMouseButton.value = button
|
||||||
sendMouseEvent({ type: 'down', button })
|
sendMouseEvent({ type: 'down', button })
|
||||||
@@ -1838,6 +1971,10 @@ function handlePointerLockError() {
|
|||||||
isPointerLocked.value = false
|
isPointerLocked.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFullscreenChange() {
|
||||||
|
isFullscreen.value = !!document.fullscreenElement
|
||||||
|
}
|
||||||
|
|
||||||
function handleBlur() {
|
function handleBlur() {
|
||||||
pressedKeys.value = []
|
pressedKeys.value = []
|
||||||
activeModifierMask.value = 0
|
activeModifierMask.value = 0
|
||||||
@@ -1890,6 +2027,71 @@ function handleMouseSendIntervalStorage(e: StorageEvent) {
|
|||||||
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
|
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerInteractionListeners() {
|
||||||
|
if (interactionListenersBound) return
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
window.addEventListener('keyup', handleKeyUp)
|
||||||
|
window.addEventListener('blur', handleBlur)
|
||||||
|
window.addEventListener('mouseup', handleWindowMouseUp)
|
||||||
|
window.addEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
|
||||||
|
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
|
||||||
|
window.addEventListener('storage', handleMouseSendIntervalStorage)
|
||||||
|
|
||||||
|
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||||
|
document.addEventListener('pointerlockerror', handlePointerLockError)
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
|
|
||||||
|
interactionListenersBound = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterInteractionListeners() {
|
||||||
|
if (!interactionListenersBound) return
|
||||||
|
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
window.removeEventListener('keyup', handleKeyUp)
|
||||||
|
window.removeEventListener('blur', handleBlur)
|
||||||
|
window.removeEventListener('mouseup', handleWindowMouseUp)
|
||||||
|
window.removeEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
|
||||||
|
window.removeEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
|
||||||
|
window.removeEventListener('storage', handleMouseSendIntervalStorage)
|
||||||
|
|
||||||
|
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||||
|
document.removeEventListener('pointerlockerror', handlePointerLockError)
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
|
|
||||||
|
interactionListenersBound = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateConsoleView() {
|
||||||
|
isConsoleActive.value = true
|
||||||
|
registerInteractionListeners()
|
||||||
|
|
||||||
|
if (videoMode.value !== 'mjpeg' && webrtc.videoTrack.value) {
|
||||||
|
await nextTick()
|
||||||
|
await rebindWebRTCVideo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
videoMode.value !== 'mjpeg'
|
||||||
|
&& !webrtc.isConnected.value
|
||||||
|
&& !webrtc.isConnecting.value
|
||||||
|
&& !videoSession.localSwitching.value
|
||||||
|
&& !videoSession.backendSwitching.value
|
||||||
|
&& !initialModeRestoreInProgress
|
||||||
|
) {
|
||||||
|
await connectWebRTCOnly(videoMode.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivateConsoleView() {
|
||||||
|
isConsoleActive.value = false
|
||||||
|
handleBlur()
|
||||||
|
exitPointerLock()
|
||||||
|
unregisterInteractionListeners()
|
||||||
|
}
|
||||||
|
|
||||||
// ActionBar handlers
|
// ActionBar handlers
|
||||||
// (MSD and Settings are now handled by ActionBar component directly)
|
// (MSD and Settings are now handled by ActionBar component directly)
|
||||||
|
|
||||||
@@ -1898,18 +2100,14 @@ function handleToggleVirtualKeyboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Virtual keyboard key event handlers
|
// Virtual keyboard key event handlers
|
||||||
function handleVirtualKeyDown(key: string) {
|
function handleVirtualKeyDown(key: CanonicalKey) {
|
||||||
// Add to pressedKeys for InfoBar display
|
// Add to pressedKeys for InfoBar display
|
||||||
if (!pressedKeys.value.includes(key)) {
|
if (!pressedKeys.value.includes(key)) {
|
||||||
pressedKeys.value = [...pressedKeys.value, key]
|
pressedKeys.value = [...pressedKeys.value, key]
|
||||||
}
|
}
|
||||||
// Toggle CapsLock state when virtual keyboard presses CapsLock
|
|
||||||
if (key === 'CapsLock') {
|
|
||||||
keyboardLed.value.capsLock = !keyboardLed.value.capsLock
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVirtualKeyUp(key: string) {
|
function handleVirtualKeyUp(key: CanonicalKey) {
|
||||||
// Remove from pressedKeys
|
// Remove from pressedKeys
|
||||||
pressedKeys.value = pressedKeys.value.filter(k => k !== key)
|
pressedKeys.value = pressedKeys.value.filter(k => k !== key)
|
||||||
}
|
}
|
||||||
@@ -1961,40 +2159,18 @@ onMounted(async () => {
|
|||||||
syncMouseModeFromConfig()
|
syncMouseModeFromConfig()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
window.addEventListener('keyup', handleKeyUp)
|
|
||||||
window.addEventListener('blur', handleBlur)
|
|
||||||
window.addEventListener('mouseup', handleWindowMouseUp)
|
|
||||||
|
|
||||||
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
|
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
|
||||||
|
|
||||||
// Listen for cursor visibility changes from HidConfigPopover
|
|
||||||
window.addEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
|
|
||||||
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
|
|
||||||
window.addEventListener('storage', handleMouseSendIntervalStorage)
|
|
||||||
|
|
||||||
watch(() => configStore.hid?.mouse_absolute, () => {
|
watch(() => configStore.hid?.mouse_absolute, () => {
|
||||||
syncMouseModeFromConfig()
|
syncMouseModeFromConfig()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pointer Lock event listeners
|
|
||||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
document.addEventListener('pointerlockerror', handlePointerLockError)
|
|
||||||
|
|
||||||
document.addEventListener('fullscreenchange', () => {
|
|
||||||
isFullscreen.value = !!document.fullscreenElement
|
|
||||||
})
|
|
||||||
|
|
||||||
const storedTheme = localStorage.getItem('theme')
|
const storedTheme = localStorage.getItem('theme')
|
||||||
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
isDark.value = true
|
isDark.value = true
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add('dark')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch ttyd status initially and poll every 10 seconds
|
|
||||||
fetchTtydStatus()
|
|
||||||
ttydPollInterval = setInterval(fetchTtydStatus, 10000)
|
|
||||||
|
|
||||||
// Note: Video mode is now synced from server via device_info event
|
// Note: Video mode is now synced from server via device_info event
|
||||||
// The handleDeviceInfo function will automatically switch to the server's mode
|
// The handleDeviceInfo function will automatically switch to the server's mode
|
||||||
// localStorage preference is only used when server mode matches
|
// localStorage preference is only used when server mode matches
|
||||||
@@ -2009,7 +2185,17 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
void activateConsoleView()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
deactivateConsoleView()
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
deactivateConsoleView()
|
||||||
|
|
||||||
// Reset initial device info flag
|
// Reset initial device info flag
|
||||||
initialDeviceInfoReceived = false
|
initialDeviceInfoReceived = false
|
||||||
initialModeRestoreDone = false
|
initialModeRestoreDone = false
|
||||||
@@ -2021,12 +2207,6 @@ onUnmounted(() => {
|
|||||||
mouseFlushTimer = null
|
mouseFlushTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear ttyd poll interval
|
|
||||||
if (ttydPollInterval) {
|
|
||||||
clearInterval(ttydPollInterval)
|
|
||||||
ttydPollInterval = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear all timers
|
// Clear all timers
|
||||||
if (retryTimeoutId !== null) {
|
if (retryTimeoutId !== null) {
|
||||||
clearTimeout(retryTimeoutId)
|
clearTimeout(retryTimeoutId)
|
||||||
@@ -2051,18 +2231,6 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
// Exit pointer lock if active
|
// Exit pointer lock if active
|
||||||
exitPointerLock()
|
exitPointerLock()
|
||||||
|
|
||||||
window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
window.removeEventListener('keyup', handleKeyUp)
|
|
||||||
window.removeEventListener('blur', handleBlur)
|
|
||||||
window.removeEventListener('mouseup', handleWindowMouseUp)
|
|
||||||
window.removeEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
|
|
||||||
window.removeEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
|
|
||||||
window.removeEventListener('storage', handleMouseSendIntervalStorage)
|
|
||||||
|
|
||||||
// Remove pointer lock event listeners
|
|
||||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
document.removeEventListener('pointerlockerror', handlePointerLockError)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -2367,6 +2535,9 @@ onUnmounted(() => {
|
|||||||
v-if="virtualKeyboardVisible"
|
v-if="virtualKeyboardVisible"
|
||||||
v-model:visible="virtualKeyboardVisible"
|
v-model:visible="virtualKeyboardVisible"
|
||||||
v-model:attached="virtualKeyboardAttached"
|
v-model:attached="virtualKeyboardAttached"
|
||||||
|
:caps-lock="keyboardLed.capsLock"
|
||||||
|
:pressed-keys="pressedKeys"
|
||||||
|
:consumer-enabled="virtualKeyboardConsumerEnabled"
|
||||||
@key-down="handleVirtualKeyDown"
|
@key-down="handleVirtualKeyDown"
|
||||||
@key-up="handleVirtualKeyUp"
|
@key-up="handleVirtualKeyUp"
|
||||||
/>
|
/>
|
||||||
@@ -2379,6 +2550,9 @@ onUnmounted(() => {
|
|||||||
<InfoBar
|
<InfoBar
|
||||||
:pressed-keys="pressedKeys"
|
:pressed-keys="pressedKeys"
|
||||||
:caps-lock="keyboardLed.capsLock"
|
:caps-lock="keyboardLed.capsLock"
|
||||||
|
:num-lock="keyboardLed.numLock"
|
||||||
|
:scroll-lock="keyboardLed.scrollLock"
|
||||||
|
:keyboard-led-enabled="keyboardLedEnabled"
|
||||||
:mouse-position="mousePosition"
|
:mouse-position="mousePosition"
|
||||||
:debug-mode="false"
|
:debug-mode="false"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import { ref } from 'vue'
|
|||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import {
|
||||||
|
setLanguage,
|
||||||
|
getCurrentLanguage,
|
||||||
|
type SupportedLocale,
|
||||||
|
} from '@/i18n'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Monitor, Lock, Eye, EyeOff, User } from 'lucide-vue-next'
|
import { Monitor, Lock, Eye, EyeOff, User } from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -10,12 +19,18 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const currentLanguage = ref<SupportedLocale>(getCurrentLanguage())
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const showPassword = ref(false)
|
const showPassword = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
|
function handleLanguageChange(lang: SupportedLocale) {
|
||||||
|
currentLanguage.value = lang
|
||||||
|
setLanguage(lang)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!username.value) {
|
if (!username.value) {
|
||||||
error.value = t('auth.enterUsername')
|
error.value = t('auth.enterUsername')
|
||||||
@@ -40,57 +55,66 @@ async function handleLogin() {
|
|||||||
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleLogin()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen min-h-dvh flex items-center justify-center bg-background p-4">
|
<div class="min-h-screen min-h-dvh flex items-center justify-center bg-background p-4">
|
||||||
<div class="w-full max-w-sm space-y-6">
|
<Card class="relative w-full max-w-sm">
|
||||||
<!-- Logo and Title -->
|
<div class="absolute top-4 right-4 flex gap-2">
|
||||||
<div class="text-center space-y-2">
|
<Button
|
||||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10">
|
:variant="currentLanguage === 'zh-CN' ? 'default' : 'outline'"
|
||||||
<Monitor class="w-8 h-8 text-primary" />
|
size="sm"
|
||||||
</div>
|
@click="handleLanguageChange('zh-CN')"
|
||||||
<h1 class="text-2xl font-bold text-foreground">One-KVM</h1>
|
>
|
||||||
<p class="text-sm text-muted-foreground">{{ t('auth.loginPrompt') }}</p>
|
中文
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:variant="currentLanguage === 'en-US' ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
@click="handleLanguageChange('en-US')"
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<CardHeader class="space-y-2 pt-10 text-center sm:pt-12">
|
||||||
<div class="space-y-4">
|
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
|
||||||
<!-- Username Input -->
|
<Monitor class="w-8 h-8 text-primary" />
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
|
||||||
<User class="w-4 h-4" />
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
|
||||||
|
<CardDescription>{{ t('auth.login') }}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form class="space-y-4" @submit.prevent="handleLogin">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="username">{{ t('auth.username') }}</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<User class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
v-model="username"
|
v-model="username"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="t('auth.username')"
|
:placeholder="t('auth.username')"
|
||||||
class="w-full h-10 pl-10 pr-4 rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
class="pl-10"
|
||||||
@keydown="handleKeydown"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password Input -->
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
|
||||||
<Lock class="w-4 h-4" />
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="password">{{ t('auth.password') }}</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Lock class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
:placeholder="t('auth.password')"
|
:placeholder="t('auth.password')"
|
||||||
class="w-full h-10 pl-10 pr-10 rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
class="pl-10 pr-10"
|
||||||
@keydown="handleKeydown"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
:aria-label="showPassword ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')"
|
:aria-label="showPassword ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')"
|
||||||
@click="showPassword = !showPassword"
|
@click="showPassword = !showPassword"
|
||||||
>
|
>
|
||||||
@@ -98,18 +122,16 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<EyeOff v-else class="w-4 h-4" />
|
<EyeOff v-else class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<p v-if="error" class="text-center text-sm text-destructive">{{ error }}</p>
|
||||||
class="w-full h-10 rounded-md bg-primary text-primary-foreground font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
:disabled="loading"
|
<Button type="submit" class="w-full" :disabled="loading">
|
||||||
@click="handleLogin"
|
|
||||||
>
|
|
||||||
<span v-if="loading">{{ t('common.loading') }}</span>
|
<span v-if="loading">{{ t('common.loading') }}</span>
|
||||||
<span v-else>{{ t('auth.login') }}</span>
|
<span v-else>{{ t('auth.login') }}</span>
|
||||||
</button>
|
</Button>
|
||||||
|
</form>
|
||||||
<p v-if="error" class="text-sm text-destructive text-center">{{ error }}</p>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { useSystemStore } from '@/stores/system'
|
import { useSystemStore } from '@/stores/system'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
type UpdateOverviewResponse,
|
type UpdateOverviewResponse,
|
||||||
type UpdateStatusResponse,
|
type UpdateStatusResponse,
|
||||||
type UpdateChannel,
|
type UpdateChannel,
|
||||||
|
type VideoEncoderSelfCheckResponse,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import type {
|
import type {
|
||||||
ExtensionsStatus,
|
ExtensionsStatus,
|
||||||
@@ -31,11 +33,13 @@ import type {
|
|||||||
AtxDriverType,
|
AtxDriverType,
|
||||||
ActiveLevel,
|
ActiveLevel,
|
||||||
AtxDevices,
|
AtxDevices,
|
||||||
|
OtgEndpointBudget,
|
||||||
OtgHidProfile,
|
OtgHidProfile,
|
||||||
OtgHidFunctions,
|
OtgHidFunctions,
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
import { setLanguage } from '@/i18n'
|
import { setLanguage } from '@/i18n'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
import { getVideoFormatState } from '@/lib/video-format-support'
|
||||||
import AppLayout from '@/components/AppLayout.vue'
|
import AppLayout from '@/components/AppLayout.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -82,6 +86,7 @@ import {
|
|||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t, te, locale } = useI18n()
|
const { t, te, locale } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -91,6 +96,21 @@ const activeSection = ref('appearance')
|
|||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saved = ref(false)
|
const saved = ref(false)
|
||||||
|
const SETTINGS_SECTION_IDS = new Set([
|
||||||
|
'appearance',
|
||||||
|
'account',
|
||||||
|
'access',
|
||||||
|
'video',
|
||||||
|
'hid',
|
||||||
|
'msd',
|
||||||
|
'atx',
|
||||||
|
'environment',
|
||||||
|
'ext-ttyd',
|
||||||
|
'ext-rustdesk',
|
||||||
|
'ext-rtsp',
|
||||||
|
'ext-remote-access',
|
||||||
|
'about',
|
||||||
|
])
|
||||||
|
|
||||||
// Navigation structure
|
// Navigation structure
|
||||||
const navGroups = computed(() => [
|
const navGroups = computed(() => [
|
||||||
@@ -134,6 +154,10 @@ function selectSection(id: string) {
|
|||||||
mobileMenuOpen.value = false
|
mobileMenuOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSettingsSection(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && SETTINGS_SECTION_IDS.has(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
const theme = ref<'light' | 'dark' | 'system'>('system')
|
const theme = ref<'light' | 'dark' | 'system'>('system')
|
||||||
|
|
||||||
@@ -304,13 +328,15 @@ const config = ref({
|
|||||||
hid_serial_device: '',
|
hid_serial_device: '',
|
||||||
hid_serial_baudrate: 9600,
|
hid_serial_baudrate: 9600,
|
||||||
hid_otg_udc: '',
|
hid_otg_udc: '',
|
||||||
hid_otg_profile: 'full' as OtgHidProfile,
|
hid_otg_profile: 'custom' as OtgHidProfile,
|
||||||
|
hid_otg_endpoint_budget: 'six' as OtgEndpointBudget,
|
||||||
hid_otg_functions: {
|
hid_otg_functions: {
|
||||||
keyboard: true,
|
keyboard: true,
|
||||||
mouse_relative: true,
|
mouse_relative: true,
|
||||||
mouse_absolute: true,
|
mouse_absolute: true,
|
||||||
consumer: true,
|
consumer: true,
|
||||||
} as OtgHidFunctions,
|
} as OtgHidFunctions,
|
||||||
|
hid_otg_keyboard_leds: false,
|
||||||
msd_enabled: false,
|
msd_enabled: false,
|
||||||
msd_dir: '',
|
msd_dir: '',
|
||||||
encoder_backend: 'auto',
|
encoder_backend: 'auto',
|
||||||
@@ -323,20 +349,6 @@ const config = ref({
|
|||||||
|
|
||||||
// Tracks whether TURN password is configured on the server
|
// Tracks whether TURN password is configured on the server
|
||||||
const hasTurnPassword = ref(false)
|
const hasTurnPassword = ref(false)
|
||||||
const configLoaded = ref(false)
|
|
||||||
const devicesLoaded = ref(false)
|
|
||||||
const hidProfileAligned = ref(false)
|
|
||||||
|
|
||||||
const isLowEndpointUdc = computed(() => {
|
|
||||||
if (config.value.hid_otg_udc) {
|
|
||||||
return /musb/i.test(config.value.hid_otg_udc)
|
|
||||||
}
|
|
||||||
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
|
|
||||||
})
|
|
||||||
|
|
||||||
const showLowEndpointHint = computed(() =>
|
|
||||||
config.value.hid_backend === 'otg' && isLowEndpointUdc.value
|
|
||||||
)
|
|
||||||
|
|
||||||
type OtgSelfCheckLevel = 'info' | 'warn' | 'error'
|
type OtgSelfCheckLevel = 'info' | 'warn' | 'error'
|
||||||
type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped'
|
type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped'
|
||||||
@@ -539,28 +551,138 @@ async function onRunOtgSelfCheckClick() {
|
|||||||
await runOtgSelfCheck()
|
await runOtgSelfCheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
function alignHidProfileForLowEndpoint() {
|
type VideoEncoderSelfCheckCell = VideoEncoderSelfCheckResponse['rows'][number]['cells'][number]
|
||||||
if (hidProfileAligned.value) return
|
type VideoEncoderSelfCheckRow = VideoEncoderSelfCheckResponse['rows'][number]
|
||||||
if (!configLoaded.value || !devicesLoaded.value) return
|
|
||||||
if (config.value.hid_backend !== 'otg') {
|
const videoEncoderSelfCheckLoading = ref(false)
|
||||||
hidProfileAligned.value = true
|
const videoEncoderSelfCheckResult = ref<VideoEncoderSelfCheckResponse | null>(null)
|
||||||
return
|
const videoEncoderSelfCheckError = ref('')
|
||||||
|
const videoEncoderRunButtonPressed = ref(false)
|
||||||
|
|
||||||
|
function videoEncoderCell(row: VideoEncoderSelfCheckRow, codecId: string): VideoEncoderSelfCheckCell | undefined {
|
||||||
|
return row.cells.find(cell => cell.codec_id === codecId)
|
||||||
}
|
}
|
||||||
if (!isLowEndpointUdc.value) {
|
|
||||||
hidProfileAligned.value = true
|
const currentHardwareEncoderText = computed(() =>
|
||||||
return
|
videoEncoderSelfCheckResult.value?.current_hardware_encoder === 'None'
|
||||||
|
? t('settings.encoderSelfCheck.none')
|
||||||
|
: (videoEncoderSelfCheckResult.value?.current_hardware_encoder || t('settings.encoderSelfCheck.none'))
|
||||||
|
)
|
||||||
|
|
||||||
|
function videoEncoderCodecLabel(codecId: string, codecName: string): string {
|
||||||
|
return codecId === 'h265' ? 'H.265' : codecName
|
||||||
}
|
}
|
||||||
if (config.value.hid_otg_profile === 'full') {
|
|
||||||
config.value.hid_otg_profile = 'full_no_consumer' as OtgHidProfile
|
function videoEncoderCellClass(ok: boolean | undefined): string {
|
||||||
} else if (config.value.hid_otg_profile === 'full_no_msd') {
|
return ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
|
||||||
config.value.hid_otg_profile = 'full_no_consumer_no_msd' as OtgHidProfile
|
}
|
||||||
|
|
||||||
|
function videoEncoderCellSymbol(ok: boolean | undefined): string {
|
||||||
|
return ok ? '✓' : '✗'
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoEncoderCellTime(cell: VideoEncoderSelfCheckCell | undefined): string {
|
||||||
|
if (!cell || typeof cell.elapsed_ms !== 'number') return '-'
|
||||||
|
return `${cell.elapsed_ms}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runVideoEncoderSelfCheck() {
|
||||||
|
videoEncoderSelfCheckLoading.value = true
|
||||||
|
videoEncoderSelfCheckError.value = ''
|
||||||
|
try {
|
||||||
|
videoEncoderSelfCheckResult.value = await streamApi.encoderSelfCheck()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to run encoder self-check:', e)
|
||||||
|
videoEncoderSelfCheckError.value = t('settings.encoderSelfCheck.failed')
|
||||||
|
} finally {
|
||||||
|
videoEncoderSelfCheckLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRunVideoEncoderSelfCheckClick() {
|
||||||
|
if (!videoEncoderSelfCheckLoading.value) {
|
||||||
|
videoEncoderRunButtonPressed.value = true
|
||||||
|
window.setTimeout(() => {
|
||||||
|
videoEncoderRunButtonPressed.value = false
|
||||||
|
}, 160)
|
||||||
|
}
|
||||||
|
await runVideoEncoderSelfCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultOtgEndpointBudgetForUdc(udc?: string): OtgEndpointBudget {
|
||||||
|
return /musb/i.test(udc || '') ? 'five' as OtgEndpointBudget : 'six' as OtgEndpointBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOtgEndpointBudget(budget: OtgEndpointBudget | undefined, udc?: string): OtgEndpointBudget {
|
||||||
|
if (!budget || budget === 'auto') {
|
||||||
|
return defaultOtgEndpointBudgetForUdc(udc)
|
||||||
|
}
|
||||||
|
return budget
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointLimitForBudget(budget: OtgEndpointBudget): number | null {
|
||||||
|
if (budget === 'unlimited') return null
|
||||||
|
return budget === 'five' ? 5 : 6
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveOtgFunctions = computed(() => ({ ...config.value.hid_otg_functions }))
|
||||||
|
|
||||||
|
const otgEndpointLimit = computed(() =>
|
||||||
|
endpointLimitForBudget(config.value.hid_otg_endpoint_budget)
|
||||||
|
)
|
||||||
|
|
||||||
|
const otgRequiredEndpoints = computed(() => {
|
||||||
|
if (config.value.hid_backend !== 'otg') return 0
|
||||||
|
const functions = effectiveOtgFunctions.value
|
||||||
|
let endpoints = 0
|
||||||
|
if (functions.keyboard) {
|
||||||
|
endpoints += 1
|
||||||
|
if (config.value.hid_otg_keyboard_leds) endpoints += 1
|
||||||
|
}
|
||||||
|
if (functions.mouse_relative) endpoints += 1
|
||||||
|
if (functions.mouse_absolute) endpoints += 1
|
||||||
|
if (functions.consumer) endpoints += 1
|
||||||
|
if (config.value.msd_enabled) endpoints += 2
|
||||||
|
return endpoints
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOtgEndpointBudgetValid = computed(() => {
|
||||||
|
if (config.value.hid_backend !== 'otg') return true
|
||||||
|
const limit = otgEndpointLimit.value
|
||||||
|
return limit === null || otgRequiredEndpoints.value <= limit
|
||||||
|
})
|
||||||
|
|
||||||
|
const otgEndpointUsageText = computed(() => {
|
||||||
|
const limit = otgEndpointLimit.value
|
||||||
|
if (limit === null) {
|
||||||
|
return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value })
|
||||||
|
}
|
||||||
|
return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit })
|
||||||
|
})
|
||||||
|
|
||||||
|
const showOtgEndpointBudgetHint = computed(() =>
|
||||||
|
config.value.hid_backend === 'otg'
|
||||||
|
)
|
||||||
|
|
||||||
|
const isKeyboardLedToggleDisabled = computed(() =>
|
||||||
|
config.value.hid_backend !== 'otg' || !effectiveOtgFunctions.value.keyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
function describeEndpointBudget(budget: OtgEndpointBudget): string {
|
||||||
|
switch (budget) {
|
||||||
|
case 'five':
|
||||||
|
return '5'
|
||||||
|
case 'six':
|
||||||
|
return '6'
|
||||||
|
case 'unlimited':
|
||||||
|
return t('settings.otgEndpointBudgetUnlimited')
|
||||||
|
default:
|
||||||
|
return '6'
|
||||||
}
|
}
|
||||||
hidProfileAligned.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHidFunctionSelectionValid = computed(() => {
|
const isHidFunctionSelectionValid = computed(() => {
|
||||||
if (config.value.hid_backend !== 'otg') return true
|
if (config.value.hid_backend !== 'otg') return true
|
||||||
if (config.value.hid_otg_profile !== 'custom') return true
|
|
||||||
const f = config.value.hid_otg_functions
|
const f = config.value.hid_otg_functions
|
||||||
return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer)
|
return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer)
|
||||||
})
|
})
|
||||||
@@ -659,6 +781,21 @@ const availableFormats = computed(() => {
|
|||||||
return selectedDevice.value.formats
|
return selectedDevice.value.formats
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const availableFormatOptions = computed(() => {
|
||||||
|
return availableFormats.value.map(format => {
|
||||||
|
const state = getVideoFormatState(format.format, 'config', config.value.encoder_backend)
|
||||||
|
return {
|
||||||
|
...format,
|
||||||
|
state,
|
||||||
|
disabled: state === 'unsupported',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectableFormats = computed(() => {
|
||||||
|
return availableFormatOptions.value.filter(format => !format.disabled)
|
||||||
|
})
|
||||||
|
|
||||||
const selectedFormat = computed(() => {
|
const selectedFormat = computed(() => {
|
||||||
if (!selectedDevice.value || !config.value.video_format) return null
|
if (!selectedDevice.value || !config.value.video_format) return null
|
||||||
return selectedDevice.value.formats.find(f => f.format === config.value.video_format)
|
return selectedDevice.value.formats.find(f => f.format === config.value.video_format)
|
||||||
@@ -689,17 +826,22 @@ const availableFps = computed(() => {
|
|||||||
return currentRes ? currentRes.fps : []
|
return currentRes ? currentRes.fps : []
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for device change to set default format
|
// Keep the selected format aligned with currently selectable formats.
|
||||||
watch(() => config.value.video_device, () => {
|
watch(
|
||||||
if (availableFormats.value.length > 0) {
|
selectableFormats,
|
||||||
const isValid = availableFormats.value.some(f => f.format === config.value.video_format)
|
() => {
|
||||||
if (!isValid) {
|
if (selectableFormats.value.length === 0) {
|
||||||
config.value.video_format = availableFormats.value[0]?.format || ''
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.value.video_format = ''
|
config.value.video_format = ''
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
const isValid = selectableFormats.value.some(f => f.format === config.value.video_format)
|
||||||
|
if (!isValid) {
|
||||||
|
config.value.video_format = selectableFormats.value[0]?.format || ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
// Watch for format change to set default resolution
|
// Watch for format change to set default resolution
|
||||||
watch(() => config.value.video_format, () => {
|
watch(() => config.value.video_format, () => {
|
||||||
@@ -866,26 +1008,9 @@ async function saveConfig() {
|
|||||||
|
|
||||||
// HID config
|
// HID config
|
||||||
if (activeSection.value === 'hid') {
|
if (activeSection.value === 'hid') {
|
||||||
if (!isHidFunctionSelectionValid.value) {
|
if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let desiredMsdEnabled = config.value.msd_enabled
|
|
||||||
if (config.value.hid_backend === 'otg') {
|
|
||||||
if (config.value.hid_otg_profile === 'full') {
|
|
||||||
desiredMsdEnabled = true
|
|
||||||
} else if (config.value.hid_otg_profile === 'full_no_msd') {
|
|
||||||
desiredMsdEnabled = false
|
|
||||||
} else if (config.value.hid_otg_profile === 'full_no_consumer') {
|
|
||||||
desiredMsdEnabled = true
|
|
||||||
} else if (config.value.hid_otg_profile === 'full_no_consumer_no_msd') {
|
|
||||||
desiredMsdEnabled = false
|
|
||||||
} else if (
|
|
||||||
config.value.hid_otg_profile === 'legacy_keyboard'
|
|
||||||
|| config.value.hid_otg_profile === 'legacy_mouse_relative'
|
|
||||||
) {
|
|
||||||
desiredMsdEnabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const hidUpdate: any = {
|
const hidUpdate: any = {
|
||||||
backend: config.value.hid_backend as any,
|
backend: config.value.hid_backend as any,
|
||||||
ch9329_port: config.value.hid_serial_device || undefined,
|
ch9329_port: config.value.hid_serial_device || undefined,
|
||||||
@@ -900,16 +1025,15 @@ async function saveConfig() {
|
|||||||
product: otgProduct.value || 'One-KVM USB Device',
|
product: otgProduct.value || 'One-KVM USB Device',
|
||||||
serial_number: otgSerialNumber.value || undefined,
|
serial_number: otgSerialNumber.value || undefined,
|
||||||
}
|
}
|
||||||
hidUpdate.otg_profile = config.value.hid_otg_profile
|
hidUpdate.otg_profile = 'custom'
|
||||||
|
hidUpdate.otg_endpoint_budget = config.value.hid_otg_endpoint_budget
|
||||||
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
|
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
|
||||||
|
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
|
||||||
}
|
}
|
||||||
savePromises.push(configStore.updateHid(hidUpdate))
|
savePromises.push(configStore.updateHid(hidUpdate))
|
||||||
if (config.value.msd_enabled !== desiredMsdEnabled) {
|
|
||||||
config.value.msd_enabled = desiredMsdEnabled
|
|
||||||
}
|
|
||||||
savePromises.push(
|
savePromises.push(
|
||||||
configStore.updateMsd({
|
configStore.updateMsd({
|
||||||
enabled: desiredMsdEnabled,
|
enabled: config.value.msd_enabled,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -954,13 +1078,15 @@ async function loadConfig() {
|
|||||||
hid_serial_device: hid.ch9329_port || '',
|
hid_serial_device: hid.ch9329_port || '',
|
||||||
hid_serial_baudrate: hid.ch9329_baudrate || 9600,
|
hid_serial_baudrate: hid.ch9329_baudrate || 9600,
|
||||||
hid_otg_udc: hid.otg_udc || '',
|
hid_otg_udc: hid.otg_udc || '',
|
||||||
hid_otg_profile: (hid.otg_profile || 'full') as OtgHidProfile,
|
hid_otg_profile: 'custom' as OtgHidProfile,
|
||||||
|
hid_otg_endpoint_budget: normalizeOtgEndpointBudget(hid.otg_endpoint_budget, hid.otg_udc || ''),
|
||||||
hid_otg_functions: {
|
hid_otg_functions: {
|
||||||
keyboard: hid.otg_functions?.keyboard ?? true,
|
keyboard: hid.otg_functions?.keyboard ?? true,
|
||||||
mouse_relative: hid.otg_functions?.mouse_relative ?? true,
|
mouse_relative: hid.otg_functions?.mouse_relative ?? true,
|
||||||
mouse_absolute: hid.otg_functions?.mouse_absolute ?? true,
|
mouse_absolute: hid.otg_functions?.mouse_absolute ?? true,
|
||||||
consumer: hid.otg_functions?.consumer ?? true,
|
consumer: hid.otg_functions?.consumer ?? true,
|
||||||
} as OtgHidFunctions,
|
} as OtgHidFunctions,
|
||||||
|
hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false,
|
||||||
msd_enabled: msd.enabled || false,
|
msd_enabled: msd.enabled || false,
|
||||||
msd_dir: msd.msd_dir || '',
|
msd_dir: msd.msd_dir || '',
|
||||||
encoder_backend: stream.encoder || 'auto',
|
encoder_backend: stream.encoder || 'auto',
|
||||||
@@ -985,9 +1111,6 @@ async function loadConfig() {
|
|||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load config:', e)
|
console.error('Failed to load config:', e)
|
||||||
} finally {
|
|
||||||
configLoaded.value = true
|
|
||||||
alignHidProfileForLowEndpoint()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,9 +1119,6 @@ async function loadDevices() {
|
|||||||
devices.value = await configApi.listDevices()
|
devices.value = await configApi.listDevices()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load devices:', e)
|
console.error('Failed to load devices:', e)
|
||||||
} finally {
|
|
||||||
devicesLoaded.value = true
|
|
||||||
alignHidProfileForLowEndpoint()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1781,17 +1901,23 @@ onMounted(async () => {
|
|||||||
if (updateRunning.value) {
|
if (updateRunning.value) {
|
||||||
startUpdatePolling()
|
startUpdatePolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
await runOtgSelfCheck()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(updateChannel, async () => {
|
watch(updateChannel, async () => {
|
||||||
await loadUpdateOverview()
|
await loadUpdateOverview()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => config.value.hid_backend, async () => {
|
watch(() => config.value.hid_backend, () => {
|
||||||
await runOtgSelfCheck()
|
otgSelfCheckResult.value = null
|
||||||
|
otgSelfCheckError.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => route.query.tab, (tab) => {
|
||||||
|
const section = normalizeSettingsSection(tab)
|
||||||
|
if (section && activeSection.value !== section) {
|
||||||
|
selectSection(section)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -1986,7 +2112,14 @@ watch(() => config.value.hid_backend, async () => {
|
|||||||
<Label for="video-format">{{ t('settings.videoFormat') }}</Label>
|
<Label for="video-format">{{ t('settings.videoFormat') }}</Label>
|
||||||
<select id="video-format" v-model="config.video_format" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_device">
|
<select id="video-format" v-model="config.video_format" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_device">
|
||||||
<option value="">{{ t('settings.selectFormat') }}</option>
|
<option value="">{{ t('settings.selectFormat') }}</option>
|
||||||
<option v-for="fmt in availableFormats" :key="fmt.format" :value="fmt.format">{{ fmt.format }} - {{ fmt.description }}</option>
|
<option
|
||||||
|
v-for="fmt in availableFormatOptions"
|
||||||
|
:key="fmt.format"
|
||||||
|
:value="fmt.format"
|
||||||
|
:disabled="fmt.disabled"
|
||||||
|
>
|
||||||
|
{{ fmt.format }} - {{ fmt.description }}{{ fmt.disabled ? t('common.notSupportedYet') : '' }}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
@@ -2144,26 +2277,16 @@ watch(() => config.value.hid_backend, async () => {
|
|||||||
<p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p>
|
<p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="otg-profile">{{ t('settings.profile') }}</Label>
|
<Label for="otg-endpoint-budget">{{ t('settings.otgEndpointBudget') }}</Label>
|
||||||
<select id="otg-profile" v-model="config.hid_otg_profile" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
<select id="otg-endpoint-budget" v-model="config.hid_otg_endpoint_budget" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||||
<option value="full">{{ t('settings.otgProfileFull') }}</option>
|
<option value="five">5</option>
|
||||||
<option value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</option>
|
<option value="six">6</option>
|
||||||
<option value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</option>
|
<option value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</option>
|
||||||
<option value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</option>
|
|
||||||
<option value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</option>
|
|
||||||
<option value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</option>
|
|
||||||
<option value="custom">{{ t('settings.otgProfileCustom') }}</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ otgEndpointUsageText }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="config.hid_otg_profile === 'custom'" class="space-y-3 rounded-md border border-border/60 p-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
||||||
<div>
|
|
||||||
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="config.hid_otg_functions.keyboard" />
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
|
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
|
||||||
@@ -2179,6 +2302,15 @@ watch(() => config.value.hid_backend, async () => {
|
|||||||
</div>
|
</div>
|
||||||
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
|
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="config.hid_otg_functions.keyboard" />
|
||||||
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -2188,6 +2320,15 @@ watch(() => config.value.hid_backend, async () => {
|
|||||||
<Switch v-model="config.hid_otg_functions.consumer" />
|
<Switch v-model="config.hid_otg_functions.consumer" />
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="config.hid_otg_keyboard_leds" :disabled="isKeyboardLedToggleDisabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 rounded-md border border-border/60 p-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
|
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
|
||||||
@@ -2196,11 +2337,15 @@ watch(() => config.value.hid_backend, async () => {
|
|||||||
<Switch v-model="config.msd_enabled" />
|
<Switch v-model="config.msd_enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p class="text-xs text-amber-600 dark:text-amber-400">
|
<p class="text-xs text-amber-600 dark:text-amber-400">
|
||||||
{{ t('settings.otgProfileWarning') }}
|
{{ t('settings.otgProfileWarning') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="showLowEndpointHint" class="text-xs text-amber-600 dark:text-amber-400">
|
<p v-if="showOtgEndpointBudgetHint" class="text-xs text-muted-foreground">
|
||||||
{{ t('settings.otgLowEndpointHint') }}
|
{{ t('settings.otgEndpointBudgetHint') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!isOtgEndpointBudgetValid" class="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
{{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: describeEndpointBudget(config.hid_otg_endpoint_budget) }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator class="my-4" />
|
<Separator class="my-4" />
|
||||||
@@ -2364,6 +2509,86 @@ watch(() => config.value.hid_backend, async () => {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-row items-start justify-between space-y-0">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<CardTitle>{{ t('settings.encoderSelfCheck.title') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('settings.encoderSelfCheck.desc') }}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="videoEncoderSelfCheckLoading"
|
||||||
|
:class="[
|
||||||
|
'transition-all duration-150 active:scale-95 active:brightness-95',
|
||||||
|
videoEncoderRunButtonPressed ? 'scale-95 brightness-95' : ''
|
||||||
|
]"
|
||||||
|
@click="onRunVideoEncoderSelfCheckClick"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4 mr-2" :class="{ 'animate-spin': videoEncoderSelfCheckLoading }" />
|
||||||
|
{{ t('settings.encoderSelfCheck.run') }}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<p v-if="videoEncoderSelfCheckError" class="text-xs text-red-600 dark:text-red-400">
|
||||||
|
{{ videoEncoderSelfCheckError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="videoEncoderSelfCheckResult">
|
||||||
|
<div class="text-sm">
|
||||||
|
{{ t('settings.encoderSelfCheck.currentHardwareEncoder') }}:{{ currentHardwareEncoderText }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border bg-card">
|
||||||
|
<table class="w-full table-fixed text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-3 text-left font-medium w-[18%]">{{ t('settings.encoderSelfCheck.resolution') }}</th>
|
||||||
|
<th
|
||||||
|
v-for="codec in videoEncoderSelfCheckResult.codecs"
|
||||||
|
:key="codec.id"
|
||||||
|
class="px-2 py-3 text-center font-medium w-[20.5%]"
|
||||||
|
>
|
||||||
|
{{ videoEncoderCodecLabel(codec.id, codec.name) }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in videoEncoderSelfCheckResult.rows"
|
||||||
|
:key="row.resolution_id"
|
||||||
|
>
|
||||||
|
<td class="px-2 py-3 align-middle">
|
||||||
|
<div class="font-medium">{{ row.resolution_label }}</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-for="codec in videoEncoderSelfCheckResult.codecs"
|
||||||
|
:key="`${row.resolution_id}-${codec.id}`"
|
||||||
|
class="px-2 py-3 align-middle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-1"
|
||||||
|
:class="videoEncoderCellClass(videoEncoderCell(row, codec.id)?.ok)"
|
||||||
|
>
|
||||||
|
<div class="text-lg leading-none font-semibold">
|
||||||
|
{{ videoEncoderCellSymbol(videoEncoderCell(row, codec.id)?.ok) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] leading-4 text-foreground/70">
|
||||||
|
{{ videoEncoderCellTime(videoEncoderCell(row, codec.id)) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else-if="videoEncoderSelfCheckLoading" class="text-xs text-muted-foreground">
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Access Section -->
|
<!-- Access Section -->
|
||||||
|
|||||||
@@ -97,7 +97,12 @@ const ch9329Port = ref('')
|
|||||||
const ch9329Baudrate = ref(9600)
|
const ch9329Baudrate = ref(9600)
|
||||||
const otgUdc = ref('')
|
const otgUdc = ref('')
|
||||||
const hidOtgProfile = ref('full')
|
const hidOtgProfile = ref('full')
|
||||||
|
const otgMsdEnabled = ref(true)
|
||||||
|
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
|
||||||
|
const otgKeyboardLeds = ref(true)
|
||||||
const otgProfileTouched = ref(false)
|
const otgProfileTouched = ref(false)
|
||||||
|
const otgEndpointBudgetTouched = ref(false)
|
||||||
|
const otgKeyboardLedsTouched = ref(false)
|
||||||
const showAdvancedOtg = ref(false)
|
const showAdvancedOtg = ref(false)
|
||||||
|
|
||||||
// Extension settings
|
// Extension settings
|
||||||
@@ -203,19 +208,67 @@ const availableFps = computed(() => {
|
|||||||
return resolution?.fps || []
|
return resolution?.fps || []
|
||||||
})
|
})
|
||||||
|
|
||||||
const isLowEndpointUdc = computed(() => {
|
function defaultOtgEndpointBudgetForUdc(udc?: string): 'five' | 'six' {
|
||||||
if (otgUdc.value) {
|
return /musb/i.test(udc || '') ? 'five' : 'six'
|
||||||
return /musb/i.test(otgUdc.value)
|
|
||||||
}
|
}
|
||||||
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
|
|
||||||
|
function endpointLimitForBudget(budget: 'five' | 'six' | 'unlimited'): number | null {
|
||||||
|
if (budget === 'unlimited') return null
|
||||||
|
return budget === 'five' ? 5 : 6
|
||||||
|
}
|
||||||
|
|
||||||
|
const otgRequiredEndpoints = computed(() => {
|
||||||
|
if (hidBackend.value !== 'otg') return 0
|
||||||
|
const functions = {
|
||||||
|
keyboard: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_keyboard',
|
||||||
|
mouseRelative: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_mouse_relative',
|
||||||
|
mouseAbsolute: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer',
|
||||||
|
consumer: hidOtgProfile.value === 'full',
|
||||||
|
}
|
||||||
|
let endpoints = 0
|
||||||
|
if (functions.keyboard) {
|
||||||
|
endpoints += 1
|
||||||
|
if (otgKeyboardLeds.value) endpoints += 1
|
||||||
|
}
|
||||||
|
if (functions.mouseRelative) endpoints += 1
|
||||||
|
if (functions.mouseAbsolute) endpoints += 1
|
||||||
|
if (functions.consumer) endpoints += 1
|
||||||
|
if (otgMsdEnabled.value) endpoints += 2
|
||||||
|
return endpoints
|
||||||
})
|
})
|
||||||
|
|
||||||
function applyOtgProfileDefault() {
|
const otgProfileHasKeyboard = computed(() =>
|
||||||
if (otgProfileTouched.value) return
|
hidOtgProfile.value === 'full'
|
||||||
|
|| hidOtgProfile.value === 'full_no_consumer'
|
||||||
|
|| hidOtgProfile.value === 'legacy_keyboard'
|
||||||
|
)
|
||||||
|
|
||||||
|
const isOtgEndpointBudgetValid = computed(() => {
|
||||||
|
const limit = endpointLimitForBudget(otgEndpointBudget.value)
|
||||||
|
return limit === null || otgRequiredEndpoints.value <= limit
|
||||||
|
})
|
||||||
|
|
||||||
|
const otgEndpointUsageText = computed(() => {
|
||||||
|
const limit = endpointLimitForBudget(otgEndpointBudget.value)
|
||||||
|
if (limit === null) {
|
||||||
|
return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value })
|
||||||
|
}
|
||||||
|
return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit })
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyOtgDefaults() {
|
||||||
if (hidBackend.value !== 'otg') return
|
if (hidBackend.value !== 'otg') return
|
||||||
const preferred = isLowEndpointUdc.value ? 'full_no_consumer' : 'full'
|
|
||||||
if (hidOtgProfile.value === preferred) return
|
const recommendedBudget = defaultOtgEndpointBudgetForUdc(otgUdc.value)
|
||||||
hidOtgProfile.value = preferred
|
if (!otgEndpointBudgetTouched.value) {
|
||||||
|
otgEndpointBudget.value = recommendedBudget
|
||||||
|
}
|
||||||
|
if (!otgProfileTouched.value) {
|
||||||
|
hidOtgProfile.value = 'full_no_consumer'
|
||||||
|
}
|
||||||
|
if (!otgKeyboardLedsTouched.value) {
|
||||||
|
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOtgProfileChange(value: unknown) {
|
function onOtgProfileChange(value: unknown) {
|
||||||
@@ -223,6 +276,20 @@ function onOtgProfileChange(value: unknown) {
|
|||||||
otgProfileTouched.value = true
|
otgProfileTouched.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onOtgEndpointBudgetChange(value: unknown) {
|
||||||
|
otgEndpointBudget.value =
|
||||||
|
value === 'five' || value === 'six' || value === 'unlimited' ? value : 'six'
|
||||||
|
otgEndpointBudgetTouched.value = true
|
||||||
|
if (!otgKeyboardLedsTouched.value) {
|
||||||
|
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOtgKeyboardLedsChange(value: boolean) {
|
||||||
|
otgKeyboardLeds.value = value
|
||||||
|
otgKeyboardLedsTouched.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// Common baud rates for CH9329
|
// Common baud rates for CH9329
|
||||||
const baudRates = [9600, 19200, 38400, 57600, 115200]
|
const baudRates = [9600, 19200, 38400, 57600, 115200]
|
||||||
|
|
||||||
@@ -338,16 +405,16 @@ watch(hidBackend, (newBackend) => {
|
|||||||
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
|
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
|
||||||
otgUdc.value = devices.value.udc[0]?.name || ''
|
otgUdc.value = devices.value.udc[0]?.name || ''
|
||||||
}
|
}
|
||||||
applyOtgProfileDefault()
|
applyOtgDefaults()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(otgUdc, () => {
|
watch(otgUdc, () => {
|
||||||
applyOtgProfileDefault()
|
applyOtgDefaults()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(showAdvancedOtg, (open) => {
|
watch(showAdvancedOtg, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
applyOtgProfileDefault()
|
applyOtgDefaults()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -370,7 +437,7 @@ onMounted(async () => {
|
|||||||
if (result.udc.length > 0 && result.udc[0]) {
|
if (result.udc.length > 0 && result.udc[0]) {
|
||||||
otgUdc.value = result.udc[0].name
|
otgUdc.value = result.udc[0].name
|
||||||
}
|
}
|
||||||
applyOtgProfileDefault()
|
applyOtgDefaults()
|
||||||
|
|
||||||
// Auto-select audio device if available (and no video device to trigger watch)
|
// Auto-select audio device if available (and no video device to trigger watch)
|
||||||
if (result.audio.length > 0 && !audioDevice.value) {
|
if (result.audio.length > 0 && !audioDevice.value) {
|
||||||
@@ -461,6 +528,13 @@ function validateStep3(): boolean {
|
|||||||
error.value = t('setup.selectUdc')
|
error.value = t('setup.selectUdc')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (hidBackend.value === 'otg' && !isOtgEndpointBudgetValid.value) {
|
||||||
|
error.value = t('settings.otgEndpointExceeded', {
|
||||||
|
used: otgRequiredEndpoints.value,
|
||||||
|
limit: otgEndpointBudget.value === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget.value === 'five' ? '5' : '6',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +597,9 @@ async function handleSetup() {
|
|||||||
if (hidBackend.value === 'otg' && otgUdc.value) {
|
if (hidBackend.value === 'otg' && otgUdc.value) {
|
||||||
setupData.hid_otg_udc = otgUdc.value
|
setupData.hid_otg_udc = otgUdc.value
|
||||||
setupData.hid_otg_profile = hidOtgProfile.value
|
setupData.hid_otg_profile = hidOtgProfile.value
|
||||||
|
setupData.hid_otg_endpoint_budget = otgEndpointBudget.value
|
||||||
|
setupData.hid_otg_keyboard_leds = otgKeyboardLeds.value
|
||||||
|
setupData.msd_enabled = otgMsdEnabled.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encoder backend setting
|
// Encoder backend setting
|
||||||
@@ -990,16 +1067,47 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem>
|
<SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem>
|
||||||
<SelectItem value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</SelectItem>
|
|
||||||
<SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem>
|
<SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem>
|
||||||
<SelectItem value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</SelectItem>
|
|
||||||
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
|
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
|
||||||
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
|
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="isLowEndpointUdc" class="text-xs text-amber-600 dark:text-amber-400">
|
<div class="space-y-2">
|
||||||
{{ t('setup.otgLowEndpointHint') }}
|
<Label for="otgEndpointBudget">{{ t('settings.otgEndpointBudget') }}</Label>
|
||||||
|
<Select :model-value="otgEndpointBudget" @update:modelValue="onOtgEndpointBudgetChange">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="five">5</SelectItem>
|
||||||
|
<SelectItem value="six">6</SelectItem>
|
||||||
|
<SelectItem value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ otgEndpointUsageText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between rounded-md border border-border/60 p-3">
|
||||||
|
<div>
|
||||||
|
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch :model-value="otgKeyboardLeds" :disabled="!otgProfileHasKeyboard" @update:model-value="onOtgKeyboardLedsChange" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between rounded-md border border-border/60 p-3">
|
||||||
|
<div>
|
||||||
|
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="otgMsdEnabled" />
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ t('settings.otgEndpointBudgetHint') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!isOtgEndpointBudgetValid" class="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
{{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: otgEndpointBudget === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget === 'five' ? '5' : '6' }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user