diff --git a/Cargo.toml b/Cargo.toml index c4f7dc1a..c246fdd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,8 @@ serde_json = "1" # Logging tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"] } +tracing-log = "0.2" # Error handling thiserror = "2" @@ -46,7 +47,7 @@ nix = { version = "0.30", features = ["fs", "net", "hostname", "poll"] } # HTTP client (for URL downloads) # Use rustls by default, but allow native-tls for systems with older GLIBC -reqwest = { version = "0.13", features = ["stream", "rustls"], default-features = false } +reqwest = { version = "0.13", features = ["stream", "rustls", "json"], default-features = false } urlencoding = "2" # Static file embedding @@ -65,7 +66,7 @@ clap = { version = "4", features = ["derive"] } time = "0.3" # Video capture (V4L2) -v4l = "0.14" +v4l2r = "0.0.7" # JPEG encoding (libjpeg-turbo, SIMD accelerated) turbojpeg = "1.3" @@ -91,6 +92,8 @@ arc-swap = "1.8" # WebRTC webrtc = "0.14" rtp = "0.14" +rtsp-types = "0.1" +sdp-types = "0.1" # Audio (ALSA capture + Opus encoding) # Note: audiopus links to libopus.so (unavoidable for audio support) @@ -115,7 +118,6 @@ hwcodec = { path = "libs/hwcodec" } protobuf = { version = "3.7", features = ["with-bytes"] } sodiumoxide = "0.2" sha2 = "0.10" - # High-performance pixel format conversion (libyuv) libyuv = { path = "res/vcpkg/libyuv" } diff --git a/build/cross/Dockerfile.arm64 b/build/cross/Dockerfile.arm64 index d0542c1b..e67d71dc 100644 --- a/build/cross/Dockerfile.arm64 +++ b/build/cross/Dockerfile.arm64 @@ -3,9 +3,13 @@ FROM debian:11 +# Linux headers used by v4l2r bindgen +ARG LINUX_HEADERS_VERSION=6.6 +ARG LINUX_HEADERS_SHA256= + # Set Rustup mirrors (Aliyun) -ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \ - RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup +#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \ +# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup # Install Rust toolchain RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -31,7 +35,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ meson \ ninja-build \ wget \ + xz-utils \ file \ + rsync \ gcc-aarch64-linux-gnu \ g++-aarch64-linux-gnu \ libc6-dev-arm64-cross \ @@ -47,10 +53,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libv4l-dev:arm64 \ libudev-dev:arm64 \ zlib1g-dev:arm64 \ + linux-libc-dev:arm64 \ # Note: libjpeg-turbo, libyuv, libvpx, libx264, libx265, libopus are built from source below for static linking libdrm-dev:arm64 \ && rm -rf /var/lib/apt/lists/* +# Install newer V4L2 headers for v4l2r bindgen +RUN mkdir -p /opt/v4l2-headers \ + && wget -q https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${LINUX_HEADERS_VERSION}.tar.xz -O /tmp/linux-headers.tar.xz \ + && if [ -n "$LINUX_HEADERS_SHA256" ]; then echo "$LINUX_HEADERS_SHA256 /tmp/linux-headers.tar.xz" | sha256sum -c -; fi \ + && tar -xf /tmp/linux-headers.tar.xz -C /tmp \ + && cd /tmp/linux-${LINUX_HEADERS_VERSION} \ + && make ARCH=arm64 headers_install INSTALL_HDR_PATH=/opt/v4l2-headers \ + && rm -rf /tmp/linux-${LINUX_HEADERS_VERSION} /tmp/linux-headers.tar.xz + +ENV V4L2R_VIDEODEV2_H_PATH=/opt/v4l2-headers/include + # Build static libjpeg-turbo from source (cross-compile for ARM64) RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libjpeg-turbo \ && cd /tmp/libjpeg-turbo \ diff --git a/build/cross/Dockerfile.armv7 b/build/cross/Dockerfile.armv7 index 3bebfc6f..4e8f4617 100644 --- a/build/cross/Dockerfile.armv7 +++ b/build/cross/Dockerfile.armv7 @@ -3,9 +3,13 @@ FROM debian:11 +# Linux headers used by v4l2r bindgen +ARG LINUX_HEADERS_VERSION=6.6 +ARG LINUX_HEADERS_SHA256= + # Set Rustup mirrors (Aliyun) -ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \ - RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup +#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \ +# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup # Install Rust toolchain RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -31,7 +35,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ meson \ ninja-build \ wget \ + xz-utils \ file \ + rsync \ gcc-arm-linux-gnueabihf \ g++-arm-linux-gnueabihf \ libc6-dev-armhf-cross \ @@ -46,10 +52,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libasound2-dev:armhf \ libv4l-dev:armhf \ libudev-dev:armhf \ + linux-libc-dev:armhf \ zlib1g-dev:armhf \ libdrm-dev:armhf \ && rm -rf /var/lib/apt/lists/* +# Install newer V4L2 headers for v4l2r bindgen +RUN mkdir -p /opt/v4l2-headers \ + && wget -q https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${LINUX_HEADERS_VERSION}.tar.xz -O /tmp/linux-headers.tar.xz \ + && if [ -n "$LINUX_HEADERS_SHA256" ]; then echo "$LINUX_HEADERS_SHA256 /tmp/linux-headers.tar.xz" | sha256sum -c -; fi \ + && tar -xf /tmp/linux-headers.tar.xz -C /tmp \ + && cd /tmp/linux-${LINUX_HEADERS_VERSION} \ + && make ARCH=arm headers_install INSTALL_HDR_PATH=/opt/v4l2-headers \ + && rm -rf /tmp/linux-${LINUX_HEADERS_VERSION} /tmp/linux-headers.tar.xz + +ENV V4L2R_VIDEODEV2_H_PATH=/opt/v4l2-headers/include + # Build static libjpeg-turbo from source (cross-compile for ARMv7) RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libjpeg-turbo \ && cd /tmp/libjpeg-turbo \ diff --git a/build/cross/Dockerfile.x86_64 b/build/cross/Dockerfile.x86_64 index 779b1b02..46b15ac9 100644 --- a/build/cross/Dockerfile.x86_64 +++ b/build/cross/Dockerfile.x86_64 @@ -3,9 +3,13 @@ FROM debian:11 +# Linux headers used by v4l2r bindgen +ARG LINUX_HEADERS_VERSION=6.6 +ARG LINUX_HEADERS_SHA256= + # Set Rustup mirrors (Aliyun) -ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \ - RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup +#ENV RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup \ +# RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup # Install Rust toolchain RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -29,6 +33,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libclang-dev \ llvm \ wget \ + xz-utils \ + rsync \ # Autotools for libopus (requires autoreconf) autoconf \ automake \ @@ -37,6 +43,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libasound2-dev \ libv4l-dev \ libudev-dev \ + linux-libc-dev \ zlib1g-dev \ # Note: libjpeg-turbo, libx264, libx265, libopus are built from source below for static linking libva-dev \ @@ -49,6 +56,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libxdmcp-dev \ && rm -rf /var/lib/apt/lists/* +# Install newer V4L2 headers for v4l2r bindgen +RUN mkdir -p /opt/v4l2-headers \ + && wget -q https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${LINUX_HEADERS_VERSION}.tar.xz -O /tmp/linux-headers.tar.xz \ + && if [ -n "$LINUX_HEADERS_SHA256" ]; then echo "$LINUX_HEADERS_SHA256 /tmp/linux-headers.tar.xz" | sha256sum -c -; fi \ + && tar -xf /tmp/linux-headers.tar.xz -C /tmp \ + && cd /tmp/linux-${LINUX_HEADERS_VERSION} \ + && make ARCH=x86 headers_install INSTALL_HDR_PATH=/opt/v4l2-headers \ + && rm -rf /tmp/linux-${LINUX_HEADERS_VERSION} /tmp/linux-headers.tar.xz + +ENV V4L2R_VIDEODEV2_H_PATH=/opt/v4l2-headers/include + # Build static libjpeg-turbo from source (needed by libyuv) RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libjpeg-turbo \ && cd /tmp/libjpeg-turbo \ @@ -208,4 +226,4 @@ RUN rustup target add x86_64-unknown-linux-gnu # Configure environment for static linking ENV PKG_CONFIG_ALLOW_CROSS=1\ FFMPEG_STATIC=1 \ - LIBYUV_STATIC=1 \ No newline at end of file + LIBYUV_STATIC=1 diff --git a/libs/hwcodec/cpp/common/util.cpp b/libs/hwcodec/cpp/common/util.cpp index 0418e337..f6df6a2d 100644 --- a/libs/hwcodec/cpp/common/util.cpp +++ b/libs/hwcodec/cpp/common/util.cpp @@ -508,4 +508,4 @@ bool has_flag_could_not_find_ref_with_poc() { extern "C" void hwcodec_set_flag_could_not_find_ref_with_poc() { util_decode::g_flag_could_not_find_ref_with_poc = true; -} \ No newline at end of file +} diff --git a/libs/hwcodec/src/ffmpeg.rs b/libs/hwcodec/src/ffmpeg.rs index bb57dd7f..0ae134d1 100644 --- a/libs/hwcodec/src/ffmpeg.rs +++ b/libs/hwcodec/src/ffmpeg.rs @@ -6,6 +6,7 @@ include!(concat!(env!("OUT_DIR"), "/ffmpeg_ffi.rs")); use serde_derive::{Deserialize, Serialize}; +use std::env; #[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)] pub enum AVHWDeviceType { @@ -53,7 +54,36 @@ pub extern "C" fn hwcodec_av_log_callback(level: i32, message: *const std::os::r pub(crate) fn init_av_log() { static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| unsafe { - av_log_set_level(AV_LOG_ERROR as i32); + av_log_set_level(parse_ffmpeg_log_level()); hwcodec_set_av_log_callback(); }); } + +fn parse_ffmpeg_log_level() -> i32 { + let raw = match env::var("ONE_KVM_FFMPEG_LOG") { + Ok(value) => value, + Err(_) => return AV_LOG_ERROR as i32, + }; + + let value = raw.trim().to_ascii_lowercase(); + if value.is_empty() { + return AV_LOG_ERROR as i32; + } + + if let Ok(level) = value.parse::() { + return level; + } + + match value.as_str() { + "quiet" => AV_LOG_QUIET as i32, + "panic" => AV_LOG_PANIC as i32, + "fatal" => AV_LOG_FATAL as i32, + "error" => AV_LOG_ERROR as i32, + "warn" | "warning" => AV_LOG_WARNING as i32, + "info" => AV_LOG_INFO as i32, + "verbose" => AV_LOG_VERBOSE as i32, + "debug" => AV_LOG_DEBUG as i32, + "trace" => AV_LOG_TRACE as i32, + _ => AV_LOG_ERROR as i32, + } +} diff --git a/libs/hwcodec/src/ffmpeg_hw/mod.rs b/libs/hwcodec/src/ffmpeg_hw/mod.rs index 222c9d14..a80e4ba4 100644 --- a/libs/hwcodec/src/ffmpeg_hw/mod.rs +++ b/libs/hwcodec/src/ffmpeg_hw/mod.rs @@ -31,8 +31,10 @@ unsafe impl Send for HwMjpegH26xPipeline {} impl HwMjpegH26xPipeline { pub fn new(config: HwMjpegH26xConfig) -> Result { unsafe { - let dec = CString::new(config.decoder.as_str()).map_err(|_| "decoder name invalid".to_string())?; - let enc = CString::new(config.encoder.as_str()).map_err(|_| "encoder name invalid".to_string())?; + let dec = CString::new(config.decoder.as_str()) + .map_err(|_| "decoder name invalid".to_string())?; + let enc = CString::new(config.encoder.as_str()) + .map_err(|_| "encoder name invalid".to_string())?; let ctx = ffmpeg_hw_mjpeg_h26x_new( dec.as_ptr(), enc.as_ptr(), diff --git a/libs/hwcodec/src/ffmpeg_ram/decode.rs b/libs/hwcodec/src/ffmpeg_ram/decode.rs index df0512a3..0cd9caf4 100644 --- a/libs/hwcodec/src/ffmpeg_ram/decode.rs +++ b/libs/hwcodec/src/ffmpeg_ram/decode.rs @@ -1,8 +1,7 @@ use crate::{ ffmpeg::{init_av_log, AVPixelFormat}, ffmpeg_ram::{ - ffmpeg_ram_decode, ffmpeg_ram_free_decoder, ffmpeg_ram_last_error, - ffmpeg_ram_new_decoder, + ffmpeg_ram_decode, ffmpeg_ram_free_decoder, ffmpeg_ram_last_error, ffmpeg_ram_new_decoder, }, }; use std::{ diff --git a/libs/hwcodec/src/ffmpeg_ram/encode.rs b/libs/hwcodec/src/ffmpeg_ram/encode.rs index 17c34442..e5e93ea2 100644 --- a/libs/hwcodec/src/ffmpeg_ram/encode.rs +++ b/libs/hwcodec/src/ffmpeg_ram/encode.rs @@ -352,6 +352,7 @@ impl Encoder { debug!("Encoder {} created successfully", codec.name); let mut passed = false; let mut last_err: Option = None; + let is_v4l2m2m = codec.name.contains("v4l2m2m"); let max_attempts = if codec.name.contains("v4l2m2m") { 5 diff --git a/src/atx/controller.rs b/src/atx/controller.rs index 16e8aa07..7ca143cd 100644 --- a/src/atx/controller.rs +++ b/src/atx/controller.rs @@ -8,11 +8,11 @@ use tracing::{debug, info, warn}; use super::executor::{timing, AtxKeyExecutor}; use super::led::LedSensor; -use super::types::{AtxKeyConfig, AtxLedConfig, AtxState, PowerStatus}; +use super::types::{AtxAction, AtxKeyConfig, AtxLedConfig, AtxState, PowerStatus}; use crate::error::{AppError, Result}; /// ATX power control configuration -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct AtxControllerConfig { /// Whether ATX is enabled pub enabled: bool, @@ -24,17 +24,6 @@ pub struct AtxControllerConfig { pub led: AtxLedConfig, } -impl Default for AtxControllerConfig { - fn default() -> Self { - Self { - enabled: false, - power: AtxKeyConfig::default(), - reset: AtxKeyConfig::default(), - led: AtxLedConfig::default(), - } - } -} - /// Internal state holding all ATX components /// Grouped together to reduce lock acquisitions struct AtxInner { @@ -54,34 +43,7 @@ pub struct AtxController { } impl AtxController { - /// Create a new ATX controller with the specified configuration - pub fn new(config: AtxControllerConfig) -> Self { - Self { - inner: RwLock::new(AtxInner { - config, - power_executor: None, - reset_executor: None, - led_sensor: None, - }), - } - } - - /// Create a disabled ATX controller - pub fn disabled() -> Self { - Self::new(AtxControllerConfig::default()) - } - - /// Initialize the ATX controller and its executors - pub async fn init(&self) -> Result<()> { - let mut inner = self.inner.write().await; - - if !inner.config.enabled { - info!("ATX disabled in configuration"); - return Ok(()); - } - - info!("Initializing ATX controller"); - + async fn init_components(inner: &mut AtxInner) { // Initialize power executor if inner.config.power.is_configured() { let mut executor = AtxKeyExecutor::new(inner.config.power.clone()); @@ -123,234 +85,180 @@ impl AtxController { inner.led_sensor = Some(sensor); } } - - info!("ATX controller initialized successfully"); - Ok(()) } - /// Reload the ATX controller with new configuration - /// - /// This is called when configuration changes and supports hot-reload. - pub async fn reload(&self, new_config: AtxControllerConfig) -> Result<()> { - info!("Reloading ATX controller with new configuration"); + async fn shutdown_components(inner: &mut AtxInner) { + if let Some(executor) = inner.power_executor.as_mut() { + if let Err(e) = executor.shutdown().await { + warn!("Failed to shutdown power executor: {}", e); + } + } + inner.power_executor = None; - // Shutdown existing executors - self.shutdown_internal().await?; + if let Some(executor) = inner.reset_executor.as_mut() { + if let Err(e) = executor.shutdown().await { + warn!("Failed to shutdown reset executor: {}", e); + } + } + inner.reset_executor = None; - // Update configuration and re-initialize - { - let mut inner = self.inner.write().await; - inner.config = new_config; + if let Some(sensor) = inner.led_sensor.as_mut() { + if let Err(e) = sensor.shutdown().await { + warn!("Failed to shutdown LED sensor: {}", e); + } + } + inner.led_sensor = None; + } + + /// Create a new ATX controller with the specified configuration + pub fn new(config: AtxControllerConfig) -> Self { + Self { + inner: RwLock::new(AtxInner { + config, + power_executor: None, + reset_executor: None, + led_sensor: None, + }), + } + } + + /// Create a disabled ATX controller + pub fn disabled() -> Self { + Self::new(AtxControllerConfig::default()) + } + + /// Initialize the ATX controller and its executors + pub async fn init(&self) -> Result<()> { + let mut inner = self.inner.write().await; + + if !inner.config.enabled { + info!("ATX disabled in configuration"); + return Ok(()); } - // Re-initialize - self.init().await?; + info!("Initializing ATX controller"); + + Self::init_components(&mut inner).await; - info!("ATX controller reloaded successfully"); Ok(()) } - /// Get current ATX state (single lock acquisition) + /// Reload ATX controller configuration + pub async fn reload(&self, config: AtxControllerConfig) -> Result<()> { + let mut inner = self.inner.write().await; + + info!("Reloading ATX controller configuration"); + + // Shutdown existing components first, then rebuild with new config. + Self::shutdown_components(&mut inner).await; + inner.config = config; + + if !inner.config.enabled { + info!("ATX disabled after reload"); + return Ok(()); + } + + Self::init_components(&mut inner).await; + info!("ATX controller reloaded"); + + Ok(()) + } + + /// Shutdown ATX controller and release all resources + pub async fn shutdown(&self) -> Result<()> { + let mut inner = self.inner.write().await; + Self::shutdown_components(&mut inner).await; + info!("ATX controller shutdown complete"); + Ok(()) + } + + /// Trigger a power action (short/long/reset) + pub async fn trigger_power_action(&self, action: AtxAction) -> Result<()> { + let inner = self.inner.read().await; + + match action { + AtxAction::Short | AtxAction::Long => { + if let Some(executor) = &inner.power_executor { + let duration = match action { + AtxAction::Short => timing::SHORT_PRESS, + AtxAction::Long => timing::LONG_PRESS, + _ => unreachable!(), + }; + executor.pulse(duration).await?; + } else { + return Err(AppError::Config( + "Power button not configured for ATX controller".to_string(), + )); + } + } + AtxAction::Reset => { + if let Some(executor) = &inner.reset_executor { + executor.pulse(timing::RESET_PRESS).await?; + } else { + return Err(AppError::Config( + "Reset button not configured for ATX controller".to_string(), + )); + } + } + } + + Ok(()) + } + + /// Trigger a short power button press + pub async fn power_short(&self) -> Result<()> { + self.trigger_power_action(AtxAction::Short).await + } + + /// Trigger a long power button press + pub async fn power_long(&self) -> Result<()> { + self.trigger_power_action(AtxAction::Long).await + } + + /// Trigger a reset button press + pub async fn reset(&self) -> Result<()> { + self.trigger_power_action(AtxAction::Reset).await + } + + /// Get the current power status using the LED sensor (if configured) + pub async fn power_status(&self) -> PowerStatus { + let inner = self.inner.read().await; + + if let Some(sensor) = &inner.led_sensor { + match sensor.read().await { + Ok(status) => status, + Err(e) => { + debug!("Failed to read ATX LED sensor: {}", e); + PowerStatus::Unknown + } + } + } else { + PowerStatus::Unknown + } + } + + /// Get a snapshot of the ATX state for API responses pub async fn state(&self) -> AtxState { let inner = self.inner.read().await; - let power_status = if let Some(sensor) = inner.led_sensor.as_ref() { - sensor.read().await.unwrap_or(PowerStatus::Unknown) + let power_status = if let Some(sensor) = &inner.led_sensor { + match sensor.read().await { + Ok(status) => status, + Err(e) => { + debug!("Failed to read ATX LED sensor: {}", e); + PowerStatus::Unknown + } + } } else { PowerStatus::Unknown }; AtxState { available: inner.config.enabled, - power_configured: inner - .power_executor - .as_ref() - .map(|e| e.is_initialized()) - .unwrap_or(false), - reset_configured: inner - .reset_executor - .as_ref() - .map(|e| e.is_initialized()) - .unwrap_or(false), + power_configured: inner.power_executor.is_some(), + reset_configured: inner.reset_executor.is_some(), power_status, - led_supported: inner - .led_sensor - .as_ref() - .map(|s| s.is_initialized()) - .unwrap_or(false), + led_supported: inner.led_sensor.is_some(), } } - - /// Get current state as SystemEvent - pub async fn current_state_event(&self) -> crate::events::SystemEvent { - let state = self.state().await; - crate::events::SystemEvent::AtxStateChanged { - power_status: state.power_status, - } - } - - /// Check if ATX is available - pub async fn is_available(&self) -> bool { - let inner = self.inner.read().await; - inner.config.enabled - } - - /// Check if power button is configured and initialized - pub async fn is_power_ready(&self) -> bool { - let inner = self.inner.read().await; - inner - .power_executor - .as_ref() - .map(|e| e.is_initialized()) - .unwrap_or(false) - } - - /// Check if reset button is configured and initialized - pub async fn is_reset_ready(&self) -> bool { - let inner = self.inner.read().await; - inner - .reset_executor - .as_ref() - .map(|e| e.is_initialized()) - .unwrap_or(false) - } - - /// Short press power button (turn on or graceful shutdown) - pub async fn power_short(&self) -> Result<()> { - let inner = self.inner.read().await; - let executor = inner - .power_executor - .as_ref() - .ok_or_else(|| AppError::Internal("Power button not configured".to_string()))?; - - info!( - "ATX: Short press power button ({}ms)", - timing::SHORT_PRESS.as_millis() - ); - executor.pulse(timing::SHORT_PRESS).await - } - - /// Long press power button (force power off) - pub async fn power_long(&self) -> Result<()> { - let inner = self.inner.read().await; - let executor = inner - .power_executor - .as_ref() - .ok_or_else(|| AppError::Internal("Power button not configured".to_string()))?; - - info!( - "ATX: Long press power button ({}ms)", - timing::LONG_PRESS.as_millis() - ); - executor.pulse(timing::LONG_PRESS).await - } - - /// Press reset button - pub async fn reset(&self) -> Result<()> { - let inner = self.inner.read().await; - let executor = inner - .reset_executor - .as_ref() - .ok_or_else(|| AppError::Internal("Reset button not configured".to_string()))?; - - info!( - "ATX: Press reset button ({}ms)", - timing::RESET_PRESS.as_millis() - ); - executor.pulse(timing::RESET_PRESS).await - } - - /// Get current power status from LED sensor - pub async fn power_status(&self) -> Result { - let inner = self.inner.read().await; - match inner.led_sensor.as_ref() { - Some(sensor) => sensor.read().await, - None => Ok(PowerStatus::Unknown), - } - } - - /// Shutdown the ATX controller - pub async fn shutdown(&self) -> Result<()> { - info!("Shutting down ATX controller"); - self.shutdown_internal().await?; - info!("ATX controller shutdown complete"); - Ok(()) - } - - /// Internal shutdown helper - async fn shutdown_internal(&self) -> Result<()> { - let mut inner = self.inner.write().await; - - // Shutdown power executor - if let Some(mut executor) = inner.power_executor.take() { - executor.shutdown().await.ok(); - } - - // Shutdown reset executor - if let Some(mut executor) = inner.reset_executor.take() { - executor.shutdown().await.ok(); - } - - // Shutdown LED sensor - if let Some(mut sensor) = inner.led_sensor.take() { - sensor.shutdown().await.ok(); - } - - Ok(()) - } -} - -impl Drop for AtxController { - fn drop(&mut self) { - debug!("ATX controller dropped"); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_controller_config_default() { - let config = AtxControllerConfig::default(); - assert!(!config.enabled); - assert!(!config.power.is_configured()); - assert!(!config.reset.is_configured()); - assert!(!config.led.is_configured()); - } - - #[test] - fn test_controller_creation() { - let controller = AtxController::disabled(); - assert!(controller.inner.try_read().is_ok()); - } - - #[tokio::test] - async fn test_controller_disabled_state() { - let controller = AtxController::disabled(); - let state = controller.state().await; - assert!(!state.available); - assert!(!state.power_configured); - assert!(!state.reset_configured); - } - - #[tokio::test] - async fn test_controller_init_disabled() { - let controller = AtxController::disabled(); - let result = controller.init().await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_controller_is_available() { - let controller = AtxController::disabled(); - assert!(!controller.is_available().await); - - let config = AtxControllerConfig { - enabled: true, - ..Default::default() - }; - let controller = AtxController::new(config); - assert!(controller.is_available().await); - } } diff --git a/src/atx/mod.rs b/src/atx/mod.rs index f35eae52..a3c3671c 100644 --- a/src/atx/mod.rs +++ b/src/atx/mod.rs @@ -28,12 +28,14 @@ //! device: "/dev/gpiochip0".to_string(), //! pin: 5, //! active_level: ActiveLevel::High, +//! baud_rate: 9600, //! }, //! reset: AtxKeyConfig { //! driver: AtxDriverType::UsbRelay, //! device: "/dev/hidraw0".to_string(), //! pin: 0, //! active_level: ActiveLevel::High, +//! baud_rate: 9600, //! }, //! led: Default::default(), //! }; diff --git a/src/atx/types.rs b/src/atx/types.rs index 8e3060f9..25dade85 100644 --- a/src/atx/types.rs +++ b/src/atx/types.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; /// Power status -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum PowerStatus { /// Power is on @@ -15,18 +15,13 @@ pub enum PowerStatus { /// Power is off Off, /// Power status unknown (no LED connected) + #[default] Unknown, } -impl Default for PowerStatus { - fn default() -> Self { - Self::Unknown - } -} - /// Driver type for ATX key operations #[typeshare] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum AtxDriverType { /// GPIO control via Linux character device @@ -36,32 +31,22 @@ pub enum AtxDriverType { /// Serial/COM port relay (taobao LCUS type) Serial, /// Disabled / Not configured + #[default] None, } -impl Default for AtxDriverType { - fn default() -> Self { - Self::None - } -} - /// Active level for GPIO pins #[typeshare] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ActiveLevel { /// Active high (default for most cases) + #[default] High, /// Active low (inverted) Low, } -impl Default for ActiveLevel { - fn default() -> Self { - Self::High - } -} - /// Configuration for a single ATX key (power or reset) /// This is the "four-tuple" configuration: (driver, device, pin/channel, level) #[typeshare] @@ -77,6 +62,7 @@ pub struct AtxKeyConfig { /// Pin or channel number: /// - For GPIO: GPIO pin number /// - For USB Relay: relay channel (0-based) + /// - For Serial Relay (LCUS): relay channel (1-based) pub pin: u32, /// Active level (only applicable to GPIO, ignored for USB Relay) pub active_level: ActiveLevel, @@ -105,7 +91,7 @@ impl AtxKeyConfig { /// LED sensing configuration (optional) #[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(default)] pub struct AtxLedConfig { /// Whether LED sensing is enabled @@ -118,17 +104,6 @@ pub struct AtxLedConfig { pub inverted: bool, } -impl Default for AtxLedConfig { - fn default() -> Self { - Self { - enabled: false, - gpio_chip: String::new(), - gpio_pin: 0, - inverted: false, - } - } -} - impl AtxLedConfig { /// Check if LED sensing is configured pub fn is_configured(&self) -> bool { @@ -137,7 +112,7 @@ impl AtxLedConfig { } /// ATX state information -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AtxState { /// Whether ATX feature is available/enabled pub available: bool, @@ -151,18 +126,6 @@ pub struct AtxState { pub led_supported: bool, } -impl Default for AtxState { - fn default() -> Self { - Self { - available: false, - power_configured: false, - reset_configured: false, - power_status: PowerStatus::Unknown, - led_supported: false, - } - } -} - /// ATX power action request #[derive(Debug, Clone, Deserialize)] pub struct AtxPowerRequest { @@ -274,5 +237,6 @@ mod tests { assert!(!state.power_configured); assert!(!state.reset_configured); assert_eq!(state.power_status, PowerStatus::Unknown); + assert!(!state.led_supported); } } diff --git a/src/atx/wol.rs b/src/atx/wol.rs index 958e3a97..9da93cd7 100644 --- a/src/atx/wol.rs +++ b/src/atx/wol.rs @@ -10,7 +10,7 @@ use crate::error::{AppError, Result}; /// WOL magic packet structure: /// - 6 bytes of 0xFF /// - 16 repetitions of the target MAC address (6 bytes each) -/// Total: 6 + 16 * 6 = 102 bytes +/// Total: 6 + 16 * 6 = 102 bytes const MAGIC_PACKET_SIZE: usize = 102; /// Parse MAC address string into bytes @@ -160,8 +160,8 @@ mod tests { let packet = build_magic_packet(&mac); // Check header (6 bytes of 0xFF) - for i in 0..6 { - assert_eq!(packet[i], 0xFF); + for byte in packet.iter().take(6) { + assert_eq!(*byte, 0xFF); } // Check MAC repetitions diff --git a/src/audio/capture.rs b/src/audio/capture.rs index 33fcc673..aef64344 100644 --- a/src/audio/capture.rs +++ b/src/audio/capture.rs @@ -184,14 +184,7 @@ impl AudioCapturer { let log_throttler = self.log_throttler.clone(); let handle = tokio::task::spawn_blocking(move || { - capture_loop( - config, - state, - frame_tx, - stop_flag, - sequence, - log_throttler, - ); + capture_loop(config, state, frame_tx, stop_flag, sequence, log_throttler); }); *self.capture_handle.lock().await = Some(handle); diff --git a/src/audio/controller.rs b/src/audio/controller.rs index ea3621d0..5191fd82 100644 --- a/src/audio/controller.rs +++ b/src/audio/controller.rs @@ -39,6 +39,7 @@ impl AudioQuality { } /// Parse from string + #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Self { match s.to_lowercase().as_str() { "voice" | "low" => AudioQuality::Voice, diff --git a/src/audio/device.rs b/src/audio/device.rs index ed42726c..77536680 100644 --- a/src/audio/device.rs +++ b/src/audio/device.rs @@ -85,9 +85,7 @@ pub fn enumerate_audio_devices_with_current( let mut devices = Vec::new(); // Try to enumerate cards - let cards = match alsa::card::Iter::new() { - i => i, - }; + let cards = alsa::card::Iter::new(); for card_result in cards { let card = match card_result { diff --git a/src/audio/monitor.rs b/src/audio/monitor.rs index d29b747a..f764c775 100644 --- a/src/audio/monitor.rs +++ b/src/audio/monitor.rs @@ -16,9 +16,10 @@ use crate::events::{EventBus, SystemEvent}; use crate::utils::LogThrottler; /// Audio health status -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub enum AudioHealthStatus { /// Device is healthy and operational + #[default] Healthy, /// Device has an error, attempting recovery Error { @@ -33,12 +34,6 @@ pub enum AudioHealthStatus { Disconnected, } -impl Default for AudioHealthStatus { - fn default() -> Self { - Self::Healthy - } -} - /// Audio health monitor configuration #[derive(Debug, Clone)] pub struct AudioMonitorConfig { @@ -166,7 +161,7 @@ impl AudioHealthMonitor { let attempt = self.retry_count.load(Ordering::Relaxed); // Only publish every 5 attempts to avoid event spam - if attempt == 1 || attempt % 5 == 0 { + if attempt == 1 || attempt.is_multiple_of(5) { debug!("Audio reconnecting, attempt {}", attempt); if let Some(ref events) = *self.events.read().await { diff --git a/src/audio/streamer.rs b/src/audio/streamer.rs index 0d843e05..f05773fd 100644 --- a/src/audio/streamer.rs +++ b/src/audio/streamer.rs @@ -14,9 +14,10 @@ use super::encoder::{OpusConfig, OpusEncoder, OpusFrame}; use crate::error::{AppError, Result}; /// Audio stream state -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum AudioStreamState { /// Stream is stopped + #[default] Stopped, /// Stream is starting up Starting, @@ -26,14 +27,8 @@ pub enum AudioStreamState { Error, } -impl Default for AudioStreamState { - fn default() -> Self { - Self::Stopped - } -} - /// Audio streamer configuration -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct AudioStreamerConfig { /// Audio capture configuration pub capture: AudioConfig, @@ -41,15 +36,6 @@ pub struct AudioStreamerConfig { pub opus: OpusConfig, } -impl Default for AudioStreamerConfig { - fn default() -> Self { - Self { - capture: AudioConfig::default(), - opus: OpusConfig::default(), - } - } -} - impl AudioStreamerConfig { /// Create config for a specific device with default quality pub fn for_device(device_name: &str) -> Self { @@ -290,11 +276,9 @@ impl AudioStreamer { // Encode to Opus let opus_result = { let mut enc_guard = encoder.lock().await; - if let Some(ref mut enc) = *enc_guard { - Some(enc.encode_frame(&audio_frame)) - } else { - None - } + (*enc_guard) + .as_mut() + .map(|enc| enc.encode_frame(&audio_frame)) }; match opus_result { diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 5bbbd2f0..80f40f3f 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -92,11 +92,7 @@ fn is_public_endpoint(path: &str) -> bool { // Note: paths here are relative to /api since middleware is applied within the nested router matches!( path, - "/" - | "/auth/login" - | "/health" - | "/setup" - | "/setup/init" + "/" | "/auth/login" | "/health" | "/setup" | "/setup/init" ) || path.starts_with("/assets/") || path.starts_with("/static/") || path.ends_with(".js") diff --git a/src/auth/session.rs b/src/auth/session.rs index 9902acc8..6106dc16 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -110,7 +110,9 @@ impl SessionStore { /// Delete all expired sessions pub async fn cleanup_expired(&self) -> Result { - let result = sqlx::query("DELETE FROM sessions WHERE expires_at < datetime('now')") + let now = Utc::now().to_rfc3339(); + let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?1") + .bind(now) .execute(&self.pool) .await?; Ok(result.rows_affected()) diff --git a/src/auth/user.rs b/src/auth/user.rs index f731f52f..986fc41b 100644 --- a/src/auth/user.rs +++ b/src/auth/user.rs @@ -7,7 +7,7 @@ use super::password::{hash_password, verify_password}; use crate::error::{AppError, Result}; /// User row type from database -type UserRow = (String, String, String, i32, String, String); +type UserRow = (String, String, String, String, String); /// User data #[derive(Debug, Clone, Serialize, Deserialize)] @@ -16,7 +16,6 @@ pub struct User { pub username: String, #[serde(skip_serializing)] pub password_hash: String, - pub is_admin: bool, pub created_at: DateTime, pub updated_at: DateTime, } @@ -24,12 +23,11 @@ pub struct User { impl User { /// Convert from database row to User fn from_row(row: UserRow) -> Self { - let (id, username, password_hash, is_admin, created_at, updated_at) = row; + let (id, username, password_hash, created_at, updated_at) = row; Self { id, username, password_hash, - is_admin: is_admin != 0, created_at: DateTime::parse_from_rfc3339(&created_at) .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|_| Utc::now()), @@ -53,7 +51,7 @@ impl UserStore { } /// Create a new user - pub async fn create(&self, username: &str, password: &str, is_admin: bool) -> Result { + pub async fn create(&self, username: &str, password: &str) -> Result { // Check if username already exists if self.get_by_username(username).await?.is_some() { return Err(AppError::BadRequest(format!( @@ -68,21 +66,19 @@ impl UserStore { id: Uuid::new_v4().to_string(), username: username.to_string(), password_hash, - is_admin, created_at: now, updated_at: now, }; sqlx::query( r#" - INSERT INTO users (id, username, password_hash, is_admin, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) + INSERT INTO users (id, username, password_hash, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) "#, ) .bind(&user.id) .bind(&user.username) .bind(&user.password_hash) - .bind(user.is_admin as i32) .bind(user.created_at.to_rfc3339()) .bind(user.updated_at.to_rfc3339()) .execute(&self.pool) @@ -94,7 +90,7 @@ impl UserStore { /// Get user by ID pub async fn get(&self, user_id: &str) -> Result> { let row: Option = sqlx::query_as( - "SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users WHERE id = ?1", + "SELECT id, username, password_hash, created_at, updated_at FROM users WHERE id = ?1", ) .bind(user_id) .fetch_optional(&self.pool) @@ -106,7 +102,7 @@ impl UserStore { /// Get user by username pub async fn get_by_username(&self, username: &str) -> Result> { let row: Option = sqlx::query_as( - "SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users WHERE username = ?1", + "SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?1", ) .bind(username) .fetch_optional(&self.pool) @@ -161,13 +157,12 @@ impl UserStore { } let now = Utc::now(); - let result = - sqlx::query("UPDATE users SET username = ?1, updated_at = ?2 WHERE id = ?3") - .bind(new_username) - .bind(now.to_rfc3339()) - .bind(user_id) - .execute(&self.pool) - .await?; + let result = sqlx::query("UPDATE users SET username = ?1, updated_at = ?2 WHERE id = ?3") + .bind(new_username) + .bind(now.to_rfc3339()) + .bind(user_id) + .execute(&self.pool) + .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("User not found".to_string())); @@ -179,7 +174,7 @@ impl UserStore { /// List all users pub async fn list(&self) -> Result> { let rows: Vec = sqlx::query_as( - "SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users ORDER BY created_at", + "SELECT id, username, password_hash, created_at, updated_at FROM users ORDER BY created_at", ) .fetch_all(&self.pool) .await?; diff --git a/src/config/schema.rs b/src/config/schema.rs index 63abe268..ff5fb131 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -11,6 +11,7 @@ pub use crate::rustdesk::config::RustDeskConfig; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] +#[derive(Default)] pub struct AppConfig { /// Whether initial setup has been completed pub initialized: bool, @@ -34,24 +35,8 @@ pub struct AppConfig { pub extensions: ExtensionsConfig, /// RustDesk remote access settings pub rustdesk: RustDeskConfig, -} - -impl Default for AppConfig { - fn default() -> Self { - Self { - initialized: false, - auth: AuthConfig::default(), - video: VideoConfig::default(), - hid: HidConfig::default(), - msd: MsdConfig::default(), - atx: AtxConfig::default(), - audio: AudioConfig::default(), - stream: StreamConfig::default(), - web: WebConfig::default(), - extensions: ExtensionsConfig::default(), - rustdesk: RustDeskConfig::default(), - } - } + /// RTSP streaming settings + pub rtsp: RtspConfig, } /// Authentication configuration @@ -116,21 +101,17 @@ impl Default for VideoConfig { #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum HidBackend { /// USB OTG HID gadget Otg, /// CH9329 serial HID controller Ch9329, /// Disabled + #[default] None, } -impl Default for HidBackend { - fn default() -> Self { - Self::None - } -} - /// OTG USB device descriptor configuration #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -163,8 +144,10 @@ impl Default for OtgDescriptorConfig { #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] +#[derive(Default)] pub enum OtgHidProfile { /// Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) + #[default] Full, /// Full HID device set without MSD FullNoMsd, @@ -180,12 +163,6 @@ pub enum OtgHidProfile { Custom, } -impl Default for OtgHidProfile { - fn default() -> Self { - Self::Full - } -} - /// OTG HID function selection (used when profile is Custom) #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -360,6 +337,7 @@ pub use crate::atx::{ActiveLevel, AtxDriverType, AtxKeyConfig, AtxLedConfig}; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] +#[derive(Default)] pub struct AtxConfig { /// Enable ATX functionality pub enabled: bool, @@ -373,18 +351,6 @@ pub struct AtxConfig { pub wol_interface: String, } -impl Default for AtxConfig { - fn default() -> Self { - Self { - enabled: false, - power: AtxKeyConfig::default(), - reset: AtxKeyConfig::default(), - led: AtxLedConfig::default(), - wol_interface: String::new(), - } - } -} - impl AtxConfig { /// Convert to AtxControllerConfig for the controller pub fn to_controller_config(&self) -> crate::atx::AtxControllerConfig { @@ -427,16 +393,62 @@ impl Default for AudioConfig { #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum StreamMode { /// WebRTC with H264/H265 WebRTC, /// MJPEG over HTTP + #[default] Mjpeg, } -impl Default for StreamMode { +/// RTSP output codec +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +#[derive(Default)] +pub enum RtspCodec { + #[default] + H264, + H265, +} + +/// RTSP configuration +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RtspConfig { + /// Enable RTSP output + pub enabled: bool, + /// Bind IP address + pub bind: String, + /// RTSP TCP listen port + pub port: u16, + /// Stream path (without leading slash) + pub path: String, + /// Allow only one client connection at a time + pub allow_one_client: bool, + /// Output codec (H264/H265) + pub codec: RtspCodec, + /// Optional username for authentication + pub username: Option, + /// Optional password for authentication + #[typeshare(skip)] + pub password: Option, +} + +impl Default for RtspConfig { fn default() -> Self { - Self::Mjpeg + Self { + enabled: false, + bind: "0.0.0.0".to_string(), + port: 8554, + path: "live".to_string(), + allow_one_client: true, + codec: RtspCodec::H264, + username: None, + password: None, + } } } @@ -444,8 +456,10 @@ impl Default for StreamMode { #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum EncoderType { /// Auto-detect best encoder + #[default] Auto, /// Software encoder (libx264) Software, @@ -463,12 +477,6 @@ pub enum EncoderType { V4l2m2m, } -impl Default for EncoderType { - fn default() -> Self { - Self::Auto - } -} - impl EncoderType { /// Convert to EncoderBackend for registry queries pub fn to_backend(&self) -> Option { diff --git a/src/config/store.rs b/src/config/store.rs index 0e48be8d..2a69ade0 100644 --- a/src/config/store.rs +++ b/src/config/store.rs @@ -82,7 +82,6 @@ impl ConfigStore { id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) @@ -121,6 +120,26 @@ impl ConfigStore { .execute(pool) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS wol_history ( + mac_address TEXT PRIMARY KEY, + updated_at INTEGER NOT NULL + ) + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE INDEX IF NOT EXISTS idx_wol_history_updated_at + ON wol_history(updated_at DESC) + "#, + ) + .execute(pool) + .await?; + Ok(()) } diff --git a/src/events/types.rs b/src/events/types.rs index 44a69b2d..ab3ebfd4 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -124,6 +124,7 @@ pub struct ClientStats { /// ``` #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "event", content = "data")] +#[allow(clippy::large_enum_variant)] pub enum SystemEvent { // ============================================================================ // Video Stream Events diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 279d303c..6feedbb2 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -230,13 +230,6 @@ impl ExtensionManager { "-W".to_string(), // Writable (allow input) ]; - // Add credential if set (still useful for additional security layer) - if let Some(ref cred) = c.credential { - if !cred.is_empty() { - args.extend(["-c".to_string(), cred.clone()]); - } - } - // Add shell as last argument args.push(c.shell.clone()); Ok(args) diff --git a/src/extensions/types.rs b/src/extensions/types.rs index c6a3a70d..a5dc795c 100644 --- a/src/extensions/types.rs +++ b/src/extensions/types.rs @@ -102,9 +102,6 @@ pub struct TtydConfig { pub port: u16, /// Shell to execute pub shell: String, - /// Credential in format "user:password" (optional) - #[serde(skip_serializing_if = "Option::is_none")] - pub credential: Option, } impl Default for TtydConfig { @@ -113,7 +110,6 @@ impl Default for TtydConfig { enabled: false, port: 7681, shell: "/bin/bash".to_string(), - credential: None, } } } @@ -149,6 +145,7 @@ impl Default for GostcConfig { #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] +#[derive(Default)] pub struct EasytierConfig { /// Enable auto-start pub enabled: bool, @@ -165,18 +162,6 @@ pub struct EasytierConfig { pub virtual_ip: Option, } -impl Default for EasytierConfig { - fn default() -> Self { - Self { - enabled: false, - network_name: String::new(), - network_secret: String::new(), - peer_urls: Vec::new(), - virtual_ip: None, - } - } -} - /// Combined extensions configuration #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src/hid/backend.rs b/src/hid/backend.rs index 95de431f..62fd1bf5 100644 --- a/src/hid/backend.rs +++ b/src/hid/backend.rs @@ -14,6 +14,7 @@ fn default_ch9329_baud_rate() -> u32 { /// HID backend type #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] +#[derive(Default)] pub enum HidBackendType { /// USB OTG gadget mode Otg, @@ -26,15 +27,10 @@ pub enum HidBackendType { baud_rate: u32, }, /// No HID backend (disabled) + #[default] None, } -impl Default for HidBackendType { - fn default() -> Self { - Self::None - } -} - impl HidBackendType { /// Check if OTG backend is available on this system pub fn otg_available() -> bool { diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index 0893a49e..13abf25e 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -219,8 +219,10 @@ impl From for LedStatus { /// CH9329 work mode #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[repr(u8)] +#[derive(Default)] pub enum WorkMode { /// Mode 0: Standard USB Keyboard + Mouse (default) + #[default] KeyboardMouse = 0x00, /// Mode 1: Standard USB Keyboard only KeyboardOnly = 0x01, @@ -230,17 +232,13 @@ pub enum WorkMode { CustomHid = 0x03, } -impl Default for WorkMode { - fn default() -> Self { - Self::KeyboardMouse - } -} - /// CH9329 serial communication mode #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[repr(u8)] +#[derive(Default)] pub enum SerialMode { /// Mode 0: Protocol transmission mode (default) + #[default] Protocol = 0x00, /// Mode 1: ASCII mode Ascii = 0x01, @@ -248,12 +246,6 @@ pub enum SerialMode { Transparent = 0x02, } -impl Default for SerialMode { - fn default() -> Self { - Self::Protocol - } -} - /// CH9329 configuration parameters #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Ch9329Config { diff --git a/src/hid/datachannel.rs b/src/hid/datachannel.rs index 00ecfb12..04c76d21 100644 --- a/src/hid/datachannel.rs +++ b/src/hid/datachannel.rs @@ -9,7 +9,7 @@ //! //! Keyboard event (type 0x01): //! - Byte 1: Event type (0x00 = down, 0x01 = up) -//! - Byte 2: Key code (USB HID usage code or JS keyCode) +//! - Byte 2: Key code (USB HID usage code) //! - Byte 3: Modifiers bitmask //! - Bit 0: Left Ctrl //! - Bit 1: Left Shift @@ -119,7 +119,7 @@ fn parse_keyboard_message(data: &[u8]) -> Option { event_type, key, modifiers, - is_usb_hid: false, // WebRTC datachannel sends JS keycodes + is_usb_hid: true, // WebRTC/WebSocket HID channel sends USB HID usages })) } @@ -245,6 +245,7 @@ mod tests { assert_eq!(kb.key, 0x04); assert!(kb.modifiers.left_ctrl); assert!(!kb.modifiers.left_shift); + assert!(kb.is_usb_hid); } _ => panic!("Expected keyboard event"), } @@ -280,7 +281,7 @@ mod tests { right_alt: false, right_meta: false, }, - is_usb_hid: false, + is_usb_hid: true, }; let encoded = encode_keyboard_event(&event); diff --git a/src/hid/mod.rs b/src/hid/mod.rs index 611bdea8..b73dc916 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -42,17 +42,17 @@ pub struct HidInfo { pub screen_resolution: Option<(u32, u32)>, } -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use tokio::sync::RwLock; use tracing::{info, warn}; use crate::error::{AppError, Result}; use crate::otg::OtgService; +use std::time::Duration; use tokio::sync::mpsc; use tokio::sync::Mutex; use tokio::task::JoinHandle; -use std::time::Duration; const HID_EVENT_QUEUE_CAPACITY: usize = 64; const HID_EVENT_SEND_TIMEOUT_MS: u64 = 30; @@ -203,7 +203,10 @@ impl HidController { )); } - if matches!(event.event_type, MouseEventType::Move | MouseEventType::MoveAbs) { + if matches!( + event.event_type, + MouseEventType::Move | MouseEventType::MoveAbs + ) { // Best-effort: drop/merge move events if queue is full self.enqueue_mouse_move(event) } else { @@ -470,13 +473,7 @@ impl HidController { None => break, }; - process_hid_event( - event, - &backend, - &monitor, - &backend_type, - ) - .await; + process_hid_event(event, &backend, &monitor, &backend_type).await; // After each event, flush latest move if pending if pending_move_flag.swap(false, Ordering::AcqRel) { @@ -505,9 +502,9 @@ impl HidController { self.pending_move_flag.store(true, Ordering::Release); Ok(()) } - Err(mpsc::error::TrySendError::Closed(_)) => Err(AppError::BadRequest( - "HID event queue closed".to_string(), - )), + Err(mpsc::error::TrySendError::Closed(_)) => { + Err(AppError::BadRequest("HID event queue closed".to_string())) + } } } @@ -517,9 +514,11 @@ impl HidController { Err(mpsc::error::TrySendError::Full(ev)) => { // For non-move events, wait briefly to avoid dropping critical input let tx = self.hid_tx.clone(); - let send_result = - tokio::time::timeout(Duration::from_millis(HID_EVENT_SEND_TIMEOUT_MS), tx.send(ev)) - .await; + let send_result = tokio::time::timeout( + Duration::from_millis(HID_EVENT_SEND_TIMEOUT_MS), + tx.send(ev), + ) + .await; if send_result.is_ok() { Ok(()) } else { @@ -527,9 +526,9 @@ impl HidController { Ok(()) } } - Err(mpsc::error::TrySendError::Closed(_)) => Err(AppError::BadRequest( - "HID event queue closed".to_string(), - )), + Err(mpsc::error::TrySendError::Closed(_)) => { + Err(AppError::BadRequest("HID event queue closed".to_string())) + } } } } diff --git a/src/hid/monitor.rs b/src/hid/monitor.rs index 0ce84d88..b5eedd72 100644 --- a/src/hid/monitor.rs +++ b/src/hid/monitor.rs @@ -16,9 +16,10 @@ use crate::events::{EventBus, SystemEvent}; use crate::utils::LogThrottler; /// HID health status -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub enum HidHealthStatus { /// Device is healthy and operational + #[default] Healthy, /// Device has an error, attempting recovery Error { @@ -33,12 +34,6 @@ pub enum HidHealthStatus { Disconnected, } -impl Default for HidHealthStatus { - fn default() -> Self { - Self::Healthy - } -} - /// HID health monitor configuration #[derive(Debug, Clone)] pub struct HidMonitorConfig { @@ -196,7 +191,7 @@ impl HidHealthMonitor { let attempt = self.retry_count.load(Ordering::Relaxed); // Only publish every 5 attempts to avoid event spam - if attempt == 1 || attempt % 5 == 0 { + if attempt == 1 || attempt.is_multiple_of(5) { debug!("HID {} reconnecting, attempt {}", backend, attempt); if let Some(ref events) = *self.events.read().await { diff --git a/src/hid/otg.rs b/src/hid/otg.rs index b21917d4..3c04bea0 100644 --- a/src/hid/otg.rs +++ b/src/hid/otg.rs @@ -228,7 +228,7 @@ impl OtgBackend { Ok(false) } Ok(_) => Ok(false), - Err(e) => Err(std::io::Error::new(std::io::ErrorKind::Other, e)), + Err(e) => Err(std::io::Error::other(e)), } } @@ -393,21 +393,10 @@ impl OtgBackend { /// Check if all HID device files exist pub fn check_devices_exist(&self) -> bool { - self.keyboard_path - .as_ref() - .map_or(true, |p| p.exists()) - && self - .mouse_rel_path - .as_ref() - .map_or(true, |p| p.exists()) - && self - .mouse_abs_path - .as_ref() - .map_or(true, |p| p.exists()) - && self - .consumer_path - .as_ref() - .map_or(true, |p| p.exists()) + self.keyboard_path.as_ref().is_none_or(|p| p.exists()) + && self.mouse_rel_path.as_ref().is_none_or(|p| p.exists()) + && self.mouse_abs_path.as_ref().is_none_or(|p| p.exists()) + && self.consumer_path.as_ref().is_none_or(|p| p.exists()) } /// Get list of missing device paths @@ -952,9 +941,7 @@ impl HidBackend for OtgBackend { } fn supports_absolute_mouse(&self) -> bool { - self.mouse_abs_path - .as_ref() - .map_or(false, |p| p.exists()) + self.mouse_abs_path.as_ref().is_some_and(|p| p.exists()) } async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> { diff --git a/src/lib.rs b/src/lib.rs index a854c712..e64e5da8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,9 +14,11 @@ pub mod hid; pub mod modules; pub mod msd; pub mod otg; +pub mod rtsp; pub mod rustdesk; pub mod state; pub mod stream; +pub mod update; pub mod utils; pub mod video; pub mod web; diff --git a/src/main.rs b/src/main.rs index d360ae80..3cd9ef0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,9 +19,14 @@ use one_kvm::extensions::ExtensionManager; use one_kvm::hid::{HidBackendType, HidController}; use one_kvm::msd::MsdController; use one_kvm::otg::{configfs, OtgService}; +use one_kvm::rtsp::RtspService; use one_kvm::rustdesk::RustDeskService; use one_kvm::state::AppState; +use one_kvm::update::UpdateService; use one_kvm::utils::bind_tcp_listener; +use one_kvm::video::codec_constraints::{ + enforce_constraints_with_stream_manager, StreamCodecConstraints, +}; use one_kvm::video::format::{PixelFormat, Resolution}; use one_kvm::video::{Streamer, VideoStreamManager}; use one_kvm::web; @@ -158,7 +163,11 @@ async fn main() -> anyhow::Result<()> { } let bind_ips = resolve_bind_addresses(&config.web)?; - let scheme = if config.web.https_enabled { "https" } else { "http" }; + let scheme = if config.web.https_enabled { + "https" + } else { + "http" + }; let bind_port = if config.web.https_enabled { config.web.https_port } else { @@ -530,7 +539,24 @@ async fn main() -> anyhow::Result<()> { None }; + // Create RTSP service (optional, based on config) + let rtsp = if config.rtsp.enabled { + tracing::info!( + "Initializing RTSP service: rtsp://{}:{}/{}", + config.rtsp.bind, + config.rtsp.port, + config.rtsp.path + ); + let service = RtspService::new(config.rtsp.clone(), stream_manager.clone()); + Some(Arc::new(service)) + } else { + tracing::info!("RTSP disabled in configuration"); + None + }; + // Create application state + let update_service = Arc::new(UpdateService::new(data_dir.join("updates"))); + let state = AppState::new( config_store.clone(), session_store, @@ -542,8 +568,10 @@ async fn main() -> anyhow::Result<()> { atx, audio, rustdesk.clone(), + rtsp.clone(), extensions.clone(), events.clone(), + update_service, shutdown_tx.clone(), data_dir.clone(), ); @@ -573,6 +601,30 @@ async fn main() -> anyhow::Result<()> { } } + // Start RTSP service if enabled + if let Some(ref service) = rtsp { + if let Err(e) = service.start().await { + tracing::error!("Failed to start RTSP service: {}", e); + } else { + tracing::info!("RTSP service started"); + } + } + + // Enforce startup codec constraints (e.g. RTSP/RustDesk locks) + { + let runtime_config = state.config.get(); + let constraints = StreamCodecConstraints::from_config(&runtime_config); + match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await { + Ok(result) if result.changed => { + if let Some(message) = result.message { + tracing::info!("{}", message); + } + } + Ok(_) => {} + Err(e) => tracing::warn!("Failed to enforce startup codec constraints: {}", e), + } + } + // Start enabled extensions { let ext_config = config_store.get(); @@ -646,7 +698,7 @@ async fn main() -> anyhow::Result<()> { let server = axum_server::from_tcp_rustls(listener, tls_config.clone())? .serve(app.clone().into_make_service()); - servers.push(async move { server.await }); + servers.push(server); } tokio::select! { @@ -712,10 +764,13 @@ fn init_logging(level: LogLevel, verbose_count: u8) { let env_filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()); - tracing_subscriber::registry() + if let Err(err) = tracing_subscriber::registry() .with(env_filter) .with(tracing_subscriber::fmt::layer()) - .init(); + .try_init() + { + eprintln!("failed to initialize tracing: {}", err); + } } /// Get the application data directory @@ -879,6 +934,15 @@ async fn cleanup(state: &Arc) { } } + // Stop RTSP service + if let Some(ref service) = *state.rtsp.read().await { + if let Err(e) = service.stop().await { + tracing::warn!("Failed to stop RTSP service: {}", e); + } else { + tracing::info!("RTSP service stopped"); + } + } + // Stop video if let Err(e) = state.stream_manager.stop().await { tracing::warn!("Failed to stop streamer: {}", e); diff --git a/src/msd/controller.rs b/src/msd/controller.rs index 5641d3c7..5d38396d 100644 --- a/src/msd/controller.rs +++ b/src/msd/controller.rs @@ -52,10 +52,7 @@ impl MsdController { /// # Parameters /// * `otg_service` - OTG service for gadget management /// * `msd_dir` - Base directory for MSD storage - pub fn new( - otg_service: Arc, - msd_dir: impl Into, - ) -> Self { + pub fn new(otg_service: Arc, msd_dir: impl Into) -> Self { let msd_dir = msd_dir.into(); let images_path = msd_dir.join("images"); let ventoy_dir = msd_dir.join("ventoy"); diff --git a/src/msd/image.rs b/src/msd/image.rs index d08b7a18..e7a066f4 100644 --- a/src/msd/image.rs +++ b/src/msd/image.rs @@ -87,8 +87,7 @@ impl ImageManager { .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| { - chrono::DateTime::from_timestamp(d.as_secs() as i64, 0) - .unwrap_or_else(|| Utc::now().into()) + chrono::DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_else(Utc::now) }) .unwrap_or_else(Utc::now); @@ -400,7 +399,7 @@ impl ImageManager { .headers() .get(reqwest::header::CONTENT_DISPOSITION) .and_then(|v| v.to_str().ok()) - .and_then(|s| extract_filename_from_content_disposition(s)); + .and_then(extract_filename_from_content_disposition); if let Some(name) = from_header { sanitize_filename(&name) diff --git a/src/msd/monitor.rs b/src/msd/monitor.rs index a9f80109..fd4a9f96 100644 --- a/src/msd/monitor.rs +++ b/src/msd/monitor.rs @@ -15,9 +15,10 @@ use crate::events::{EventBus, SystemEvent}; use crate::utils::LogThrottler; /// MSD health status -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub enum MsdHealthStatus { /// Device is healthy and operational + #[default] Healthy, /// Device has an error Error { @@ -28,12 +29,6 @@ pub enum MsdHealthStatus { }, } -impl Default for MsdHealthStatus { - fn default() -> Self { - Self::Healthy - } -} - /// MSD health monitor configuration #[derive(Debug, Clone)] pub struct MsdMonitorConfig { diff --git a/src/msd/types.rs b/src/msd/types.rs index a658db16..8f1e68cf 100644 --- a/src/msd/types.rs +++ b/src/msd/types.rs @@ -7,8 +7,10 @@ use std::path::PathBuf; /// MSD operating mode #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] +#[derive(Default)] pub enum MsdMode { /// No storage connected + #[default] None, /// Image file mounted (ISO/IMG) Image, @@ -16,12 +18,6 @@ pub enum MsdMode { Drive, } -impl Default for MsdMode { - fn default() -> Self { - Self::None - } -} - /// Image file metadata #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageInfo { diff --git a/src/msd/ventoy_drive.rs b/src/msd/ventoy_drive.rs index 8839a6e1..20d07494 100644 --- a/src/msd/ventoy_drive.rs +++ b/src/msd/ventoy_drive.rs @@ -328,10 +328,7 @@ impl VentoyDrive { let image = match VentoyImage::open(&path) { Ok(img) => img, Err(e) => { - let _ = rt.block_on(tx.send(Err(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )))); + let _ = rt.block_on(tx.send(Err(std::io::Error::other(e.to_string())))); return; } }; @@ -341,10 +338,7 @@ impl VentoyDrive { // Stream the file through the writer if let Err(e) = image.read_file_to_writer(&file_path_owned, &mut chunk_writer) { - let _ = rt.block_on(tx.send(Err(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )))); + let _ = rt.block_on(tx.send(Err(std::io::Error::other(e.to_string())))); } }); @@ -543,17 +537,14 @@ mod tests { /// Decompress xz file using system command fn decompress_xz(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { let output = Command::new("xz") - .args(&["-d", "-k", "-c", src.to_str().unwrap()]) + .args(["-d", "-k", "-c", src.to_str().unwrap()]) .output()?; if !output.status.success() { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!( - "xz decompress failed: {}", - String::from_utf8_lossy(&output.stderr) - ), - )); + return Err(std::io::Error::other(format!( + "xz decompress failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); } std::fs::write(dst, &output.stdout)?; diff --git a/src/otg/manager.rs b/src/otg/manager.rs index 64773d2b..9996238a 100644 --- a/src/otg/manager.rs +++ b/src/otg/manager.rs @@ -422,7 +422,11 @@ impl OtgGadgetManager { if dest.exists() { if let Err(e) = remove_file(&dest) { - warn!("Failed to remove existing config link {}: {}", dest.display(), e); + warn!( + "Failed to remove existing config link {}: {}", + dest.display(), + e + ); continue; } } diff --git a/src/otg/service.rs b/src/otg/service.rs index 2be3c944..6daddd45 100644 --- a/src/otg/service.rs +++ b/src/otg/service.rs @@ -35,7 +35,7 @@ const FLAG_HID: u8 = 0b01; const FLAG_MSD: u8 = 0b10; /// HID device paths -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct HidDevicePaths { pub keyboard: Option, pub mouse_relative: Option, @@ -43,17 +43,6 @@ pub struct HidDevicePaths { pub consumer: Option, } -impl Default for HidDevicePaths { - fn default() -> Self { - Self { - keyboard: None, - mouse_relative: None, - mouse_absolute: None, - consumer: None, - } - } -} - impl HidDevicePaths { pub fn existing_paths(&self) -> Vec { let mut paths = Vec::new(); @@ -239,12 +228,10 @@ impl OtgService { let requested_functions = self.hid_functions.read().await.clone(); { let state = self.state.read().await; - if state.hid_enabled { - if state.hid_functions.as_ref() == Some(&requested_functions) { - if let Some(ref paths) = state.hid_paths { - info!("HID already enabled, returning existing paths"); - return Ok(paths.clone()); - } + if state.hid_enabled && state.hid_functions.as_ref() == Some(&requested_functions) { + if let Some(ref paths) = state.hid_paths { + info!("HID already enabled, returning existing paths"); + return Ok(paths.clone()); } } } @@ -671,7 +658,7 @@ mod tests { fn test_service_creation() { let _service = OtgService::new(); // Just test that creation doesn't panic - assert!(!OtgService::is_available() || true); // Depends on environment + let _ = OtgService::is_available(); // Depends on environment } #[tokio::test] diff --git a/src/rtsp/mod.rs b/src/rtsp/mod.rs new file mode 100644 index 00000000..b8cfd8b5 --- /dev/null +++ b/src/rtsp/mod.rs @@ -0,0 +1,3 @@ +pub mod service; + +pub use service::{RtspService, RtspServiceStatus}; diff --git a/src/rtsp/service.rs b/src/rtsp/service.rs new file mode 100644 index 00000000..ae17ab6d --- /dev/null +++ b/src/rtsp/service.rs @@ -0,0 +1,1343 @@ +use base64::Engine; +use bytes::Bytes; +use rand::Rng; +use rtp::packet::Packet; +use rtp::packetizer::Payloader; +use rtsp_types as rtsp; +use sdp_types as sdp; +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{broadcast, Mutex, RwLock}; +use tokio::time::{sleep, Duration}; +use webrtc::util::Marshal; + +use crate::config::{RtspCodec, RtspConfig}; +use crate::error::{AppError, Result}; +use crate::video::encoder::registry::VideoEncoderType; +use crate::video::encoder::VideoCodecType; +use crate::video::shared_video_pipeline::EncodedVideoFrame; +use crate::video::VideoStreamManager; +use crate::webrtc::h265_payloader::H265Payloader; +use crate::webrtc::rtp::parse_profile_level_id_from_sps; + +const RTP_CLOCK_RATE: u32 = 90_000; +const RTP_MTU: usize = 1200; +const RTSP_BUF_SIZE: usize = 8192; +const RTSP_RESUBSCRIBE_DELAY_MS: u64 = 300; + +#[derive(Debug, Clone, PartialEq)] +pub enum RtspServiceStatus { + Stopped, + Starting, + Running, + Error(String), +} + +impl std::fmt::Display for RtspServiceStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stopped => write!(f, "stopped"), + Self::Starting => write!(f, "starting"), + Self::Running => write!(f, "running"), + Self::Error(err) => write!(f, "error: {}", err), + } + } +} + +#[derive(Debug, Clone)] +struct RtspRequest { + method: rtsp::Method, + uri: String, + version: rtsp::Version, + headers: HashMap, +} + +struct RtspConnectionState { + session_id: String, + setup_done: bool, + interleaved_channel: u8, +} + +impl RtspConnectionState { + fn new() -> Self { + Self { + session_id: generate_session_id(), + setup_done: false, + interleaved_channel: 0, + } + } +} + +#[derive(Default, Clone)] +struct ParameterSets { + h264_sps: Option, + h264_pps: Option, + h265_vps: Option, + h265_sps: Option, + h265_pps: Option, +} + +#[derive(Clone)] +struct SharedRtspState { + active_client: Arc>>, + parameter_sets: Arc>, +} + +impl SharedRtspState { + fn new() -> Self { + Self { + active_client: Arc::new(Mutex::new(None)), + parameter_sets: Arc::new(RwLock::new(ParameterSets::default())), + } + } +} + +pub struct RtspService { + config: Arc>, + status: Arc>, + video_manager: Arc, + shutdown_tx: broadcast::Sender<()>, + server_handle: Arc>>>, + client_handles: Arc>>>, + shared_state: SharedRtspState, +} + +impl RtspService { + pub fn new(config: RtspConfig, video_manager: Arc) -> Self { + let (shutdown_tx, _) = broadcast::channel(1); + Self { + config: Arc::new(RwLock::new(config)), + status: Arc::new(RwLock::new(RtspServiceStatus::Stopped)), + video_manager, + shutdown_tx, + server_handle: Arc::new(Mutex::new(None)), + client_handles: Arc::new(Mutex::new(Vec::new())), + shared_state: SharedRtspState::new(), + } + } + + pub async fn start(&self) -> Result<()> { + let config = self.config.read().await.clone(); + if !config.enabled { + *self.status.write().await = RtspServiceStatus::Stopped; + return Ok(()); + } + + if matches!(*self.status.read().await, RtspServiceStatus::Running) { + return Ok(()); + } + + *self.status.write().await = RtspServiceStatus::Starting; + + let codec = match config.codec { + RtspCodec::H264 => VideoCodecType::H264, + RtspCodec::H265 => VideoCodecType::H265, + }; + + if let Err(err) = self.video_manager.set_video_codec(codec).await { + let message = format!("failed to set codec before RTSP start: {}", err); + *self.status.write().await = RtspServiceStatus::Error(message.clone()); + return Err(AppError::VideoError(message)); + } + + if let Err(err) = self.video_manager.request_keyframe().await { + tracing::debug!("Failed to request keyframe on RTSP start: {}", err); + } + + let bind_addr: SocketAddr = format!("{}:{}", config.bind, config.port) + .parse() + .map_err(|e| AppError::BadRequest(format!("Invalid RTSP bind address: {}", e)))?; + + let listener = TcpListener::bind(bind_addr).await.map_err(|e| { + AppError::Io(io::Error::new(e.kind(), format!("RTSP bind failed: {}", e))) + })?; + + let service_config = self.config.clone(); + let video_manager = self.video_manager.clone(); + let shared_state = self.shared_state.clone(); + let mut shutdown_rx = self.shutdown_tx.subscribe(); + let status = self.status.clone(); + let client_handles = self.client_handles.clone(); + + let handle = tokio::spawn(async move { + tracing::info!("RTSP service listening on {}", bind_addr); + *status.write().await = RtspServiceStatus::Running; + + loop { + tokio::select! { + _ = shutdown_rx.recv() => { + tracing::info!("RTSP service shutdown signal received"); + break; + } + result = listener.accept() => { + match result { + Ok((stream, addr)) => { + let cfg = service_config.clone(); + let vm = video_manager.clone(); + let shared = shared_state.clone(); + let handle = tokio::spawn(async move { + if let Err(e) = handle_client(stream, addr, cfg, vm, shared).await { + tracing::warn!("RTSP client {} ended with error: {}", addr, e); + } + }); + let mut handles = client_handles.lock().await; + handles.retain(|task| !task.is_finished()); + handles.push(handle); + } + Err(e) => { + tracing::warn!("RTSP accept failed: {}", e); + } + } + } + } + } + + *status.write().await = RtspServiceStatus::Stopped; + }); + + *self.server_handle.lock().await = Some(handle); + Ok(()) + } + + pub async fn stop(&self) -> Result<()> { + let _ = self.shutdown_tx.send(()); + if let Some(handle) = self.server_handle.lock().await.take() { + handle.abort(); + } + + let mut client_handles = self.client_handles.lock().await; + for handle in client_handles.drain(..) { + handle.abort(); + } + + *self.shared_state.active_client.lock().await = None; + *self.status.write().await = RtspServiceStatus::Stopped; + Ok(()) + } + + pub async fn restart(&self, config: RtspConfig) -> Result<()> { + self.update_config(config).await; + self.stop().await?; + self.start().await + } + + pub async fn update_config(&self, config: RtspConfig) { + *self.config.write().await = config; + } + + pub async fn config(&self) -> RtspConfig { + self.config.read().await.clone() + } + + pub async fn status(&self) -> RtspServiceStatus { + self.status.read().await.clone() + } +} + +async fn handle_client( + mut stream: TcpStream, + peer: SocketAddr, + config: Arc>, + video_manager: Arc, + shared: SharedRtspState, +) -> Result<()> { + let cfg_snapshot = config.read().await.clone(); + let expected_auth = rtsp_auth_credentials(&cfg_snapshot); + + if cfg_snapshot.allow_one_client { + let mut active_guard = shared.active_client.lock().await; + if let Some(active) = *active_guard { + if active != peer { + send_simple_response( + &mut stream, + 453, + "Not Enough Bandwidth", + None, + "another client is active", + ) + .await?; + return Ok(()); + } + } else { + *active_guard = Some(peer); + } + } + + let mut state = RtspConnectionState::new(); + let mut read_buf = [0u8; RTSP_BUF_SIZE]; + let mut request_buffer = Vec::with_capacity(RTSP_BUF_SIZE); + + 'client_loop: loop { + let n = stream.read(&mut read_buf).await?; + if n == 0 { + break; + } + + request_buffer.extend_from_slice(&read_buf[..n]); + + while let Some(req_text) = take_rtsp_request_from_buffer(&mut request_buffer) { + let req = match parse_rtsp_request(&req_text) { + Some(r) => r, + None => { + send_simple_response(&mut stream, 400, "Bad Request", None, "").await?; + continue; + } + }; + + if !is_valid_rtsp_path(&req.method, &req.uri, &cfg_snapshot.path) { + send_response(&mut stream, &req, 404, "Not Found", vec![], "", "").await?; + continue; + } + + if let Some((expected_user, expected_pass)) = expected_auth.as_ref() { + let ok = extract_basic_auth(&req) + .map(|(u, p)| u == expected_user.as_str() && p == expected_pass.as_str()) + .unwrap_or(false); + if !ok { + send_response( + &mut stream, + &req, + 401, + "Unauthorized", + vec![( + "WWW-Authenticate".to_string(), + "Basic realm=\"One-KVM RTSP\"".to_string(), + )], + "", + "", + ) + .await?; + continue; + } + } + + match &req.method { + rtsp::Method::Options => { + send_response( + &mut stream, + &req, + 200, + "OK", + vec![( + "Public".to_string(), + "OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN".to_string(), + )], + "", + "", + ) + .await?; + } + rtsp::Method::Describe => { + let codec = match cfg_snapshot.codec { + RtspCodec::H264 => VideoCodecType::H264, + RtspCodec::H265 => VideoCodecType::H265, + }; + let params = shared.parameter_sets.read().await.clone(); + let sdp = build_sdp(&cfg_snapshot, codec, ¶ms); + if sdp.is_empty() { + send_response( + &mut stream, + &req, + 500, + "Internal Server Error", + vec![], + "", + &state.session_id, + ) + .await?; + continue; + } + + send_response( + &mut stream, + &req, + 200, + "OK", + vec![("Content-Type".to_string(), "application/sdp".to_string())], + &sdp, + &state.session_id, + ) + .await?; + } + rtsp::Method::Setup => { + let transport = req.headers.get("transport").cloned().unwrap_or_default(); + + if !is_tcp_transport_request(&transport) { + send_response( + &mut stream, + &req, + 461, + "Unsupported Transport", + vec![( + "Transport".to_string(), + "RTP/AVP/TCP;unicast;interleaved=0-1".to_string(), + )], + "", + &state.session_id, + ) + .await?; + continue; + } + + let interleaved = parse_interleaved_channel(&transport).unwrap_or(0); + state.setup_done = true; + state.interleaved_channel = interleaved; + + let transport_resp = format!( + "RTP/AVP/TCP;unicast;interleaved={}-{}", + interleaved, + interleaved.saturating_add(1) + ); + + send_response( + &mut stream, + &req, + 200, + "OK", + vec![("Transport".to_string(), transport_resp)], + "", + &state.session_id, + ) + .await?; + } + rtsp::Method::Play => { + if !state.setup_done { + send_response( + &mut stream, + &req, + 455, + "Method Not Valid in This State", + vec![], + "", + &state.session_id, + ) + .await?; + continue; + } + + send_response(&mut stream, &req, 200, "OK", vec![], "", &state.session_id) + .await?; + + if let Err(e) = stream_video_interleaved( + stream, + &video_manager, + cfg_snapshot.codec.clone(), + state.interleaved_channel, + shared.clone(), + state.session_id.clone(), + ) + .await + { + tracing::warn!("RTSP stream loop ended for {}: {}", peer, e); + } + + break 'client_loop; + } + rtsp::Method::Teardown => { + send_response(&mut stream, &req, 200, "OK", vec![], "", &state.session_id) + .await?; + break 'client_loop; + } + _ => { + send_response( + &mut stream, + &req, + 405, + "Method Not Allowed", + vec![], + "", + &state.session_id, + ) + .await?; + } + } + } + } + + if cfg_snapshot.allow_one_client { + let mut active_guard = shared.active_client.lock().await; + if active_guard.as_ref().copied() == Some(peer) { + *active_guard = None; + } + } + + Ok(()) +} + +async fn stream_video_interleaved( + stream: TcpStream, + video_manager: &Arc, + rtsp_codec: RtspCodec, + channel: u8, + shared: SharedRtspState, + session_id: String, +) -> Result<()> { + let (mut reader, mut writer) = stream.into_split(); + + let mut rx = video_manager + .subscribe_encoded_frames() + .await + .ok_or_else(|| { + AppError::VideoError("RTSP failed to subscribe encoded frames".to_string()) + })?; + + video_manager.request_keyframe().await.ok(); + + let payload_type = match rtsp_codec { + RtspCodec::H264 => 96, + RtspCodec::H265 => 99, + }; + let mut sequence_number: u16 = rand::rng().random(); + let ssrc: u32 = rand::rng().random(); + + let mut h264_payloader = rtp::codecs::h264::H264Payloader::default(); + let mut h265_payloader = H265Payloader::new(); + let mut ctrl_read_buf = [0u8; RTSP_BUF_SIZE]; + let mut ctrl_buffer = Vec::with_capacity(RTSP_BUF_SIZE); + + loop { + tokio::select! { + maybe_frame = rx.recv() => { + let Some(frame) = maybe_frame else { + tracing::warn!("RTSP encoded frame subscription ended, attempting to restart pipeline"); + + if let Some(new_rx) = video_manager.subscribe_encoded_frames().await { + rx = new_rx; + let _ = video_manager.request_keyframe().await; + tracing::info!("RTSP frame subscription recovered"); + } else { + tracing::warn!( + "RTSP failed to resubscribe encoded frames, retrying in {}ms", + RTSP_RESUBSCRIBE_DELAY_MS + ); + sleep(Duration::from_millis(RTSP_RESUBSCRIBE_DELAY_MS)).await; + } + + continue; + }; + + if !is_frame_codec_match(&frame, &rtsp_codec) { + continue; + } + + { + let mut params = shared.parameter_sets.write().await; + update_parameter_sets(&mut params, &frame); + } + + let rtp_timestamp = pts_to_rtp_timestamp(frame.pts_ms); + + let payloads: Vec = match rtsp_codec { + RtspCodec::H264 => h264_payloader + .payload(RTP_MTU, &frame.data) + .map_err(|e| AppError::VideoError(format!("H264 payload failed: {}", e)))?, + RtspCodec::H265 => h265_payloader.payload(RTP_MTU, &frame.data), + }; + + if payloads.is_empty() { + continue; + } + + let total_payloads = payloads.len(); + for (idx, payload) in payloads.into_iter().enumerate() { + let marker = idx == total_payloads.saturating_sub(1); + let packet = Packet { + header: rtp::header::Header { + version: 2, + padding: false, + extension: false, + marker, + payload_type, + sequence_number, + timestamp: rtp_timestamp, + ssrc, + ..Default::default() + }, + payload, + }; + + sequence_number = sequence_number.wrapping_add(1); + send_interleaved_rtp(&mut writer, channel, &packet).await?; + } + + if frame.is_keyframe { + tracing::debug!("RTSP keyframe sent"); + } + } + read_res = reader.read(&mut ctrl_read_buf) => { + let n = read_res?; + if n == 0 { + break; + } + + ctrl_buffer.extend_from_slice(&ctrl_read_buf[..n]); + + while strip_interleaved_frames_prefix(&mut ctrl_buffer) {} + + while let Some(raw_req) = take_rtsp_request_from_buffer(&mut ctrl_buffer) { + let Some(req) = parse_rtsp_request(&raw_req) else { + continue; + }; + + if handle_play_control_request(&mut writer, &req, &session_id).await? { + return Ok(()); + } + + while strip_interleaved_frames_prefix(&mut ctrl_buffer) {} + } + } + } + } + + Ok(()) +} + +async fn send_interleaved_rtp( + stream: &mut W, + channel: u8, + packet: &Packet, +) -> Result<()> { + let marshaled = packet + .marshal() + .map_err(|e| AppError::VideoError(format!("RTP marshal failed: {}", e)))?; + let len = marshaled.len() as u16; + + let mut header = [0u8; 4]; + header[0] = b'$'; + header[1] = channel; + header[2] = (len >> 8) as u8; + header[3] = (len & 0xff) as u8; + + stream.write_all(&header).await?; + stream.write_all(&marshaled).await?; + Ok(()) +} + +async fn handle_play_control_request( + stream: &mut W, + req: &RtspRequest, + session_id: &str, +) -> Result { + match &req.method { + rtsp::Method::Teardown => { + send_response(stream, req, 200, "OK", vec![], "", session_id).await?; + Ok(true) + } + rtsp::Method::Options => { + send_response( + stream, + req, + 200, + "OK", + vec![( + "Public".to_string(), + "OPTIONS, DESCRIBE, SETUP, PLAY, GET_PARAMETER, SET_PARAMETER, TEARDOWN" + .to_string(), + )], + "", + session_id, + ) + .await?; + Ok(false) + } + rtsp::Method::GetParameter | rtsp::Method::SetParameter => { + send_response(stream, req, 200, "OK", vec![], "", session_id).await?; + Ok(false) + } + _ => { + send_response( + stream, + req, + 405, + "Method Not Allowed", + vec![], + "", + session_id, + ) + .await?; + Ok(false) + } + } +} + +fn strip_interleaved_frames_prefix(buffer: &mut Vec) -> bool { + if buffer.len() < 4 || buffer[0] != b'$' { + return false; + } + + let payload_len = u16::from_be_bytes([buffer[2], buffer[3]]) as usize; + let frame_len = 4 + payload_len; + if buffer.len() < frame_len { + return false; + } + + buffer.drain(0..frame_len); + true +} + +fn take_rtsp_request_from_buffer(buffer: &mut Vec) -> Option { + let delimiter = b"\r\n\r\n"; + let pos = find_bytes(buffer, delimiter)?; + let req_end = pos + delimiter.len(); + let req_bytes: Vec = buffer.drain(0..req_end).collect(); + Some(String::from_utf8_lossy(&req_bytes).to_string()) +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn parse_rtsp_request(raw: &str) -> Option { + let (message, consumed): (rtsp::Message>, usize) = + rtsp::Message::parse(raw.as_bytes()).ok()?; + if consumed != raw.len() { + return None; + } + + let request = match message { + rtsp::Message::Request(req) => req, + _ => return None, + }; + + let uri = request + .request_uri() + .map(|value| value.as_str().to_string()) + .unwrap_or_default(); + + let mut headers = HashMap::new(); + for (name, value) in request.headers() { + headers.insert(name.to_string().to_ascii_lowercase(), value.to_string()); + } + + Some(RtspRequest { + method: request.method().clone(), + uri, + version: request.version(), + headers, + }) +} + +fn extract_basic_auth(req: &RtspRequest) -> Option<(String, String)> { + let value = req.headers.get("authorization")?; + let mut parts = value.split_whitespace(); + let scheme = parts.next()?; + if !scheme.eq_ignore_ascii_case("basic") { + return None; + } + let b64 = parts.next()?; + let decoded = base64::engine::general_purpose::STANDARD.decode(b64).ok()?; + let raw = String::from_utf8(decoded).ok()?; + let (user, pass) = raw.split_once(':')?; + Some((user.to_string(), pass.to_string())) +} + +fn rtsp_auth_credentials(config: &RtspConfig) -> Option<(String, String)> { + let username = config.username.as_ref()?.trim(); + if username.is_empty() { + return None; + } + + Some(( + username.to_string(), + config.password.clone().unwrap_or_default(), + )) +} + +fn parse_interleaved_channel(transport: &str) -> Option { + let lower = transport.to_ascii_lowercase(); + if let Some((_, v)) = lower.split_once("interleaved=") { + let head = v.split(';').next().unwrap_or(v); + let first = head.split('-').next().unwrap_or(head).trim(); + return first.parse::().ok(); + } + None +} + +fn is_tcp_transport_request(transport: &str) -> bool { + transport + .split(',') + .map(str::trim) + .map(str::to_ascii_lowercase) + .any(|item| item.contains("rtp/avp/tcp") || item.contains("interleaved=")) +} + +fn update_parameter_sets(params: &mut ParameterSets, frame: &EncodedVideoFrame) { + let nal_units = split_annexb_nal_units(frame.data.as_ref()); + + match frame.codec { + VideoEncoderType::H264 => { + for nal in nal_units { + match h264_nal_type(nal) { + Some(7) => params.h264_sps = Some(Bytes::copy_from_slice(nal)), + Some(8) => params.h264_pps = Some(Bytes::copy_from_slice(nal)), + _ => {} + } + } + } + VideoEncoderType::H265 => { + for nal in nal_units { + match h265_nal_type(nal) { + Some(32) => params.h265_vps = Some(Bytes::copy_from_slice(nal)), + Some(33) => params.h265_sps = Some(Bytes::copy_from_slice(nal)), + Some(34) => params.h265_pps = Some(Bytes::copy_from_slice(nal)), + _ => {} + } + } + } + _ => {} + } +} + +fn split_annexb_nal_units(data: &[u8]) -> Vec<&[u8]> { + let mut nal_units = Vec::new(); + let mut cursor = 0usize; + + while let Some((start, start_code_len)) = find_annexb_start_code(data, cursor) { + let nal_start = start + start_code_len; + if nal_start >= data.len() { + break; + } + + let next_start = find_annexb_start_code(data, nal_start) + .map(|(idx, _)| idx) + .unwrap_or(data.len()); + + let mut nal_end = next_start; + while nal_end > nal_start && data[nal_end - 1] == 0 { + nal_end -= 1; + } + + if nal_end > nal_start { + nal_units.push(&data[nal_start..nal_end]); + } + + cursor = next_start; + } + + nal_units +} + +fn find_annexb_start_code(data: &[u8], from: usize) -> Option<(usize, usize)> { + if from >= data.len() { + return None; + } + + let mut i = from; + while i + 3 <= data.len() { + if i + 4 <= data.len() + && data[i] == 0 + && data[i + 1] == 0 + && data[i + 2] == 0 + && data[i + 3] == 1 + { + return Some((i, 4)); + } + + if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 { + return Some((i, 3)); + } + + i += 1; + } + + None +} + +fn h264_nal_type(nal: &[u8]) -> Option { + nal.first().map(|value| value & 0x1f) +} + +fn h265_nal_type(nal: &[u8]) -> Option { + nal.first().map(|value| (value >> 1) & 0x3f) +} + +fn build_h264_fmtp(payload_type: u8, params: &ParameterSets) -> String { + let mut attrs = vec!["packetization-mode=1".to_string()]; + + if let Some(sps) = params.h264_sps.as_ref() { + if let Some(profile_level_id) = parse_profile_level_id_from_sps(sps) { + attrs.push(format!("profile-level-id={}", profile_level_id)); + } + } else { + attrs.push("profile-level-id=42e01f".to_string()); + } + + if let (Some(sps), Some(pps)) = (params.h264_sps.as_ref(), params.h264_pps.as_ref()) { + let sps_b64 = base64::engine::general_purpose::STANDARD.encode(sps.as_ref()); + let pps_b64 = base64::engine::general_purpose::STANDARD.encode(pps.as_ref()); + attrs.push(format!("sprop-parameter-sets={},{}", sps_b64, pps_b64)); + } + + format!("{} {}", payload_type, attrs.join(";")) +} + +fn build_h265_fmtp(payload_type: u8, params: &ParameterSets) -> String { + let mut attrs = Vec::new(); + + if let Some(vps) = params.h265_vps.as_ref() { + attrs.push(format!( + "sprop-vps={}", + base64::engine::general_purpose::STANDARD.encode(vps.as_ref()) + )); + } + + if let Some(sps) = params.h265_sps.as_ref() { + attrs.push(format!( + "sprop-sps={}", + base64::engine::general_purpose::STANDARD.encode(sps.as_ref()) + )); + } + + if let Some(pps) = params.h265_pps.as_ref() { + attrs.push(format!( + "sprop-pps={}", + base64::engine::general_purpose::STANDARD.encode(pps.as_ref()) + )); + } + + if attrs.is_empty() { + format!("{} profile-id=1", payload_type) + } else { + format!("{} {}", payload_type, attrs.join(";")) + } +} + +fn build_sdp(config: &RtspConfig, codec: VideoCodecType, params: &ParameterSets) -> String { + let (payload_type, codec_name, fmtp_value) = match codec { + VideoCodecType::H264 => (96u8, "H264", build_h264_fmtp(96, params)), + VideoCodecType::H265 => (99u8, "H265", build_h265_fmtp(99, params)), + _ => (96u8, "H264", build_h264_fmtp(96, params)), + }; + + let session = sdp::Session { + origin: sdp::Origin { + username: Some("-".to_string()), + sess_id: "0".to_string(), + sess_version: 0, + nettype: "IN".to_string(), + addrtype: "IP4".to_string(), + unicast_address: config.bind.clone(), + }, + session_name: "One-KVM RTSP Stream".to_string(), + session_description: None, + uri: None, + emails: Vec::new(), + phones: Vec::new(), + connection: Some(sdp::Connection { + nettype: "IN".to_string(), + addrtype: "IP4".to_string(), + connection_address: "0.0.0.0".to_string(), + }), + bandwidths: Vec::new(), + times: vec![sdp::Time { + start_time: 0, + stop_time: 0, + repeats: Vec::new(), + }], + time_zones: Vec::new(), + key: None, + attributes: vec![sdp::Attribute { + attribute: "control".to_string(), + value: Some("*".to_string()), + }], + medias: vec![sdp::Media { + media: "video".to_string(), + port: 0, + num_ports: None, + proto: "RTP/AVP".to_string(), + fmt: payload_type.to_string(), + media_title: None, + connections: Vec::new(), + bandwidths: Vec::new(), + key: None, + attributes: vec![ + sdp::Attribute { + attribute: "rtpmap".to_string(), + value: Some(format!("{} {}/90000", payload_type, codec_name)), + }, + sdp::Attribute { + attribute: "fmtp".to_string(), + value: Some(fmtp_value), + }, + sdp::Attribute { + attribute: "control".to_string(), + value: Some("trackID=0".to_string()), + }, + ], + }], + }; + + let mut output = Vec::new(); + if let Err(err) = session.write(&mut output) { + tracing::warn!("Failed to serialize SDP with sdp-types: {}", err); + return String::new(); + } + + match String::from_utf8(output) { + Ok(sdp_text) => sdp_text, + Err(err) => { + tracing::warn!("Failed to convert SDP bytes to UTF-8: {}", err); + String::new() + } + } +} + +async fn send_simple_response( + stream: &mut W, + code: u16, + _reason: &str, + cseq: Option<&str>, + body: &str, +) -> Result<()> { + let mut builder = rtsp::Response::builder(rtsp::Version::V1_0, status_code_from_u16(code)); + if let Some(cseq) = cseq { + builder = builder.header(rtsp::headers::CSEQ, cseq); + } + + let response = builder.build(body.as_bytes().to_vec()); + + let mut data = Vec::new(); + response + .write(&mut data) + .map_err(|e| AppError::BadRequest(format!("failed to serialize RTSP response: {}", e)))?; + stream.write_all(&data).await?; + Ok(()) +} + +async fn send_response( + stream: &mut W, + req: &RtspRequest, + code: u16, + _reason: &str, + extra_headers: Vec<(String, String)>, + body: &str, + session_id: &str, +) -> Result<()> { + let cseq = req + .headers + .get("cseq") + .cloned() + .unwrap_or_else(|| "1".to_string()); + + let mut builder = rtsp::Response::builder(req.version, status_code_from_u16(code)) + .header(rtsp::headers::CSEQ, cseq.as_str()); + + if !session_id.is_empty() { + builder = builder.header(rtsp::headers::SESSION, session_id); + } + + for (name, value) in extra_headers { + let header_name = rtsp::HeaderName::try_from(name.as_str()).map_err(|e| { + AppError::BadRequest(format!("invalid RTSP header name {}: {}", name, e)) + })?; + builder = builder.header(header_name, value); + } + + let response = builder.build(body.as_bytes().to_vec()); + + let mut data = Vec::new(); + response + .write(&mut data) + .map_err(|e| AppError::BadRequest(format!("failed to serialize RTSP response: {}", e)))?; + stream.write_all(&data).await?; + Ok(()) +} + +fn status_code_from_u16(code: u16) -> rtsp::StatusCode { + match code { + 200 => rtsp::StatusCode::Ok, + 400 => rtsp::StatusCode::BadRequest, + 401 => rtsp::StatusCode::Unauthorized, + 404 => rtsp::StatusCode::NotFound, + 405 => rtsp::StatusCode::MethodNotAllowed, + 453 => rtsp::StatusCode::NotEnoughBandwidth, + 455 => rtsp::StatusCode::MethodNotValidInThisState, + 461 => rtsp::StatusCode::UnsupportedTransport, + _ => rtsp::StatusCode::InternalServerError, + } +} + +fn is_valid_rtsp_path(method: &rtsp::Method, uri: &str, configured_path: &str) -> bool { + if matches!(method, rtsp::Method::Options) && uri.trim() == "*" { + return true; + } + + let normalized_cfg = configured_path.trim_matches('/'); + if normalized_cfg.is_empty() { + return false; + } + + let request_path = extract_rtsp_path(uri); + + if request_path == normalized_cfg { + return true; + } + + if !matches!(method, rtsp::Method::Setup | rtsp::Method::Teardown) { + return false; + } + + let control_track_path = format!("{}/trackID=0", normalized_cfg); + request_path == "trackID=0" || request_path == control_track_path +} + +fn extract_rtsp_path(uri: &str) -> String { + let raw_path = if let Some((_, remainder)) = uri.split_once("://") { + match remainder.find('/') { + Some(idx) => &remainder[idx..], + None => "/", + } + } else { + uri + }; + + raw_path + .split('?') + .next() + .unwrap_or(raw_path) + .split('#') + .next() + .unwrap_or(raw_path) + .trim_matches('/') + .to_string() +} + +fn is_frame_codec_match(frame: &EncodedVideoFrame, codec: &RtspCodec) -> bool { + matches!( + (frame.codec, codec), + ( + crate::video::encoder::registry::VideoEncoderType::H264, + RtspCodec::H264 + ) | ( + crate::video::encoder::registry::VideoEncoderType::H265, + RtspCodec::H265 + ) + ) +} + +fn pts_to_rtp_timestamp(pts_ms: i64) -> u32 { + if pts_ms <= 0 { + return 0; + } + ((pts_ms as u64 * RTP_CLOCK_RATE as u64) / 1000) as u32 +} + +fn generate_session_id() -> String { + let mut rng = rand::rng(); + let value: u64 = rng.random(); + format!("{:016x}", value) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{duplex, AsyncReadExt}; + + fn make_test_request(method: rtsp::Method) -> RtspRequest { + let mut headers = HashMap::new(); + headers.insert("cseq".to_string(), "7".to_string()); + RtspRequest { + method, + uri: "rtsp://127.0.0.1/live".to_string(), + version: rtsp::Version::V1_0, + headers, + } + } + + async fn read_response_from_duplex( + mut client: tokio::io::DuplexStream, + ) -> rtsp::Response> { + let mut buf = vec![0u8; 4096]; + let n = client + .read(&mut buf) + .await + .expect("failed to read rtsp response"); + assert!(n > 0); + let (message, consumed): (rtsp::Message>, usize) = + rtsp::Message::parse(&buf[..n]).expect("failed to parse rtsp response"); + assert_eq!(consumed, n); + + match message { + rtsp::Message::Response(response) => response, + _ => panic!("expected RTSP response"), + } + } + + #[tokio::test] + async fn play_control_teardown_returns_ok_and_stop() { + let req = make_test_request(rtsp::Method::Teardown); + let (client, mut server) = duplex(4096); + + let should_stop = handle_play_control_request(&mut server, &req, "session-1") + .await + .expect("control handling failed"); + assert!(should_stop); + + drop(server); + let response = read_response_from_duplex(client).await; + assert_eq!(response.status(), rtsp::StatusCode::Ok); + } + + #[tokio::test] + async fn play_control_pause_returns_method_not_allowed() { + let req = make_test_request(rtsp::Method::Pause); + let (client, mut server) = duplex(4096); + + let should_stop = handle_play_control_request(&mut server, &req, "session-1") + .await + .expect("control handling failed"); + assert!(!should_stop); + + drop(server); + let response = read_response_from_duplex(client).await; + assert_eq!(response.status(), rtsp::StatusCode::MethodNotAllowed); + } + + #[test] + fn build_sdp_h264_is_parseable_with_expected_video_attributes() { + let config = RtspConfig::default(); + let mut params = ParameterSets::default(); + params.h264_sps = Some(Bytes::from_static(&[0x67, 0x42, 0xe0, 0x1f, 0x96, 0x54])); + params.h264_pps = Some(Bytes::from_static(&[0x68, 0xce, 0x06, 0xe2])); + + let sdp_text = build_sdp(&config, VideoCodecType::H264, ¶ms); + assert!(!sdp_text.is_empty()); + + let session = sdp::Session::parse(sdp_text.as_bytes()).expect("sdp parse failed"); + assert_eq!(session.session_name, "One-KVM RTSP Stream"); + assert_eq!(session.medias.len(), 1); + + let media = &session.medias[0]; + assert_eq!(media.media, "video"); + assert_eq!(media.proto, "RTP/AVP"); + assert_eq!(media.fmt, "96"); + + let has_rtpmap = media.attributes.iter().any(|attr| { + attr.attribute == "rtpmap" && attr.value.as_deref() == Some("96 H264/90000") + }); + assert!(has_rtpmap); + + let fmtp_value = media + .attributes + .iter() + .find(|attr| attr.attribute == "fmtp") + .and_then(|attr| attr.value.as_deref()) + .expect("missing fmtp value"); + assert!(fmtp_value.starts_with("96 ")); + assert!(fmtp_value.contains("packetization-mode=1")); + assert!(fmtp_value.contains("sprop-parameter-sets=")); + } + + #[test] + fn rtsp_path_matching_follows_sdp_control_rules() { + assert!(is_valid_rtsp_path( + &rtsp::Method::Describe, + "rtsp://127.0.0.1/live", + "live" + )); + assert!(is_valid_rtsp_path( + &rtsp::Method::Describe, + "rtsp://127.0.0.1/live/?token=1", + "/live/" + )); + assert!(!is_valid_rtsp_path( + &rtsp::Method::Describe, + "rtsp://127.0.0.1/live2", + "live" + )); + assert!(!is_valid_rtsp_path( + &rtsp::Method::Describe, + "rtsp://127.0.0.1/", + "/" + )); + + assert!(is_valid_rtsp_path( + &rtsp::Method::Setup, + "rtsp://127.0.0.1/live/trackID=0", + "live" + )); + assert!(is_valid_rtsp_path( + &rtsp::Method::Setup, + "rtsp://127.0.0.1/trackID=0", + "live" + )); + assert!(!is_valid_rtsp_path( + &rtsp::Method::Describe, + "rtsp://127.0.0.1/live/trackID=0", + "live" + )); + + assert!(is_valid_rtsp_path(&rtsp::Method::Options, "*", "live")); + } + + #[test] + fn transport_parsing_detects_tcp_interleaved_requests() { + assert!(is_tcp_transport_request( + "RTP/AVP/TCP;unicast;interleaved=0-1" + )); + assert!(is_tcp_transport_request("RTP/AVP;unicast;interleaved=2-3")); + assert!(!is_tcp_transport_request( + "RTP/AVP;unicast;client_port=8000-8001" + )); + } + + #[test] + fn build_sdp_h265_is_parseable_with_expected_video_attributes() { + let config = RtspConfig::default(); + let mut params = ParameterSets::default(); + params.h265_vps = Some(Bytes::from_static(&[0x40, 0x01, 0x0c, 0x01])); + params.h265_sps = Some(Bytes::from_static(&[0x42, 0x01, 0x01, 0x60])); + params.h265_pps = Some(Bytes::from_static(&[0x44, 0x01, 0xc0, 0x73])); + + let sdp_text = build_sdp(&config, VideoCodecType::H265, ¶ms); + assert!(!sdp_text.is_empty()); + + let session = sdp::Session::parse(sdp_text.as_bytes()).expect("sdp parse failed"); + assert_eq!(session.medias.len(), 1); + + let media = &session.medias[0]; + assert_eq!(media.media, "video"); + assert_eq!(media.proto, "RTP/AVP"); + assert_eq!(media.fmt, "99"); + + let has_rtpmap = media.attributes.iter().any(|attr| { + attr.attribute == "rtpmap" && attr.value.as_deref() == Some("99 H265/90000") + }); + assert!(has_rtpmap); + + let fmtp_value = media + .attributes + .iter() + .find(|attr| attr.attribute == "fmtp") + .and_then(|attr| attr.value.as_deref()) + .expect("missing fmtp value"); + assert!(fmtp_value.starts_with("99 ")); + assert!(fmtp_value.contains("sprop-vps=")); + assert!(fmtp_value.contains("sprop-sps=")); + assert!(fmtp_value.contains("sprop-pps=")); + } + + #[test] + fn rtsp_auth_requires_non_empty_username() { + let mut config = RtspConfig::default(); + config.password = Some("secret".to_string()); + assert!(rtsp_auth_credentials(&config).is_none()); + + config.username = Some("".to_string()); + assert!(rtsp_auth_credentials(&config).is_none()); + + config.username = Some("user".to_string()); + let credentials = rtsp_auth_credentials(&config).expect("expected credentials"); + assert_eq!(credentials, ("user".to_string(), "secret".to_string())); + + config.password = None; + let credentials = rtsp_auth_credentials(&config).expect("expected credentials"); + assert_eq!(credentials, ("user".to_string(), "".to_string())); + } +} diff --git a/src/rustdesk/bytes_codec.rs b/src/rustdesk/bytes_codec.rs index a592f7f5..18f163ca 100644 --- a/src/rustdesk/bytes_codec.rs +++ b/src/rustdesk/bytes_codec.rs @@ -50,7 +50,7 @@ fn decode_header(first_byte: u8, header_bytes: &[u8]) -> (usize, usize) { let head_len = ((first_byte & 0x3) + 1) as usize; let mut n = first_byte as usize; - if head_len > 1 && header_bytes.len() >= 1 { + if head_len > 1 && !header_bytes.is_empty() { n |= (header_bytes[0] as usize) << 8; } if head_len > 2 && header_bytes.len() >= 2 { diff --git a/src/rustdesk/config.rs b/src/rustdesk/config.rs index 3a72e792..c8c64f29 100644 --- a/src/rustdesk/config.rs +++ b/src/rustdesk/config.rs @@ -202,9 +202,11 @@ mod tests { #[test] fn test_rendezvous_addr() { - let mut config = RustDeskConfig::default(); + let mut config = RustDeskConfig { + rendezvous_server: "example.com".to_string(), + ..Default::default() + }; - config.rendezvous_server = "example.com".to_string(); assert_eq!(config.rendezvous_addr(), "example.com:21116"); config.rendezvous_server = "example.com:21116".to_string(); @@ -217,10 +219,12 @@ mod tests { #[test] fn test_relay_addr() { - let mut config = RustDeskConfig::default(); + let mut config = RustDeskConfig { + rendezvous_server: "example.com".to_string(), + ..Default::default() + }; // Rendezvous server configured, relay defaults to same host - config.rendezvous_server = "example.com".to_string(); assert_eq!(config.relay_addr(), Some("example.com:21117".to_string())); // Explicit relay server @@ -238,10 +242,12 @@ mod tests { #[test] fn test_effective_rendezvous_server() { - let mut config = RustDeskConfig::default(); + let mut config = RustDeskConfig { + rendezvous_server: "custom.example.com".to_string(), + ..Default::default() + }; // When user sets a server, use it - config.rendezvous_server = "custom.example.com".to_string(); assert_eq!(config.effective_rendezvous_server(), "custom.example.com"); // When empty, returns empty diff --git a/src/rustdesk/connection.rs b/src/rustdesk/connection.rs index b408ed12..83b41f36 100644 --- a/src/rustdesk/connection.rs +++ b/src/rustdesk/connection.rs @@ -23,6 +23,9 @@ use tracing::{debug, error, info, warn}; use crate::audio::AudioController; use crate::hid::{HidController, KeyEventType, KeyboardEvent, KeyboardModifiers}; +use crate::video::codec_constraints::{ + encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec, +}; use crate::video::encoder::registry::{EncoderRegistry, VideoEncoderType}; use crate::video::encoder::BitratePreset; use crate::video::stream_manager::VideoStreamManager; @@ -627,7 +630,7 @@ impl Connection { // Select the best available video codec // Priority: H264 > H265 > VP8 > VP9 (H264/H265 leverage hardware encoding) - let negotiated = self.negotiate_video_codec(); + let negotiated = self.negotiate_video_codec().await; self.negotiated_codec = Some(negotiated); info!("Negotiated video codec: {:?}", negotiated); @@ -641,28 +644,51 @@ impl Connection { /// Negotiate video codec - select the best available encoder /// Priority: H264 > H265 > VP8 > VP9 (H264/H265 leverage hardware encoding on embedded devices) - fn negotiate_video_codec(&self) -> VideoEncoderType { + async fn negotiate_video_codec(&self) -> VideoEncoderType { let registry = EncoderRegistry::global(); + let constraints = self.current_codec_constraints().await; // Check availability in priority order // H264 is preferred because it has the best hardware encoder support (RKMPP, VAAPI, etc.) // and most RustDesk clients support H264 hardware decoding - if registry.is_format_available(VideoEncoderType::H264, false) { + if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264) + && registry.is_format_available(VideoEncoderType::H264, false) + { return VideoEncoderType::H264; } - if registry.is_format_available(VideoEncoderType::H265, false) { + if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265) + && registry.is_format_available(VideoEncoderType::H265, false) + { return VideoEncoderType::H265; } - if registry.is_format_available(VideoEncoderType::VP8, false) { + if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8) + && registry.is_format_available(VideoEncoderType::VP8, false) + { return VideoEncoderType::VP8; } - if registry.is_format_available(VideoEncoderType::VP9, false) { + if constraints.is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9) + && registry.is_format_available(VideoEncoderType::VP9, false) + { return VideoEncoderType::VP9; } - // Fallback to H264 (should be available via hardware or software encoder) - warn!("No video encoder available, defaulting to H264"); - VideoEncoderType::H264 + // Fallback to preferred allowed codec + let preferred = constraints.preferred_webrtc_codec(); + warn!( + "No allowed encoder available in priority order, falling back to {}", + encoder_codec_to_id(video_codec_to_encoder_codec(preferred)) + ); + video_codec_to_encoder_codec(preferred) + } + + async fn current_codec_constraints( + &self, + ) -> crate::video::codec_constraints::StreamCodecConstraints { + if let Some(ref video_manager) = self.video_manager { + video_manager.codec_constraints().await + } else { + crate::video::codec_constraints::StreamCodecConstraints::unrestricted() + } } /// Handle misc message with Arc writer @@ -729,7 +755,7 @@ impl Connection { } // Check if client sent supported_decoding with a codec preference - if let Some(ref supported_decoding) = opt.supported_decoding.as_ref() { + if let Some(supported_decoding) = opt.supported_decoding.as_ref() { let prefer = supported_decoding.prefer.value(); debug!("Client codec preference: prefer={}", prefer); @@ -747,6 +773,16 @@ impl Connection { if let Some(new_codec) = requested_codec { // Check if this codec is different from current and available if self.negotiated_codec != Some(new_codec) { + let constraints = self.current_codec_constraints().await; + if !constraints.is_webrtc_codec_allowed(encoder_codec_to_video_codec(new_codec)) + { + warn!( + "Client requested codec {:?} but it's blocked by constraints: {}", + new_codec, constraints.reason + ); + return Ok(()); + } + let registry = EncoderRegistry::global(); if registry.is_format_available(new_codec, false) { info!( @@ -1080,12 +1116,21 @@ impl Connection { if success { // Dynamically detect available encoders let registry = EncoderRegistry::global(); + let constraints = self.current_codec_constraints().await; // Check which encoders are available (include software fallback) - let h264_available = registry.is_format_available(VideoEncoderType::H264, false); - let h265_available = registry.is_format_available(VideoEncoderType::H265, false); - let vp8_available = registry.is_format_available(VideoEncoderType::VP8, false); - let vp9_available = registry.is_format_available(VideoEncoderType::VP9, false); + let h264_available = constraints + .is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H264) + && registry.is_format_available(VideoEncoderType::H264, false); + let h265_available = constraints + .is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::H265) + && registry.is_format_available(VideoEncoderType::H265, false); + let vp8_available = constraints + .is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP8) + && registry.is_format_available(VideoEncoderType::VP8, false); + let vp9_available = constraints + .is_webrtc_codec_allowed(crate::video::encoder::VideoCodecType::VP9) + && registry.is_format_available(VideoEncoderType::VP9, false); info!( "Server encoding capabilities: H264={}, H265={}, VP8={}, VP9={}", @@ -1352,8 +1397,12 @@ impl Connection { debug!("Mouse event: x={}, y={}, mask={}", me.x, me.y, me.mask); // Convert RustDesk mouse event to One-KVM mouse events - let mouse_events = - convert_mouse_event(me, self.screen_width, self.screen_height, self.relative_mouse_active); + let mouse_events = convert_mouse_event( + me, + self.screen_width, + self.screen_height, + self.relative_mouse_active, + ); // Send to HID controller if available if let Some(ref hid) = self.hid { @@ -1616,7 +1665,10 @@ async fn run_video_streaming( ); } if let Err(e) = video_manager.request_keyframe().await { - debug!("Failed to request keyframe for connection {}: {}", conn_id, e); + debug!( + "Failed to request keyframe for connection {}: {}", + conn_id, e + ); } // Inner loop: receives frames from current subscription diff --git a/src/rustdesk/crypto.rs b/src/rustdesk/crypto.rs index 10860402..88b1257f 100644 --- a/src/rustdesk/crypto.rs +++ b/src/rustdesk/crypto.rs @@ -189,7 +189,7 @@ pub fn hash_password_double(password: &str, salt: &str, challenge: &str) -> Vec< // Second hash: SHA256(first_hash + challenge) let mut hasher2 = Sha256::new(); - hasher2.update(&first_hash); + hasher2.update(first_hash); hasher2.update(challenge.as_bytes()); hasher2.finalize().to_vec() } diff --git a/src/rustdesk/frame_adapters.rs b/src/rustdesk/frame_adapters.rs index fbee2c1e..35785ca9 100644 --- a/src/rustdesk/frame_adapters.rs +++ b/src/rustdesk/frame_adapters.rs @@ -127,7 +127,7 @@ impl VideoFrameAdapter { // Inject cached SPS/PPS before IDR when missing if is_keyframe && (!has_sps || !has_pps) { - if let (Some(ref sps), Some(ref pps)) = (self.h264_sps.as_ref(), self.h264_pps.as_ref()) { + if let (Some(sps), Some(pps)) = (self.h264_sps.as_ref(), self.h264_pps.as_ref()) { let mut out = Vec::with_capacity(8 + sps.len() + pps.len() + data.len()); out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(sps); diff --git a/src/rustdesk/mod.rs b/src/rustdesk/mod.rs index 5b636497..128337e1 100644 --- a/src/rustdesk/mod.rs +++ b/src/rustdesk/mod.rs @@ -36,8 +36,8 @@ use tracing::{debug, error, info, warn}; use crate::audio::AudioController; use crate::hid::HidController; -use crate::video::stream_manager::VideoStreamManager; use crate::utils::bind_tcp_listener; +use crate::video::stream_manager::VideoStreamManager; use self::config::RustDeskConfig; use self::connection::ConnectionManager; @@ -559,6 +559,7 @@ impl RustDeskService { /// 2. Send RelayResponse with client's socket_addr /// 3. Connect to RELAY server /// 4. Accept connection without waiting for response +#[allow(clippy::too_many_arguments)] async fn handle_relay_request( rendezvous_addr: &str, relay_server: &str, diff --git a/src/rustdesk/rendezvous.rs b/src/rustdesk/rendezvous.rs index d347f81f..7699dfca 100644 --- a/src/rustdesk/rendezvous.rs +++ b/src/rustdesk/rendezvous.rs @@ -536,6 +536,10 @@ impl RendezvousMediator { } } Some(rendezvous_message::Union::PunchHole(ph)) => { + let config = self.config.read().clone(); + let effective_relay_server = + select_relay_server(config.relay_server.as_deref(), &ph.relay_server); + // Decode the peer's socket address let peer_addr = if !ph.socket_addr.is_empty() { AddrMangle::decode(&ph.socket_addr) @@ -544,8 +548,12 @@ impl RendezvousMediator { }; info!( - "Received PunchHole request: peer_addr={:?}, socket_addr_len={}, relay_server={}, nat_type={:?}", - peer_addr, ph.socket_addr.len(), ph.relay_server, ph.nat_type + "Received PunchHole request: peer_addr={:?}, socket_addr_len={}, relay_server={}, effective_relay_server={}, nat_type={:?}", + peer_addr, + ph.socket_addr.len(), + ph.relay_server, + effective_relay_server.as_deref().unwrap_or(""), + ph.nat_type ); // Send PunchHoleSent to acknowledge @@ -555,13 +563,19 @@ impl RendezvousMediator { info!( "Sending PunchHoleSent: id={}, peer_addr={:?}, relay_server={}", - id, peer_addr, ph.relay_server + id, + peer_addr, + effective_relay_server + .as_deref() + .unwrap_or(ph.relay_server.as_str()) ); let msg = make_punch_hole_sent( - &ph.socket_addr.to_vec(), // Use peer's socket_addr, not ours + &ph.socket_addr, // Use peer's socket_addr, not ours &id, - &ph.relay_server, + effective_relay_server + .as_deref() + .unwrap_or(ph.relay_server.as_str()), ph.nat_type.enum_value().unwrap_or(NatType::UNKNOWN_NAT), env!("CARGO_PKG_VERSION"), ); @@ -573,16 +587,10 @@ impl RendezvousMediator { } // Try P2P direct connection first, fall back to relay if needed - if !ph.relay_server.is_empty() { - let relay_server = if ph.relay_server.contains(':') { - ph.relay_server.clone() - } else { - format!("{}:21117", ph.relay_server) - }; + if let Some(relay_server) = effective_relay_server { // Generate a standard UUID v4 for relay pairing // This must match the format used by RustDesk client let uuid = uuid::Uuid::new_v4().to_string(); - let config = self.config.read().clone(); let rendezvous_addr = config.rendezvous_addr(); let device_id = config.device_id.clone(); @@ -606,41 +614,56 @@ impl RendezvousMediator { device_id, ); } + } else { + debug!("No relay server available for PunchHole, skipping relay fallback"); } } Some(rendezvous_message::Union::RequestRelay(rr)) => { + let config = self.config.read().clone(); + let effective_relay_server = + select_relay_server(config.relay_server.as_deref(), &rr.relay_server); + info!( - "Received RequestRelay: relay_server={}, uuid={}, secure={}", - rr.relay_server, rr.uuid, rr.secure + "Received RequestRelay: relay_server={}, effective_relay_server={}, uuid={}, secure={}", + rr.relay_server, + effective_relay_server.as_deref().unwrap_or(""), + rr.uuid, + rr.secure ); // Call the relay callback to handle the connection if let Some(callback) = self.relay_callback.read().as_ref() { - let relay_server = if rr.relay_server.contains(':') { - rr.relay_server.clone() + if let Some(relay_server) = effective_relay_server { + let rendezvous_addr = config.rendezvous_addr(); + let device_id = config.device_id.clone(); + callback( + rendezvous_addr, + relay_server, + rr.uuid.clone(), + rr.socket_addr.to_vec(), + device_id, + ); } else { - format!("{}:21117", rr.relay_server) - }; - let config = self.config.read().clone(); - let rendezvous_addr = config.rendezvous_addr(); - let device_id = config.device_id.clone(); - callback( - rendezvous_addr, - relay_server, - rr.uuid.clone(), - rr.socket_addr.to_vec(), - device_id, - ); + debug!("No relay server available for RequestRelay callback"); + } } } Some(rendezvous_message::Union::FetchLocalAddr(fla)) => { + let config = self.config.read().clone(); + let effective_relay_server = + select_relay_server(config.relay_server.as_deref(), &fla.relay_server) + .unwrap_or_default(); + // Decode the peer address for logging let peer_addr = AddrMangle::decode(&fla.socket_addr); info!( - "Received FetchLocalAddr request: peer_addr={:?}, socket_addr_len={}, relay_server={}", - peer_addr, fla.socket_addr.len(), fla.relay_server + "Received FetchLocalAddr request: peer_addr={:?}, socket_addr_len={}, relay_server={}, effective_relay_server={}", + peer_addr, + fla.socket_addr.len(), + fla.relay_server, + effective_relay_server ); // Respond with our local address for same-LAN direct connection - self.send_local_addr(socket, &fla.socket_addr, &fla.relay_server) + self.send_local_addr(socket, &fla.socket_addr, &effective_relay_server) .await?; } Some(rendezvous_message::Union::ConfigureUpdate(cu)) => { @@ -692,6 +715,25 @@ impl RendezvousMediator { /// This encoding mangles the address to avoid detection. pub struct AddrMangle; +fn normalize_relay_server(server: &str) -> Option { + let trimmed = server.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.contains(':') { + Some(trimmed.to_string()) + } else { + Some(format!("{}:21117", trimmed)) + } +} + +fn select_relay_server(local_relay: Option<&str>, server_relay: &str) -> Option { + local_relay + .and_then(normalize_relay_server) + .or_else(|| normalize_relay_server(server_relay)) +} + impl AddrMangle { /// Encode a SocketAddr to bytes using RustDesk's mangle algorithm pub fn encode(addr: SocketAddr) -> Vec { @@ -876,3 +918,47 @@ fn get_local_addresses() -> Vec { addrs } + +#[cfg(test)] +mod tests { + use super::{normalize_relay_server, select_relay_server}; + + #[test] + fn test_normalize_relay_server() { + assert_eq!(normalize_relay_server(""), None); + assert_eq!(normalize_relay_server(" "), None); + assert_eq!( + normalize_relay_server("relay.example.com"), + Some("relay.example.com:21117".to_string()) + ); + assert_eq!( + normalize_relay_server("relay.example.com:22117"), + Some("relay.example.com:22117".to_string()) + ); + } + + #[test] + fn test_select_relay_server_prefers_local() { + assert_eq!( + select_relay_server(Some("local.example.com:21117"), "server.example.com:21117"), + Some("local.example.com:21117".to_string()) + ); + + assert_eq!( + select_relay_server(Some("local.example.com"), "server.example.com:21117"), + Some("local.example.com:21117".to_string()) + ); + + assert_eq!( + select_relay_server(Some(" "), "server.example.com"), + Some("server.example.com:21117".to_string()) + ); + + assert_eq!( + select_relay_server(None, "server.example.com:21117"), + Some("server.example.com:21117".to_string()) + ); + + assert_eq!(select_relay_server(None, ""), None); + } +} diff --git a/src/state.rs b/src/state.rs index b322f7ed..18f4ead1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -13,7 +13,9 @@ use crate::extensions::ExtensionManager; use crate::hid::HidController; use crate::msd::MsdController; use crate::otg::OtgService; +use crate::rtsp::RtspService; use crate::rustdesk::RustDeskService; +use crate::update::UpdateService; use crate::video::VideoStreamManager; /// Application-wide state shared across handlers @@ -50,10 +52,14 @@ pub struct AppState { pub audio: Arc, /// RustDesk remote access service (optional) pub rustdesk: Arc>>>, + /// RTSP streaming service (optional) + pub rtsp: Arc>>>, /// Extension manager (ttyd, gostc, easytier) pub extensions: Arc, /// Event bus for real-time notifications pub events: Arc, + /// Online update service + pub update: Arc, /// Shutdown signal sender pub shutdown_tx: broadcast::Sender<()>, /// Recently revoked session IDs (for client kick detection) @@ -64,6 +70,7 @@ pub struct AppState { impl AppState { /// Create new application state + #[allow(clippy::too_many_arguments)] pub fn new( config: ConfigStore, sessions: SessionStore, @@ -75,8 +82,10 @@ impl AppState { atx: Option, audio: Arc, rustdesk: Option>, + rtsp: Option>, extensions: Arc, events: Arc, + update: Arc, shutdown_tx: broadcast::Sender<()>, data_dir: std::path::PathBuf, ) -> Arc { @@ -91,8 +100,10 @@ impl AppState { atx: Arc::new(RwLock::new(atx)), audio, rustdesk: Arc::new(RwLock::new(rustdesk)), + rtsp: Arc::new(RwLock::new(rtsp)), extensions, events, + update, shutdown_tx, revoked_sessions: Arc::new(RwLock::new(VecDeque::new())), data_dir, diff --git a/src/stream/mjpeg_streamer.rs b/src/stream/mjpeg_streamer.rs index 79e0fb38..6fbd7379 100644 --- a/src/stream/mjpeg_streamer.rs +++ b/src/stream/mjpeg_streamer.rs @@ -15,18 +15,16 @@ //! //! 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 v4l::buffer::Type as BufferType; -use v4l::io::traits::CaptureStream; -use v4l::prelude::*; -use v4l::video::Capture; -use v4l::video::capture::Parameters; -use v4l::Format; use crate::audio::AudioController; use crate::error::{AppError, Result}; @@ -491,8 +489,7 @@ impl MjpegStreamer { } }; - let mut device_opt: Option = None; - let mut format_opt: Option = None; + let mut stream_opt: Option = None; let mut last_error: Option = None; for attempt in 0..MAX_RETRIES { @@ -501,8 +498,18 @@ impl MjpegStreamer { return; } - let device = match Device::with_path(&device_path) { - Ok(d) => d, + 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") { @@ -519,42 +526,12 @@ impl MjpegStreamer { last_error = Some(err_str); break; } - }; - - let requested = Format::new( - config.resolution.width, - config.resolution.height, - config.format.to_fourcc(), - ); - - match device.set_format(&requested) { - Ok(actual) => { - device_opt = Some(device); - format_opt = Some(actual); - break; - } - Err(e) => { - let err_str = e.to_string(); - if err_str.contains("busy") || err_str.contains("resource") { - warn!( - "Device busy on set_format 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 (device, actual_format) = match (device_opt, format_opt) { - (Some(d), Some(f)) => (d, f), - _ => { + let mut stream = match stream_opt { + Some(stream) => stream, + None => { error!( "Failed to open device {:?}: {}", device_path, @@ -567,40 +544,36 @@ impl MjpegStreamer { } }; + let resolution = stream.resolution(); + let pixel_format = stream.format(); + let stride = stream.stride(); + info!( "Capture format: {}x{} {:?} stride={}", - actual_format.width, actual_format.height, actual_format.fourcc, actual_format.stride + resolution.width, resolution.height, pixel_format, stride ); - let resolution = Resolution::new(actual_format.width, actual_format.height); - let pixel_format = - PixelFormat::from_fourcc(actual_format.fourcc).unwrap_or(config.format); - - if config.fps > 0 { - if let Err(e) = device.set_params(&Parameters::with_fps(config.fps)) { - warn!("Failed to set hardware FPS: {}", e); - } - } - - let mut stream = match MmapStream::with_buffers(&device, BufferType::VideoCapture, 4) { - Ok(s) => s, - Err(e) => { - error!("Failed to create capture stream: {}", e); - set_state(MjpegStreamerState::Error); - self.mjpeg_handler.set_offline(); - self.direct_active.store(false, Ordering::SeqCst); - return; - } - }; - let buffer_pool = Arc::new(FrameBufferPool::new(8)); let mut signal_present = true; - let mut sequence: u64 = 0; let mut validate_counter: u64 = 0; + let capture_error_throttler = LogThrottler::with_secs(5); + let mut suppressed_capture_errors: HashMap = 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 (buf, meta) = match stream.next() { - Ok(frame_data) => frame_data, + 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 { @@ -628,35 +601,43 @@ impl MjpegStreamer { return; } - error!("Capture error: {}", e); + 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.bytesused as usize; + 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 % JPEG_VALIDATE_INTERVAL == 0 - && !VideoFrame::is_valid_jpeg_bytes(&buf[..frame_size]) + && validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL) + && !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size]) { continue; } - let mut owned = buffer_pool.take(frame_size); - owned.resize(frame_size, 0); - owned[..frame_size].copy_from_slice(&buf[..frame_size]); + owned.truncate(frame_size); let frame = VideoFrame::from_pooled( Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))), resolution, pixel_format, - actual_format.stride, - sequence, + stride, + meta.sequence, ); - sequence = sequence.wrapping_add(1); if !signal_present { signal_present = true; diff --git a/src/update/mod.rs b/src/update/mod.rs new file mode 100644 index 00000000..78084d0a --- /dev/null +++ b/src/update/mod.rs @@ -0,0 +1,606 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{broadcast, RwLock, Semaphore}; + +use crate::error::{AppError, Result}; + +const DEFAULT_UPDATE_BASE_URL: &str = "https://update.one-kvm.cn"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum UpdateChannel { + Stable, + Beta, +} + +impl Default for UpdateChannel { + fn default() -> Self { + Self::Stable + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelsManifest { + pub stable: String, + pub beta: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleasesManifest { + pub releases: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseInfo { + pub version: String, + pub channel: UpdateChannel, + pub published_at: String, + #[serde(default)] + pub notes: Vec, + #[serde(default)] + pub artifacts: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtifactInfo { + pub url: String, + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseNotesItem { + pub version: String, + pub published_at: String, + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateOverviewResponse { + pub success: bool, + pub current_version: String, + pub channel: UpdateChannel, + pub latest_version: String, + pub upgrade_available: bool, + pub target_version: Option, + pub notes_between: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpgradeRequest { + pub channel: Option, + pub target_version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum UpdatePhase { + Idle, + Checking, + Downloading, + Verifying, + Installing, + Restarting, + Success, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateStatusResponse { + pub success: bool, + pub phase: UpdatePhase, + pub progress: u8, + pub current_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +pub struct UpdateService { + client: reqwest::Client, + base_url: String, + work_dir: PathBuf, + status: RwLock, + upgrade_permit: Arc, +} + +impl UpdateService { + pub fn new(work_dir: PathBuf) -> Self { + let base_url = std::env::var("ONE_KVM_UPDATE_BASE_URL") + .ok() + .filter(|url| !url.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_UPDATE_BASE_URL.to_string()); + + Self { + client: reqwest::Client::new(), + base_url, + work_dir, + status: RwLock::new(UpdateStatusResponse { + success: true, + phase: UpdatePhase::Idle, + progress: 0, + current_version: env!("CARGO_PKG_VERSION").to_string(), + target_version: None, + message: None, + last_error: None, + }), + upgrade_permit: Arc::new(Semaphore::new(1)), + } + } + + pub async fn status(&self) -> UpdateStatusResponse { + self.status.read().await.clone() + } + + pub async fn overview(&self, channel: UpdateChannel) -> Result { + let channels: ChannelsManifest = self.fetch_json("/v1/channels.json").await?; + let releases: ReleasesManifest = self.fetch_json("/v1/releases.json").await?; + + let current_version = parse_version(env!("CARGO_PKG_VERSION"))?; + let latest_version_str = match channel { + UpdateChannel::Stable => channels.stable, + UpdateChannel::Beta => channels.beta, + }; + let latest_version = parse_version(&latest_version_str)?; + let current_parts = parse_version_parts(¤t_version)?; + let latest_parts = parse_version_parts(&latest_version)?; + + let mut notes_between = Vec::new(); + for release in &releases.releases { + if release.channel != channel { + continue; + } + let version = match parse_version(&release.version) { + Ok(v) => v, + Err(_) => continue, + }; + let version_parts = match parse_version_parts(&version) { + Ok(parts) => parts, + Err(_) => continue, + }; + if compare_version_parts(&version_parts, ¤t_parts) == std::cmp::Ordering::Greater + && compare_version_parts(&version_parts, &latest_parts) + != std::cmp::Ordering::Greater + { + notes_between.push(( + version_parts, + ReleaseNotesItem { + version: release.version.clone(), + published_at: release.published_at.clone(), + notes: release.notes.clone(), + }, + )); + } + } + + notes_between.sort_by(|a, b| compare_version_parts(&a.0, &b.0)); + let notes_between = notes_between.into_iter().map(|(_, item)| item).collect(); + + let upgrade_available = + compare_versions(&latest_version, ¤t_version)? == std::cmp::Ordering::Greater; + + Ok(UpdateOverviewResponse { + success: true, + current_version: current_version.to_string(), + channel, + latest_version: latest_version.clone(), + upgrade_available, + target_version: if upgrade_available { + Some(latest_version) + } else { + None + }, + notes_between, + }) + } + + pub fn start_upgrade( + self: &Arc, + req: UpgradeRequest, + shutdown_tx: broadcast::Sender<()>, + ) -> Result<()> { + if req.channel.is_none() == req.target_version.is_none() { + return Err(AppError::BadRequest( + "Provide exactly one of channel or target_version".to_string(), + )); + } + + let permit = self + .upgrade_permit + .clone() + .try_acquire_owned() + .map_err(|_| AppError::BadRequest("Upgrade is already running".to_string()))?; + + let service = self.clone(); + tokio::spawn(async move { + let _permit = permit; + if let Err(e) = service.execute_upgrade(req, shutdown_tx).await { + service + .set_status( + UpdatePhase::Failed, + 0, + None, + Some(e.to_string()), + Some(e.to_string()), + ) + .await; + } + }); + + Ok(()) + } + + async fn execute_upgrade( + &self, + req: UpgradeRequest, + shutdown_tx: broadcast::Sender<()>, + ) -> Result<()> { + self.set_status( + UpdatePhase::Checking, + 5, + None, + Some("Checking for updates".to_string()), + None, + ) + .await; + + let channels: ChannelsManifest = self.fetch_json("/v1/channels.json").await?; + let releases: ReleasesManifest = self.fetch_json("/v1/releases.json").await?; + + let current_version = parse_version(env!("CARGO_PKG_VERSION"))?; + let target_version = if let Some(channel) = req.channel { + let version_str = match channel { + UpdateChannel::Stable => channels.stable, + UpdateChannel::Beta => channels.beta, + }; + parse_version(&version_str)? + } else { + parse_version(req.target_version.as_deref().unwrap_or_default())? + }; + + if compare_versions(&target_version, ¤t_version)? != std::cmp::Ordering::Greater { + return Err(AppError::BadRequest(format!( + "Target version {} must be greater than current version {}", + target_version, current_version + ))); + } + + let target_release = releases + .releases + .iter() + .find(|r| r.version == target_version) + .ok_or_else(|| AppError::NotFound(format!("Release {} not found", target_version)))?; + + let target_triple = current_target_triple()?; + let artifact = target_release + .artifacts + .get(&target_triple) + .ok_or_else(|| { + AppError::NotFound(format!( + "No binary for target {} in version {}", + target_triple, target_version + )) + })? + .clone(); + + self.set_status( + UpdatePhase::Downloading, + 10, + Some(target_version.clone()), + Some("Downloading binary".to_string()), + None, + ) + .await; + + tokio::fs::create_dir_all(&self.work_dir).await?; + let staging_path = self + .work_dir + .join(format!("one-kvm-{}-download", target_version)); + + let artifact_url = self.resolve_url(&artifact.url); + self.download_and_verify(&artifact_url, &staging_path, &artifact) + .await?; + + self.set_status( + UpdatePhase::Installing, + 80, + Some(target_version.clone()), + Some("Replacing binary".to_string()), + None, + ) + .await; + + self.install_binary(&staging_path).await?; + + self.set_status( + UpdatePhase::Restarting, + 95, + Some(target_version), + Some("Restarting service".to_string()), + None, + ) + .await; + + let _ = shutdown_tx.send(()); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + restart_current_process()?; + Ok(()) + } + + async fn download_and_verify( + &self, + url: &str, + output_path: &Path, + artifact: &ArtifactInfo, + ) -> Result<()> { + let response = self + .client + .get(url) + .send() + .await + .map_err(|e| AppError::Internal(format!("Failed to download {}: {}", url, e)))? + .error_for_status() + .map_err(|e| AppError::Internal(format!("Download request failed: {}", e)))?; + + let mut file = tokio::fs::File::create(output_path).await?; + let mut stream = response.bytes_stream(); + let mut downloaded: u64 = 0; + + while let Some(chunk) = stream.next().await { + let chunk = chunk + .map_err(|e| AppError::Internal(format!("Read download stream failed: {}", e)))?; + file.write_all(&chunk).await?; + downloaded += chunk.len() as u64; + + if artifact.size > 0 { + let ratio = (downloaded as f64 / artifact.size as f64).clamp(0.0, 1.0); + let progress = 10 + (ratio * 60.0) as u8; + self.set_status( + UpdatePhase::Downloading, + progress, + None, + Some(format!( + "Downloading binary ({} / {} bytes)", + downloaded, artifact.size + )), + None, + ) + .await; + } + } + file.flush().await?; + + if artifact.size > 0 && downloaded != artifact.size { + return Err(AppError::Internal(format!( + "Downloaded size mismatch: expected {}, got {}", + artifact.size, downloaded + ))); + } + + self.set_status( + UpdatePhase::Verifying, + 72, + None, + Some("Verifying sha256".to_string()), + None, + ) + .await; + + let actual_sha256 = compute_file_sha256(output_path).await?; + let expected_sha256 = normalize_sha256(&artifact.sha256).ok_or_else(|| { + AppError::Internal(format!( + "Invalid sha256 format in manifest: {}", + artifact.sha256 + )) + })?; + if actual_sha256 != expected_sha256 { + return Err(AppError::Internal(format!( + "SHA256 mismatch: expected {}, got {}", + expected_sha256, actual_sha256 + ))); + } + + Ok(()) + } + + async fn install_binary(&self, staging_path: &Path) -> Result<()> { + let current_exe = std::env::current_exe() + .map_err(|e| AppError::Internal(format!("Failed to get current exe path: {}", e)))?; + let exe_dir = current_exe.parent().ok_or_else(|| { + AppError::Internal("Failed to determine executable directory".to_string()) + })?; + + let install_path = exe_dir.join("one-kvm.upgrade.new"); + + tokio::fs::copy(staging_path, &install_path) + .await + .map_err(|e| { + AppError::Internal(format!("Failed to stage binary into install path: {}", e)) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = tokio::fs::metadata(&install_path).await?.permissions(); + perms.set_mode(0o755); + tokio::fs::set_permissions(&install_path, perms).await?; + } + + tokio::fs::rename(&install_path, ¤t_exe) + .await + .map_err(|e| AppError::Internal(format!("Failed to replace executable {}", e)))?; + + Ok(()) + } + + async fn fetch_json Deserialize<'de>>(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url.trim_end_matches('/'), path); + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("Failed to fetch {}: {}", url, e)))? + .error_for_status() + .map_err(|e| AppError::Internal(format!("Request failed {}: {}", url, e)))?; + + response + .json::() + .await + .map_err(|e| AppError::Internal(format!("Invalid update response {}: {}", url, e))) + } + + fn resolve_url(&self, url: &str) -> String { + if url.starts_with("http://") || url.starts_with("https://") { + url.to_string() + } else { + format!( + "{}/{}", + self.base_url.trim_end_matches('/'), + url.trim_start_matches('/') + ) + } + } + + async fn set_status( + &self, + phase: UpdatePhase, + progress: u8, + target_version: Option, + message: Option, + last_error: Option, + ) { + let mut status = self.status.write().await; + status.phase = phase; + status.progress = progress; + if target_version.is_some() { + status.target_version = target_version; + } + status.message = message; + status.last_error = last_error; + status.success = status.phase != UpdatePhase::Failed; + status.current_version = env!("CARGO_PKG_VERSION").to_string(); + } +} + +fn parse_version(input: &str) -> Result { + let parts: Vec<&str> = input.split('.').collect(); + if parts.len() != 3 { + return Err(AppError::Internal(format!( + "Invalid version {}, expected x.x.x", + input + ))); + } + if parts + .iter() + .any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit())) + { + return Err(AppError::Internal(format!( + "Invalid version {}, expected numeric x.x.x", + input + ))); + } + Ok(input.to_string()) +} + +fn compare_versions(a: &str, b: &str) -> Result { + let pa = parse_version_parts(a)?; + let pb = parse_version_parts(b)?; + Ok(compare_version_parts(&pa, &pb)) +} + +fn parse_version_parts(input: &str) -> Result<[u64; 3]> { + let parts: Vec<&str> = input.split('.').collect(); + if parts.len() != 3 { + return Err(AppError::Internal(format!( + "Invalid version {}, expected x.x.x", + input + ))); + } + let major = parts[0] + .parse::() + .map_err(|e| AppError::Internal(format!("Invalid major version {}: {}", parts[0], e)))?; + let minor = parts[1] + .parse::() + .map_err(|e| AppError::Internal(format!("Invalid minor version {}: {}", parts[1], e)))?; + let patch = parts[2] + .parse::() + .map_err(|e| AppError::Internal(format!("Invalid patch version {}: {}", parts[2], e)))?; + Ok([major, minor, patch]) +} + +fn compare_version_parts(a: &[u64; 3], b: &[u64; 3]) -> std::cmp::Ordering { + a[0].cmp(&b[0]).then(a[1].cmp(&b[1])).then(a[2].cmp(&b[2])) +} + +async fn compute_file_sha256(path: &Path) -> Result { + let mut file = tokio::fs::File::open(path).await?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + + loop { + let bytes_read = file.read(&mut buffer).await?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + + Ok(format!("{:x}", hasher.finalize())) +} + +fn normalize_sha256(input: &str) -> Option { + let token = input.split_whitespace().next()?.trim().to_lowercase(); + if token.len() != 64 || !token.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + Some(token) +} + +fn current_target_triple() -> Result { + let triple = match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => "x86_64-unknown-linux-gnu", + ("linux", "aarch64") => "aarch64-unknown-linux-gnu", + ("linux", "arm") => "armv7-unknown-linux-gnueabihf", + _ => { + return Err(AppError::BadRequest(format!( + "Unsupported platform {}-{}", + std::env::consts::OS, + std::env::consts::ARCH + ))); + } + }; + Ok(triple.to_string()) +} + +fn restart_current_process() -> Result<()> { + let exe = std::env::current_exe() + .map_err(|e| AppError::Internal(format!("Failed to get current exe: {}", e)))?; + let args: Vec = std::env::args().skip(1).collect(); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = std::process::Command::new(&exe).args(&args).exec(); + Err(AppError::Internal(format!("Failed to restart: {}", err))) + } + + #[cfg(not(unix))] + { + std::process::Command::new(&exe) + .args(&args) + .spawn() + .map_err(|e| AppError::Internal(format!("Failed to spawn restart process: {}", e)))?; + std::process::exit(0); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 12bf372a..c31db32d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,8 +2,8 @@ //! //! This module contains common utilities used across the codebase. -pub mod throttle; pub mod net; +pub mod throttle; -pub use throttle::LogThrottler; pub use net::{bind_tcp_listener, bind_udp_socket}; +pub use throttle::LogThrottler; diff --git a/src/video/capture.rs b/src/video/capture.rs index 8701521f..464227fd 100644 --- a/src/video/capture.rs +++ b/src/video/capture.rs @@ -2,24 +2,21 @@ //! //! Provides async video capture using memory-mapped buffers. +use bytes::Bytes; +use std::collections::HashMap; use std::io; use std::path::{Path, PathBuf}; -use bytes::Bytes; 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 v4l::buffer::Type as BufferType; -use v4l::io::traits::CaptureStream; -use v4l::prelude::*; -use v4l::video::capture::Parameters; -use v4l::video::Capture; -use v4l::Format; 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; @@ -280,9 +277,15 @@ fn run_capture( return Ok(()); } - // Open device - let device = match Device::with_path(&config.device_path) { - Ok(d) => d, + 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") { @@ -306,34 +309,7 @@ fn run_capture( } }; - // Set format - let format = Format::new( - config.resolution.width, - config.resolution.height, - config.format.to_fourcc(), - ); - - let actual_format = match device.set_format(&format) { - Ok(f) => f, - Err(e) => { - let err_str = e.to_string(); - if err_str.contains("busy") || err_str.contains("resource") { - warn!( - "Device busy on set_format 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 set format: {}", e))); - continue; - } - return Err(AppError::VideoError(format!("Failed to set format: {}", e))); - } - }; - - // Device opened and format set successfully - proceed with capture - return run_capture_inner(config, state, stats, stop_flag, device, actual_format); + return run_capture_inner(config, state, stats, stop_flag, stream); } // All retries exhausted @@ -348,48 +324,16 @@ fn run_capture_inner( state: &watch::Sender, stats: &Arc>, stop_flag: &AtomicBool, - device: Device, - actual_format: Format, + mut stream: V4l2rCaptureStream, ) -> Result<()> { + let resolution = stream.resolution(); + let pixel_format = stream.format(); + let stride = stream.stride(); info!( "Capture format: {}x{} {:?} stride={}", - actual_format.width, actual_format.height, actual_format.fourcc, actual_format.stride + resolution.width, resolution.height, pixel_format, stride ); - - // Try to set hardware FPS (V4L2 VIDIOC_S_PARM) - if config.fps > 0 { - match device.set_params(&Parameters::with_fps(config.fps)) { - Ok(actual_params) => { - // Extract actual FPS from returned interval (numerator/denominator) - let actual_hw_fps = if actual_params.interval.numerator > 0 { - actual_params.interval.denominator / actual_params.interval.numerator - } else { - 0 - }; - - if actual_hw_fps == config.fps { - info!("Hardware FPS set successfully: {} fps", actual_hw_fps); - } else if actual_hw_fps > 0 { - info!( - "Hardware FPS coerced: requested {} fps, got {} fps", - config.fps, actual_hw_fps - ); - } else { - warn!("Hardware FPS setting returned invalid interval"); - } - } - Err(e) => { - warn!("Failed to set hardware FPS: {}", e); - } - } - } - - // Create stream with mmap buffers - let mut stream = - MmapStream::with_buffers(&device, BufferType::VideoCapture, config.buffer_count) - .map_err(|e| AppError::VideoError(format!("Failed to create stream: {}", e)))?; - let _ = state.send(CaptureState::Running); info!("Capture started"); @@ -397,12 +341,25 @@ fn run_capture_inner( 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 = 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) { - // Try to capture a frame - let (_buf, meta) = match stream.next() { - Ok(frame_data) => frame_data, + let meta = match stream.next_into(&mut scratch) { + Ok(meta) => meta, Err(e) => { if e.kind() == io::ErrorKind::TimedOut { warn!("Capture timeout - no signal?"); @@ -432,19 +389,30 @@ fn run_capture_inner( }); } - error!("Capture error: {}", e); + 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.bytesused as usize; + let frame_size = meta.bytes_used; // Validate frame if frame_size < MIN_FRAME_SIZE { debug!( "Dropping small frame: {} bytes (bytesused={})", - frame_size, meta.bytesused + frame_size, meta.bytes_used ); continue; } @@ -470,6 +438,10 @@ fn run_capture_inner( 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"); @@ -525,38 +497,37 @@ fn grab_single_frame( resolution: Resolution, format: PixelFormat, ) -> Result { - let device = Device::with_path(device_path) - .map_err(|e| AppError::VideoError(format!("Failed to open device: {}", e)))?; - - let fmt = Format::new(resolution.width, resolution.height, format.to_fourcc()); - let actual = device - .set_format(&fmt) - .map_err(|e| AppError::VideoError(format!("Failed to set format: {}", e)))?; - - let mut stream = MmapStream::with_buffers(&device, BufferType::VideoCapture, 2) - .map_err(|e| AppError::VideoError(format!("Failed to create stream: {}", e)))?; + 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() { - Ok((buf, _meta)) => { - if buf.len() >= MIN_FRAME_SIZE { - let actual_format = PixelFormat::from_fourcc(actual.fourcc).unwrap_or(format); - + match stream.next_into(&mut scratch) { + Ok(meta) => { + if meta.bytes_used >= MIN_FRAME_SIZE { return Ok(VideoFrame::new( - Bytes::copy_from_slice(buf), - Resolution::new(actual.width, actual.height), + Bytes::copy_from_slice(&scratch[..meta.bytes_used]), + actual_resolution, actual_format, - actual.stride, + actual_stride, 0, )); } } - Err(e) => { - if attempt == 4 { - return Err(AppError::VideoError(format!("Failed to grab frame: {}", e))); - } + Err(e) if attempt == 4 => { + return Err(AppError::VideoError(format!("Failed to grab frame: {}", e))); } + Err(_) => {} } } diff --git a/src/video/codec_constraints.rs b/src/video/codec_constraints.rs new file mode 100644 index 00000000..cb9ff711 --- /dev/null +++ b/src/video/codec_constraints.rs @@ -0,0 +1,193 @@ +use crate::config::{AppConfig, RtspCodec, StreamMode}; +use crate::error::Result; +use crate::video::encoder::registry::VideoEncoderType; +use crate::video::encoder::VideoCodecType; +use crate::video::VideoStreamManager; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct StreamCodecConstraints { + pub rustdesk_enabled: bool, + pub rtsp_enabled: bool, + pub allowed_webrtc_codecs: Vec, + pub allow_mjpeg: bool, + pub locked_codec: Option, + pub reason: String, +} + +#[derive(Debug, Clone)] +pub struct ConstraintEnforcementResult { + pub changed: bool, + pub message: Option, +} + +impl StreamCodecConstraints { + pub fn unrestricted() -> Self { + Self { + rustdesk_enabled: false, + rtsp_enabled: false, + allowed_webrtc_codecs: vec![ + VideoCodecType::H264, + VideoCodecType::H265, + VideoCodecType::VP8, + VideoCodecType::VP9, + ], + allow_mjpeg: true, + locked_codec: None, + reason: "No codec lock active".to_string(), + } + } + + pub fn from_config(config: &AppConfig) -> Self { + let rustdesk_enabled = config.rustdesk.enabled; + let rtsp_enabled = config.rtsp.enabled; + + if rtsp_enabled { + let locked_codec = match config.rtsp.codec { + RtspCodec::H264 => VideoCodecType::H264, + RtspCodec::H265 => VideoCodecType::H265, + }; + return Self { + rustdesk_enabled, + rtsp_enabled, + allowed_webrtc_codecs: vec![locked_codec], + allow_mjpeg: false, + locked_codec: Some(locked_codec), + reason: if rustdesk_enabled { + format!( + "RTSP enabled with codec lock ({:?}) and RustDesk enabled", + locked_codec + ) + } else { + format!("RTSP enabled with codec lock ({:?})", locked_codec) + }, + }; + } + + if rustdesk_enabled { + return Self { + rustdesk_enabled, + rtsp_enabled, + allowed_webrtc_codecs: vec![ + VideoCodecType::H264, + VideoCodecType::H265, + VideoCodecType::VP8, + VideoCodecType::VP9, + ], + allow_mjpeg: false, + locked_codec: None, + reason: "RustDesk enabled, MJPEG disabled".to_string(), + }; + } + + Self::unrestricted() + } + + pub fn is_mjpeg_allowed(&self) -> bool { + self.allow_mjpeg + } + + pub fn is_webrtc_codec_allowed(&self, codec: VideoCodecType) -> bool { + self.allowed_webrtc_codecs.contains(&codec) + } + + pub fn preferred_webrtc_codec(&self) -> VideoCodecType { + if let Some(codec) = self.locked_codec { + return codec; + } + self.allowed_webrtc_codecs + .first() + .copied() + .unwrap_or(VideoCodecType::H264) + } + + pub fn allowed_codecs_for_api(&self) -> Vec<&'static str> { + let mut codecs = Vec::new(); + if self.allow_mjpeg { + codecs.push("mjpeg"); + } + for codec in &self.allowed_webrtc_codecs { + codecs.push(codec_to_id(*codec)); + } + codecs + } +} + +pub async fn enforce_constraints_with_stream_manager( + stream_manager: &Arc, + constraints: &StreamCodecConstraints, +) -> Result { + let current_mode = stream_manager.current_mode().await; + + if current_mode == StreamMode::Mjpeg && !constraints.allow_mjpeg { + let target_codec = constraints.preferred_webrtc_codec(); + stream_manager.set_video_codec(target_codec).await?; + let _ = stream_manager + .switch_mode_transaction(StreamMode::WebRTC) + .await?; + return Ok(ConstraintEnforcementResult { + changed: true, + message: Some(format!( + "Auto-switched from MJPEG to {} due to codec lock", + codec_to_id(target_codec) + )), + }); + } + + if current_mode == StreamMode::WebRTC { + let current_codec = stream_manager.webrtc_streamer().current_video_codec().await; + if !constraints.is_webrtc_codec_allowed(current_codec) { + let target_codec = constraints.preferred_webrtc_codec(); + stream_manager.set_video_codec(target_codec).await?; + return Ok(ConstraintEnforcementResult { + changed: true, + message: Some(format!( + "Auto-switched codec from {} to {} due to codec lock", + codec_to_id(current_codec), + codec_to_id(target_codec) + )), + }); + } + } + + Ok(ConstraintEnforcementResult { + changed: false, + message: None, + }) +} + +pub fn codec_to_id(codec: VideoCodecType) -> &'static str { + match codec { + VideoCodecType::H264 => "h264", + VideoCodecType::H265 => "h265", + VideoCodecType::VP8 => "vp8", + VideoCodecType::VP9 => "vp9", + } +} + +pub fn encoder_codec_to_id(codec: VideoEncoderType) -> &'static str { + match codec { + VideoEncoderType::H264 => "h264", + VideoEncoderType::H265 => "h265", + VideoEncoderType::VP8 => "vp8", + VideoEncoderType::VP9 => "vp9", + } +} + +pub fn video_codec_to_encoder_codec(codec: VideoCodecType) -> VideoEncoderType { + match codec { + VideoCodecType::H264 => VideoEncoderType::H264, + VideoCodecType::H265 => VideoEncoderType::H265, + VideoCodecType::VP8 => VideoEncoderType::VP8, + VideoCodecType::VP9 => VideoEncoderType::VP9, + } +} + +pub fn encoder_codec_to_video_codec(codec: VideoEncoderType) -> VideoCodecType { + match codec { + VideoEncoderType::H264 => VideoCodecType::H264, + VideoEncoderType::H265 => VideoCodecType::H265, + VideoEncoderType::VP8 => VideoCodecType::VP8, + VideoEncoderType::VP9 => VideoCodecType::VP9, + } +} diff --git a/src/video/device.rs b/src/video/device.rs index c99b4786..b35bee8e 100644 --- a/src/video/device.rs +++ b/src/video/device.rs @@ -1,15 +1,17 @@ //! V4L2 device enumeration and capability query use serde::{Deserialize, Serialize}; +use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::time::Duration; use tracing::{debug, info, warn}; -use v4l::capability::Flags; -use v4l::prelude::*; -use v4l::video::Capture; -use v4l::Format; -use v4l::FourCC; +use v4l2r::bindings::{v4l2_frmivalenum, v4l2_frmsizeenum}; +use v4l2r::ioctl::{ + self, Capabilities, Capability as V4l2rCapability, FormatIterator, FrmIvalTypes, FrmSizeTypes, +}; +use v4l2r::nix::errno::Errno; +use v4l2r::{Format as V4l2rFormat, QueueType}; use super::format::{PixelFormat, Resolution}; use crate::error::{AppError, Result}; @@ -81,7 +83,7 @@ pub struct DeviceCapabilities { /// Wrapper around a V4L2 video device pub struct VideoDevice { pub path: PathBuf, - device: Device, + fd: File, } impl VideoDevice { @@ -90,42 +92,55 @@ impl VideoDevice { let path = path.as_ref().to_path_buf(); debug!("Opening video device: {:?}", path); - let device = Device::with_path(&path).map_err(|e| { + let fd = File::options() + .read(true) + .write(true) + .open(&path) + .map_err(|e| { + AppError::VideoError(format!("Failed to open device {:?}: {}", path, e)) + })?; + + Ok(Self { path, fd }) + } + + /// Open a video device read-only (for probing/enumeration) + pub fn open_readonly(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + debug!("Opening video device (read-only): {:?}", path); + + let fd = File::options().read(true).open(&path).map_err(|e| { AppError::VideoError(format!("Failed to open device {:?}: {}", path, e)) })?; - Ok(Self { path, device }) + Ok(Self { path, fd }) } /// Get device capabilities pub fn capabilities(&self) -> Result { - let caps = self - .device - .query_caps() + let caps: V4l2rCapability = ioctl::querycap(&self.fd) .map_err(|e| AppError::VideoError(format!("Failed to query capabilities: {}", e)))?; + let flags = caps.device_caps(); Ok(DeviceCapabilities { - video_capture: caps.capabilities.contains(Flags::VIDEO_CAPTURE), - video_capture_mplane: caps.capabilities.contains(Flags::VIDEO_CAPTURE_MPLANE), - video_output: caps.capabilities.contains(Flags::VIDEO_OUTPUT), - streaming: caps.capabilities.contains(Flags::STREAMING), - read_write: caps.capabilities.contains(Flags::READ_WRITE), + video_capture: flags.contains(Capabilities::VIDEO_CAPTURE), + video_capture_mplane: flags.contains(Capabilities::VIDEO_CAPTURE_MPLANE), + video_output: flags.contains(Capabilities::VIDEO_OUTPUT), + streaming: flags.contains(Capabilities::STREAMING), + read_write: flags.contains(Capabilities::READWRITE), }) } /// Get detailed device information pub fn info(&self) -> Result { - let caps = self - .device - .query_caps() + let caps: V4l2rCapability = ioctl::querycap(&self.fd) .map_err(|e| AppError::VideoError(format!("Failed to query capabilities: {}", e)))?; - + let flags = caps.device_caps(); let capabilities = DeviceCapabilities { - video_capture: caps.capabilities.contains(Flags::VIDEO_CAPTURE), - video_capture_mplane: caps.capabilities.contains(Flags::VIDEO_CAPTURE_MPLANE), - video_output: caps.capabilities.contains(Flags::VIDEO_OUTPUT), - streaming: caps.capabilities.contains(Flags::STREAMING), - read_write: caps.capabilities.contains(Flags::READ_WRITE), + video_capture: flags.contains(Capabilities::VIDEO_CAPTURE), + video_capture_mplane: flags.contains(Capabilities::VIDEO_CAPTURE_MPLANE), + video_output: flags.contains(Capabilities::VIDEO_OUTPUT), + streaming: flags.contains(Capabilities::STREAMING), + read_write: flags.contains(Capabilities::READWRITE), }; let formats = self.enumerate_formats()?; @@ -141,7 +156,7 @@ impl VideoDevice { path: self.path.clone(), name: caps.card.clone(), driver: caps.driver.clone(), - bus_info: caps.bus.clone(), + bus_info: caps.bus_info.clone(), card: caps.card, formats, capabilities, @@ -154,16 +169,13 @@ impl VideoDevice { pub fn enumerate_formats(&self) -> Result> { let mut formats = Vec::new(); - // Get supported formats - let format_descs = self - .device - .enum_formats() - .map_err(|e| AppError::VideoError(format!("Failed to enumerate formats: {}", e)))?; + let queue = self.capture_queue_type()?; + let format_descs = FormatIterator::new(&self.fd, queue); for desc in format_descs { // Try to convert FourCC to our PixelFormat - if let Some(format) = PixelFormat::from_fourcc(desc.fourcc) { - let resolutions = self.enumerate_resolutions(desc.fourcc)?; + if let Some(format) = PixelFormat::from_v4l2r(desc.pixelformat) { + let resolutions = self.enumerate_resolutions(desc.pixelformat)?; formats.push(FormatInfo { format, @@ -173,7 +185,7 @@ impl VideoDevice { } else { debug!( "Skipping unsupported format: {:?} ({})", - desc.fourcc, desc.description + desc.pixelformat, desc.description ); } } @@ -185,46 +197,55 @@ impl VideoDevice { } /// Enumerate resolutions for a specific format - fn enumerate_resolutions(&self, fourcc: FourCC) -> Result> { + fn enumerate_resolutions(&self, fourcc: v4l2r::PixelFormat) -> Result> { let mut resolutions = Vec::new(); - // Try to enumerate frame sizes - match self.device.enum_framesizes(fourcc) { - Ok(sizes) => { - for size in sizes { - match size.size { - v4l::framesize::FrameSizeEnum::Discrete(d) => { - let fps = self - .enumerate_fps(fourcc, d.width, d.height) - .unwrap_or_default(); - resolutions.push(ResolutionInfo::new(d.width, d.height, fps)); - } - v4l::framesize::FrameSizeEnum::Stepwise(s) => { - // For stepwise, add some common resolutions - for res in [ - Resolution::VGA, - Resolution::HD720, - Resolution::HD1080, - Resolution::UHD4K, - ] { - if res.width >= s.min_width - && res.width <= s.max_width - && res.height >= s.min_height - && res.height <= s.max_height - { - let fps = self - .enumerate_fps(fourcc, res.width, res.height) - .unwrap_or_default(); - resolutions - .push(ResolutionInfo::new(res.width, res.height, fps)); + let mut index = 0u32; + loop { + match ioctl::enum_frame_sizes::(&self.fd, index, fourcc) { + Ok(size) => { + if let Some(size) = size.size() { + match size { + FrmSizeTypes::Discrete(d) => { + let fps = self + .enumerate_fps(fourcc, d.width, d.height) + .unwrap_or_default(); + resolutions.push(ResolutionInfo::new(d.width, d.height, fps)); + } + FrmSizeTypes::StepWise(s) => { + for res in [ + Resolution::VGA, + Resolution::HD720, + Resolution::HD1080, + Resolution::UHD4K, + ] { + if res.width >= s.min_width + && res.width <= s.max_width + && res.height >= s.min_height + && res.height <= s.max_height + { + let fps = self + .enumerate_fps(fourcc, res.width, res.height) + .unwrap_or_default(); + resolutions + .push(ResolutionInfo::new(res.width, res.height, fps)); + } } } } } + index += 1; + } + Err(e) => { + let is_einval = matches!( + e, + v4l2r::ioctl::FrameSizeError::IoctlError(err) if err == Errno::EINVAL + ); + if !is_einval { + debug!("Failed to enumerate frame sizes for {:?}: {}", fourcc, e); + } + break; } - } - Err(e) => { - debug!("Failed to enumerate frame sizes for {:?}: {}", fourcc, e); } } @@ -236,36 +257,55 @@ impl VideoDevice { } /// Enumerate FPS for a specific resolution - fn enumerate_fps(&self, fourcc: FourCC, width: u32, height: u32) -> Result> { + fn enumerate_fps( + &self, + fourcc: v4l2r::PixelFormat, + width: u32, + height: u32, + ) -> Result> { let mut fps_list = Vec::new(); - match self.device.enum_frameintervals(fourcc, width, height) { - Ok(intervals) => { - for interval in intervals { - match interval.interval { - v4l::frameinterval::FrameIntervalEnum::Discrete(fraction) => { - if fraction.numerator > 0 { - let fps = fraction.denominator / fraction.numerator; - fps_list.push(fps); + let mut index = 0u32; + loop { + match ioctl::enum_frame_intervals::( + &self.fd, index, fourcc, width, height, + ) { + Ok(interval) => { + if let Some(interval) = interval.intervals() { + match interval { + FrmIvalTypes::Discrete(fraction) => { + if fraction.numerator > 0 { + let fps = fraction.denominator / fraction.numerator; + fps_list.push(fps); + } } - } - v4l::frameinterval::FrameIntervalEnum::Stepwise(step) => { - // Just pick max/min/step - if step.max.numerator > 0 { - let min_fps = step.max.denominator / step.max.numerator; - let max_fps = step.min.denominator / step.min.numerator; - fps_list.push(min_fps); - if max_fps != min_fps { - fps_list.push(max_fps); + FrmIvalTypes::StepWise(step) => { + if step.max.numerator > 0 { + let min_fps = step.max.denominator / step.max.numerator; + let max_fps = step.min.denominator / step.min.numerator; + fps_list.push(min_fps); + if max_fps != min_fps { + fps_list.push(max_fps); + } } } } } + index += 1; + } + Err(e) => { + let is_einval = matches!( + e, + v4l2r::ioctl::FrameIntervalsError::IoctlError(err) if err == Errno::EINVAL + ); + if !is_einval { + debug!( + "Failed to enumerate frame intervals for {:?} {}x{}: {}", + fourcc, width, height, e + ); + } + break; } - } - Err(_) => { - // If enumeration fails, assume 30fps - fps_list.push(30); } } @@ -275,20 +315,26 @@ impl VideoDevice { } /// Get current format - pub fn get_format(&self) -> Result { - self.device - .format() + pub fn get_format(&self) -> Result { + let queue = self.capture_queue_type()?; + ioctl::g_fmt(&self.fd, queue) .map_err(|e| AppError::VideoError(format!("Failed to get format: {}", e))) } /// Set capture format - pub fn set_format(&self, width: u32, height: u32, format: PixelFormat) -> Result { - let fmt = Format::new(width, height, format.to_fourcc()); + pub fn set_format(&self, width: u32, height: u32, format: PixelFormat) -> Result { + let queue = self.capture_queue_type()?; + let mut fmt: V4l2rFormat = ioctl::g_fmt(&self.fd, queue) + .map_err(|e| AppError::VideoError(format!("Failed to get format: {}", e)))?; + fmt.width = width; + fmt.height = height; + fmt.pixelformat = format.to_v4l2r(); - // Request the format - let actual = self - .device - .set_format(&fmt) + let mut fd = self + .fd + .try_clone() + .map_err(|e| AppError::VideoError(format!("Failed to clone device fd: {}", e)))?; + let actual: V4l2rFormat = ioctl::s_fmt(&mut fd, (queue, &fmt)) .map_err(|e| AppError::VideoError(format!("Failed to set format: {}", e)))?; if actual.width != width || actual.height != height { @@ -364,7 +410,7 @@ impl VideoDevice { .max() .unwrap_or(0); - priority += (max_resolution / 100000) as u32; + priority += max_resolution / 100000; // Known good drivers get bonus let good_drivers = ["uvcvideo", "tc358743"]; @@ -376,8 +422,21 @@ impl VideoDevice { } /// Get the inner device reference (for advanced usage) - pub fn inner(&self) -> &Device { - &self.device + pub fn inner(&self) -> &File { + &self.fd + } + + fn capture_queue_type(&self) -> Result { + let caps = self.capabilities()?; + if caps.video_capture { + Ok(QueueType::VideoCapture) + } else if caps.video_capture_mplane { + Ok(QueueType::VideoCaptureMplane) + } else { + Err(AppError::VideoError( + "Device does not expose a capture queue".to_string(), + )) + } } } @@ -446,7 +505,7 @@ fn probe_device_with_timeout(path: &Path, timeout: Duration) -> Option Result { - let device = VideoDevice::open(&path_for_thread)?; + let device = VideoDevice::open_readonly(&path_for_thread)?; device.info() })(); let _ = tx.send(result); @@ -503,15 +562,7 @@ fn sysfs_maybe_capture(path: &Path) -> bool { } let skip_hints = [ - "codec", - "decoder", - "encoder", - "isp", - "mem2mem", - "m2m", - "vbi", - "radio", - "metadata", + "codec", "decoder", "encoder", "isp", "mem2mem", "m2m", "vbi", "radio", "metadata", "output", ]; if skip_hints.iter().any(|hint| sysfs_name.contains(hint)) && !maybe_capture { diff --git a/src/video/encoder/h264.rs b/src/video/encoder/h264.rs index 65c2512b..b7fdce24 100644 --- a/src/video/encoder/h264.rs +++ b/src/video/encoder/h264.rs @@ -32,7 +32,7 @@ fn init_hwcodec_logging() { } /// H.264 encoder type (detected from hwcodec) -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum H264EncoderType { /// NVIDIA NVENC Nvenc, @@ -49,6 +49,7 @@ pub enum H264EncoderType { /// Software encoding (libx264/openh264) Software, /// No encoder available + #[default] None, } @@ -67,12 +68,6 @@ impl std::fmt::Display for H264EncoderType { } } -impl Default for H264EncoderType { - fn default() -> Self { - Self::None - } -} - /// Map codec name to encoder type fn codec_name_to_type(name: &str) -> H264EncoderType { if name.contains("nvenc") { @@ -93,11 +88,12 @@ fn codec_name_to_type(name: &str) -> H264EncoderType { } /// Input pixel format for H264 encoder -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum H264InputFormat { /// YUV420P (I420) - planar Y, U, V Yuv420p, /// NV12 - Y plane + interleaved UV plane (optimal for VAAPI) + #[default] Nv12, /// NV21 - Y plane + interleaved VU plane Nv21, @@ -113,12 +109,6 @@ pub enum H264InputFormat { Bgr24, } -impl Default for H264InputFormat { - fn default() -> Self { - Self::Nv12 // Default to NV12 for VAAPI compatibility - } -} - /// H.264 encoder configuration #[derive(Debug, Clone)] pub struct H264Config { diff --git a/src/video/encoder/h265.rs b/src/video/encoder/h265.rs index 8a89015d..11eaf537 100644 --- a/src/video/encoder/h265.rs +++ b/src/video/encoder/h265.rs @@ -30,7 +30,7 @@ fn init_hwcodec_logging() { } /// H.265 encoder type (detected from hwcodec) -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum H265EncoderType { /// NVIDIA NVENC Nvenc, @@ -47,6 +47,7 @@ pub enum H265EncoderType { /// Software encoder (libx265) Software, /// No encoder available + #[default] None, } @@ -65,12 +66,6 @@ impl std::fmt::Display for H265EncoderType { } } -impl Default for H265EncoderType { - fn default() -> Self { - Self::None - } -} - impl From for H265EncoderType { fn from(backend: EncoderBackend) -> Self { match backend { @@ -86,11 +81,12 @@ impl From for H265EncoderType { } /// Input pixel format for H265 encoder -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum H265InputFormat { /// YUV420P (I420) - planar Y, U, V Yuv420p, /// NV12 - Y plane + interleaved UV plane (optimal for hardware encoders) + #[default] Nv12, /// NV21 - Y plane + interleaved VU plane Nv21, @@ -106,12 +102,6 @@ pub enum H265InputFormat { Bgr24, } -impl Default for H265InputFormat { - fn default() -> Self { - Self::Nv12 // Default to NV12 for hardware encoder compatibility - } -} - /// H.265 encoder configuration #[derive(Debug, Clone)] pub struct H265Config { @@ -256,8 +246,6 @@ pub fn detect_best_h265_encoder(width: u32, height: u32) -> (H265EncoderType, Op H265EncoderType::Rkmpp } else if codec.name.contains("v4l2m2m") { H265EncoderType::V4l2M2m - } else if codec.name.contains("libx265") { - H265EncoderType::Software } else { H265EncoderType::Software // Default to software for unknown }; diff --git a/src/video/encoder/registry.rs b/src/video/encoder/registry.rs index 1f9dd1a9..5a9658dc 100644 --- a/src/video/encoder/registry.rs +++ b/src/video/encoder/registry.rs @@ -145,6 +145,7 @@ impl EncoderBackend { } /// Parse from string (case-insensitive) + #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Option { match s.to_lowercase().as_str() { "vaapi" => Some(EncoderBackend::Vaapi), diff --git a/src/video/encoder/traits.rs b/src/video/encoder/traits.rs index 940ec245..4b3b4cc9 100644 --- a/src/video/encoder/traits.rs +++ b/src/video/encoder/traits.rs @@ -15,12 +15,14 @@ use crate::video::format::{PixelFormat, Resolution}; #[typeshare] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", content = "value")] +#[derive(Default)] pub enum BitratePreset { /// Speed priority: 1 Mbps, lowest latency, smaller GOP /// Best for: slow networks, remote management, low-bandwidth scenarios Speed, /// Balanced: 4 Mbps, good quality/latency tradeoff /// Best for: typical usage, recommended default + #[default] Balanced, /// Quality priority: 8 Mbps, best visual quality /// Best for: local network, high-bandwidth scenarios, detailed work @@ -74,12 +76,6 @@ impl BitratePreset { } } -impl Default for BitratePreset { - fn default() -> Self { - Self::Balanced - } -} - impl std::fmt::Display for BitratePreset { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/video/encoder/vp8.rs b/src/video/encoder/vp8.rs index 868af8ee..453fa133 100644 --- a/src/video/encoder/vp8.rs +++ b/src/video/encoder/vp8.rs @@ -30,13 +30,14 @@ fn init_hwcodec_logging() { } /// VP8 encoder type (detected from hwcodec) -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum VP8EncoderType { /// VAAPI (Intel on Linux) Vaapi, /// Software encoder (libvpx) Software, /// No encoder available + #[default] None, } @@ -50,12 +51,6 @@ impl std::fmt::Display for VP8EncoderType { } } -impl Default for VP8EncoderType { - fn default() -> Self { - Self::None - } -} - impl From for VP8EncoderType { fn from(backend: EncoderBackend) -> Self { match backend { @@ -67,20 +62,15 @@ impl From for VP8EncoderType { } /// Input pixel format for VP8 encoder -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum VP8InputFormat { /// YUV420P (I420) - planar Y, U, V Yuv420p, /// NV12 - Y plane + interleaved UV plane + #[default] Nv12, } -impl Default for VP8InputFormat { - fn default() -> Self { - Self::Nv12 // Default to NV12 for VAAPI compatibility - } -} - /// VP8 encoder configuration #[derive(Debug, Clone)] pub struct VP8Config { @@ -180,8 +170,6 @@ pub fn detect_best_vp8_encoder(width: u32, height: u32) -> (VP8EncoderType, Opti let encoder_type = if codec.name.contains("vaapi") { VP8EncoderType::Vaapi - } else if codec.name.contains("libvpx") { - VP8EncoderType::Software } else { VP8EncoderType::Software // Default to software for unknown }; diff --git a/src/video/encoder/vp9.rs b/src/video/encoder/vp9.rs index 6995db5d..aab23ff3 100644 --- a/src/video/encoder/vp9.rs +++ b/src/video/encoder/vp9.rs @@ -30,13 +30,14 @@ fn init_hwcodec_logging() { } /// VP9 encoder type (detected from hwcodec) -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum VP9EncoderType { /// VAAPI (Intel on Linux) Vaapi, /// Software encoder (libvpx-vp9) Software, /// No encoder available + #[default] None, } @@ -50,12 +51,6 @@ impl std::fmt::Display for VP9EncoderType { } } -impl Default for VP9EncoderType { - fn default() -> Self { - Self::None - } -} - impl From for VP9EncoderType { fn from(backend: EncoderBackend) -> Self { match backend { @@ -67,20 +62,15 @@ impl From for VP9EncoderType { } /// Input pixel format for VP9 encoder -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum VP9InputFormat { /// YUV420P (I420) - planar Y, U, V Yuv420p, /// NV12 - Y plane + interleaved UV plane + #[default] Nv12, } -impl Default for VP9InputFormat { - fn default() -> Self { - Self::Nv12 // Default to NV12 for VAAPI compatibility - } -} - /// VP9 encoder configuration #[derive(Debug, Clone)] pub struct VP9Config { @@ -180,8 +170,6 @@ pub fn detect_best_vp9_encoder(width: u32, height: u32) -> (VP9EncoderType, Opti let encoder_type = if codec.name.contains("vaapi") { VP9EncoderType::Vaapi - } else if codec.name.contains("libvpx") { - VP9EncoderType::Software } else { VP9EncoderType::Software // Default to software for unknown }; diff --git a/src/video/format.rs b/src/video/format.rs index 4097ae6f..f794dcfe 100644 --- a/src/video/format.rs +++ b/src/video/format.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use std::fmt; -use v4l::format::fourcc; +use v4l2r::PixelFormat as V4l2rPixelFormat; /// Supported pixel formats #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -41,30 +41,29 @@ pub enum PixelFormat { } impl PixelFormat { - /// Convert to V4L2 FourCC - pub fn to_fourcc(&self) -> fourcc::FourCC { + /// Convert to V4L2 FourCC bytes + pub fn to_fourcc(&self) -> [u8; 4] { match self { - PixelFormat::Mjpeg => fourcc::FourCC::new(b"MJPG"), - PixelFormat::Jpeg => fourcc::FourCC::new(b"JPEG"), - PixelFormat::Yuyv => fourcc::FourCC::new(b"YUYV"), - PixelFormat::Yvyu => fourcc::FourCC::new(b"YVYU"), - PixelFormat::Uyvy => fourcc::FourCC::new(b"UYVY"), - PixelFormat::Nv12 => fourcc::FourCC::new(b"NV12"), - PixelFormat::Nv21 => fourcc::FourCC::new(b"NV21"), - PixelFormat::Nv16 => fourcc::FourCC::new(b"NV16"), - PixelFormat::Nv24 => fourcc::FourCC::new(b"NV24"), - PixelFormat::Yuv420 => fourcc::FourCC::new(b"YU12"), - PixelFormat::Yvu420 => fourcc::FourCC::new(b"YV12"), - PixelFormat::Rgb565 => fourcc::FourCC::new(b"RGBP"), - PixelFormat::Rgb24 => fourcc::FourCC::new(b"RGB3"), - PixelFormat::Bgr24 => fourcc::FourCC::new(b"BGR3"), - PixelFormat::Grey => fourcc::FourCC::new(b"GREY"), + PixelFormat::Mjpeg => *b"MJPG", + PixelFormat::Jpeg => *b"JPEG", + PixelFormat::Yuyv => *b"YUYV", + PixelFormat::Yvyu => *b"YVYU", + PixelFormat::Uyvy => *b"UYVY", + PixelFormat::Nv12 => *b"NV12", + PixelFormat::Nv21 => *b"NV21", + PixelFormat::Nv16 => *b"NV16", + PixelFormat::Nv24 => *b"NV24", + PixelFormat::Yuv420 => *b"YU12", + PixelFormat::Yvu420 => *b"YV12", + PixelFormat::Rgb565 => *b"RGBP", + PixelFormat::Rgb24 => *b"RGB3", + PixelFormat::Bgr24 => *b"BGR3", + PixelFormat::Grey => *b"GREY", } } /// Try to convert from V4L2 FourCC - pub fn from_fourcc(fourcc: fourcc::FourCC) -> Option { - let repr = fourcc.repr; + pub fn from_fourcc(repr: [u8; 4]) -> Option { match &repr { b"MJPG" => Some(PixelFormat::Mjpeg), b"JPEG" => Some(PixelFormat::Jpeg), @@ -85,6 +84,17 @@ impl PixelFormat { } } + /// Convert to v4l2r PixelFormat + pub fn to_v4l2r(&self) -> V4l2rPixelFormat { + V4l2rPixelFormat::from(&self.to_fourcc()) + } + + /// Convert from v4l2r PixelFormat + pub fn from_v4l2r(format: V4l2rPixelFormat) -> Option { + let repr: [u8; 4] = format.into(); + Self::from_fourcc(repr) + } + /// Check if format is compressed (JPEG/MJPEG) pub fn is_compressed(&self) -> bool { matches!(self, PixelFormat::Mjpeg | PixelFormat::Jpeg) diff --git a/src/video/frame.rs b/src/video/frame.rs index dc8f4c92..9d0b43b0 100644 --- a/src/video/frame.rs +++ b/src/video/frame.rs @@ -81,6 +81,11 @@ impl FrameBuffer { pub fn len(&self) -> usize { self.data.len() } + + /// Check if the frame buffer has no data + pub fn is_empty(&self) -> bool { + self.data.is_empty() + } } impl std::fmt::Debug for FrameBuffer { diff --git a/src/video/mod.rs b/src/video/mod.rs index b5664f48..1ea9600b 100644 --- a/src/video/mod.rs +++ b/src/video/mod.rs @@ -3,6 +3,7 @@ //! This module provides V4L2 video capture, encoding, and streaming functionality. pub mod capture; +pub mod codec_constraints; pub mod convert; pub mod decoder; pub mod device; @@ -13,6 +14,7 @@ pub mod h264_pipeline; pub mod shared_video_pipeline; pub mod stream_manager; pub mod streamer; +pub mod v4l2r_capture; pub mod video_session; pub use capture::VideoCapturer; diff --git a/src/video/shared_video_pipeline.rs b/src/video/shared_video_pipeline.rs index 6ab721ff..f34725b9 100644 --- a/src/video/shared_video_pipeline.rs +++ b/src/video/shared_video_pipeline.rs @@ -18,6 +18,7 @@ use bytes::Bytes; use parking_lot::RwLock as ParkingRwLock; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -26,22 +27,17 @@ use tracing::{debug, error, info, trace, warn}; /// Grace period before auto-stopping pipeline when no subscribers (in seconds) const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3; +/// Restart capture stream after this many consecutive timeouts. +const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5; /// 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; use crate::error::{AppError, Result}; +use crate::utils::LogThrottler; use crate::video::convert::{Nv12Converter, PixelConverter}; use crate::video::decoder::MjpegTurboDecoder; -#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] -use hwcodec::ffmpeg_hw::{last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline}; -use v4l::buffer::Type as BufferType; -use v4l::io::traits::CaptureStream; -use v4l::prelude::*; -use v4l::video::Capture; -use v4l::video::capture::Parameters; -use v4l::Format; use crate::video::encoder::h264::{detect_best_encoder, H264Config, H264Encoder, H264InputFormat}; use crate::video::encoder::h265::{ detect_best_h265_encoder, H265Config, H265Encoder, H265InputFormat, @@ -52,6 +48,11 @@ use crate::video::encoder::vp8::{detect_best_vp8_encoder, VP8Config, VP8Encoder} use crate::video::encoder::vp9::{detect_best_vp9_encoder, VP9Config, VP9Encoder}; use crate::video::format::{PixelFormat, Resolution}; use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame}; +use crate::video::v4l2r_capture::V4l2rCaptureStream; +#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] +use hwcodec::ffmpeg_hw::{ + last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline, +}; /// Encoded video frame for distribution #[derive(Debug, Clone)] @@ -511,7 +512,10 @@ impl SharedVideoPipeline { #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] if needs_mjpeg_decode && is_rkmpp_encoder - && matches!(config.output_codec, VideoEncoderType::H264 | VideoEncoderType::H265) + && matches!( + config.output_codec, + VideoEncoderType::H264 | VideoEncoderType::H265 + ) { info!( "Initializing FFmpeg HW MJPEG->{} pipeline (no fallback)", @@ -528,7 +532,11 @@ impl SharedVideoPipeline { thread_count: 1, }; let pipeline = HwMjpegH26xPipeline::new(hw_config).map_err(|e| { - let detail = if e.is_empty() { ffmpeg_hw_last_error() } else { 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 @@ -902,7 +910,11 @@ impl SharedVideoPipeline { /// Get subscriber count pub fn subscriber_count(&self) -> usize { - self.subscribers.read().iter().filter(|tx| !tx.is_closed()).count() + self.subscribers + .read() + .iter() + .filter(|tx| !tx.is_closed()) + .count() } /// Report that a receiver has lagged behind @@ -951,7 +963,11 @@ impl SharedVideoPipeline { pipeline .reconfigure(bitrate_kbps as i32, gop as i32) .map_err(|e| { - let detail = if e.is_empty() { ffmpeg_hw_last_error() } else { e }; + let detail = if e.is_empty() { + ffmpeg_hw_last_error() + } else { + e + }; AppError::VideoError(format!( "FFmpeg HW reconfigure failed: {}", detail @@ -1279,53 +1295,17 @@ impl SharedVideoPipeline { let frame_seq_tx = frame_seq_tx.clone(); let buffer_pool = buffer_pool.clone(); std::thread::spawn(move || { - let device = match Device::with_path(&device_path) { - Ok(d) => d, - Err(e) => { - error!("Failed to open device {:?}: {}", device_path, e); - let _ = pipeline.running.send(false); - pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(1); - return; - } - }; - - let requested_format = Format::new( - config.resolution.width, - config.resolution.height, - config.input_format.to_fourcc(), - ); - - let actual_format = match device.set_format(&requested_format) { - Ok(f) => f, - Err(e) => { - error!("Failed to set capture format: {}", e); - let _ = pipeline.running.send(false); - pipeline.running_flag.store(false, Ordering::Release); - let _ = frame_seq_tx.send(1); - return; - } - }; - - let resolution = Resolution::new(actual_format.width, actual_format.height); - let pixel_format = - PixelFormat::from_fourcc(actual_format.fourcc).unwrap_or(config.input_format); - let stride = actual_format.stride; - - if config.fps > 0 { - if let Err(e) = device.set_params(&Parameters::with_fps(config.fps)) { - warn!("Failed to set hardware FPS: {}", e); - } - } - - let mut stream = match MmapStream::with_buffers( - &device, - BufferType::VideoCapture, + let mut stream = match V4l2rCaptureStream::open( + &device_path, + config.resolution, + config.input_format, + config.fps, buffer_count.max(1), + Duration::from_secs(2), ) { - Ok(s) => s, + Ok(stream) => stream, Err(e) => { - error!("Failed to create capture stream: {}", e); + error!("Failed to open capture stream: {}", e); let _ = pipeline.running.send(false); pipeline.running_flag.store(false, Ordering::Release); let _ = frame_seq_tx.send(1); @@ -1333,10 +1313,28 @@ impl SharedVideoPipeline { } }; + let resolution = stream.resolution(); + let pixel_format = stream.format(); + let stride = stream.stride(); + let mut no_subscribers_since: Option = None; let grace_period = Duration::from_secs(AUTO_STOP_GRACE_PERIOD_SECS); let mut sequence: u64 = 0; let mut validate_counter: u64 = 0; + let mut consecutive_timeouts: u32 = 0; + let capture_error_throttler = LogThrottler::with_secs(5); + let mut suppressed_capture_errors: HashMap = 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 pipeline.running_flag.load(Ordering::Acquire) { let subscriber_count = pipeline.subscriber_count(); @@ -1366,49 +1364,78 @@ impl SharedVideoPipeline { no_subscribers_since = None; } - let (buf, meta) = match stream.next() { - Ok(frame_data) => frame_data, + let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE); + let meta = match stream.next_into(&mut owned) { + Ok(meta) => { + consecutive_timeouts = 0; + meta + } Err(e) => { if e.kind() == std::io::ErrorKind::TimedOut { + consecutive_timeouts = consecutive_timeouts.saturating_add(1); warn!("Capture timeout - no signal?"); + + if consecutive_timeouts >= CAPTURE_TIMEOUT_RESTART_THRESHOLD { + warn!( + "Capture timed out {} consecutive times, restarting video pipeline", + consecutive_timeouts + ); + let _ = pipeline.running.send(false); + pipeline.running_flag.store(false, Ordering::Release); + let _ = frame_seq_tx.send(sequence.wrapping_add(1)); + break; + } } else { - error!("Capture error: {}", e); + consecutive_timeouts = 0; + 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.bytesused as usize; + 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 % JPEG_VALIDATE_INTERVAL == 0 - && !VideoFrame::is_valid_jpeg_bytes(&buf[..frame_size]) + && validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL) + && !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size]) { continue; } - let mut owned = buffer_pool.take(frame_size); - owned.resize(frame_size, 0); - owned[..frame_size].copy_from_slice(&buf[..frame_size]); + owned.truncate(frame_size); let frame = Arc::new(VideoFrame::from_pooled( Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))), resolution, pixel_format, stride, - sequence, + meta.sequence, )); - sequence = sequence.wrapping_add(1); + sequence = meta.sequence.wrapping_add(1); { let mut guard = latest_frame.write(); *guard = Some(frame); } let _ = frame_seq_tx.send(sequence); - } pipeline.running_flag.store(false, Ordering::Release); @@ -1473,7 +1500,11 @@ impl SharedVideoPipeline { } let packet = pipeline.encode(raw_frame, pts_ms).map_err(|e| { - let detail = if e.is_empty() { ffmpeg_hw_last_error() } else { e }; + let detail = if e.is_empty() { + ffmpeg_hw_last_error() + } else { + e + }; AppError::VideoError(format!("FFmpeg HW encode failed: {}", detail)) })?; @@ -1493,9 +1524,10 @@ impl SharedVideoPipeline { } let decoded_buf = if input_format.is_compressed() { - let decoder = state.mjpeg_decoder.as_mut().ok_or_else(|| { - AppError::VideoError("MJPEG decoder not initialized".to_string()) - })?; + let decoder = state + .mjpeg_decoder + .as_mut() + .ok_or_else(|| AppError::VideoError("MJPEG decoder not initialized".to_string()))?; let decoded = decoder.decode(raw_frame)?; Some(decoded) } else { @@ -1525,16 +1557,18 @@ impl SharedVideoPipeline { debug!("[Pipeline] Keyframe will be generated for this frame"); } - let encode_result = if needs_yuv420p && state.yuv420p_converter.is_some() { + let encode_result = if needs_yuv420p { // Software encoder with direct input conversion to YUV420P - let conv = state.yuv420p_converter.as_mut().unwrap(); - let yuv420p_data = conv - .convert(raw_frame) - .map_err(|e| AppError::VideoError(format!("YUV420P conversion failed: {}", e)))?; - encoder.encode_raw(yuv420p_data, pts_ms) - } else if state.nv12_converter.is_some() { + if let Some(conv) = state.yuv420p_converter.as_mut() { + let yuv420p_data = conv.convert(raw_frame).map_err(|e| { + AppError::VideoError(format!("YUV420P conversion failed: {}", e)) + })?; + encoder.encode_raw(yuv420p_data, pts_ms) + } else { + encoder.encode_raw(raw_frame, pts_ms) + } + } else if let Some(conv) = state.nv12_converter.as_mut() { // Hardware encoder with input conversion to NV12 - let conv = state.nv12_converter.as_mut().unwrap(); let nv12_data = conv .convert(raw_frame) .map_err(|e| AppError::VideoError(format!("NV12 conversion failed: {}", e)))?; diff --git a/src/video/stream_manager.rs b/src/video/stream_manager.rs index cabe553a..b5beb25f 100644 --- a/src/video/stream_manager.rs +++ b/src/video/stream_manager.rs @@ -37,6 +37,7 @@ use crate::error::Result; use crate::events::{EventBus, SystemEvent, VideoDeviceInfo}; use crate::hid::HidController; use crate::stream::MjpegStreamHandler; +use crate::video::codec_constraints::StreamCodecConstraints; use crate::video::format::{PixelFormat, Resolution}; use crate::video::streamer::{Streamer, StreamerState}; use crate::webrtc::WebRtcStreamer; @@ -144,6 +145,16 @@ impl VideoStreamManager { *self.config_store.write().await = Some(config); } + /// Get current stream codec constraints derived from global configuration. + pub async fn codec_constraints(&self) -> StreamCodecConstraints { + if let Some(ref config_store) = *self.config_store.read().await { + let config = config_store.get(); + StreamCodecConstraints::from_config(&config) + } else { + StreamCodecConstraints::unrestricted() + } + } + /// Get current streaming mode pub async fn current_mode(&self) -> StreamMode { self.mode.read().await.clone() @@ -718,9 +729,11 @@ impl VideoStreamManager { /// Returns None if video capture cannot be started or pipeline creation fails. pub async fn subscribe_encoded_frames( &self, - ) -> Option>> { + ) -> Option< + tokio::sync::mpsc::Receiver< + std::sync::Arc, + >, + > { // 1. Ensure video capture is initialized (for config discovery) if self.streamer.state().await == StreamerState::Uninitialized { tracing::info!("Initializing video capture for encoded frame subscription"); @@ -756,7 +769,11 @@ impl VideoStreamManager { } // 3. Use WebRtcStreamer to ensure the shared video pipeline is running - match self.webrtc_streamer.ensure_video_pipeline_for_external().await { + match self + .webrtc_streamer + .ensure_video_pipeline_for_external() + .await + { Ok(pipeline) => Some(pipeline.subscribe()), Err(e) => { tracing::error!("Failed to start shared video pipeline: {}", e); diff --git a/src/video/streamer.rs b/src/video/streamer.rs index 2b11e744..fdca1d7c 100644 --- a/src/video/streamer.rs +++ b/src/video/streamer.rs @@ -3,9 +3,11 @@ //! This module provides a high-level interface for video capture and streaming, //! managing the lifecycle of the capture thread and MJPEG/WebRTC distribution. +use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; +use std::time::Duration; use tokio::sync::RwLock; use tracing::{debug, error, info, trace, warn}; @@ -15,12 +17,8 @@ use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame}; use crate::error::{AppError, Result}; use crate::events::{EventBus, SystemEvent}; use crate::stream::MjpegStreamHandler; -use v4l::buffer::Type as BufferType; -use v4l::io::traits::CaptureStream; -use v4l::prelude::*; -use v4l::video::capture::Parameters; -use v4l::video::Capture; -use v4l::Format; +use crate::utils::LogThrottler; +use crate::video::v4l2r_capture::V4l2rCaptureStream; /// Minimum valid frame size for capture const MIN_CAPTURE_FRAME_SIZE: usize = 128; @@ -573,11 +571,9 @@ impl Streamer { break; } } - } else { - if zero_since.is_some() { - info!("Clients reconnected, canceling auto-pause"); - zero_since = None; - } + } else if zero_since.is_some() { + info!("Clients reconnected, canceling auto-pause"); + zero_since = None; } } }); @@ -632,8 +628,7 @@ impl Streamer { } }; - let mut device_opt: Option = None; - let mut format_opt: Option = None; + let mut stream_opt: Option = None; let mut last_error: Option = None; for attempt in 0..MAX_RETRIES { @@ -642,8 +637,18 @@ impl Streamer { return; } - let device = match Device::with_path(&device_path) { - Ok(d) => d, + match V4l2rCaptureStream::open( + &device_path, + config.resolution, + config.format, + config.fps, + BUFFER_COUNT, + 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") { @@ -660,42 +665,12 @@ impl Streamer { last_error = Some(err_str); break; } - }; - - let requested = Format::new( - config.resolution.width, - config.resolution.height, - config.format.to_fourcc(), - ); - - match device.set_format(&requested) { - Ok(actual) => { - device_opt = Some(device); - format_opt = Some(actual); - break; - } - Err(e) => { - let err_str = e.to_string(); - if err_str.contains("busy") || err_str.contains("resource") { - warn!( - "Device busy on set_format 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 (device, actual_format) = match (device_opt, format_opt) { - (Some(d), Some(f)) => (d, f), - _ => { + let mut stream = match stream_opt { + Some(stream) => stream, + None => { error!( "Failed to open device {:?}: {}", device_path, @@ -709,42 +684,35 @@ impl Streamer { } }; + let resolution = stream.resolution(); + let pixel_format = stream.format(); + let stride = stream.stride(); + info!( "Capture format: {}x{} {:?} stride={}", - actual_format.width, actual_format.height, actual_format.fourcc, actual_format.stride + resolution.width, resolution.height, pixel_format, stride ); - let resolution = Resolution::new(actual_format.width, actual_format.height); - let pixel_format = - PixelFormat::from_fourcc(actual_format.fourcc).unwrap_or(config.format); - - if config.fps > 0 { - if let Err(e) = device.set_params(&Parameters::with_fps(config.fps)) { - warn!("Failed to set hardware FPS: {}", e); - } - } - - let mut stream = - match MmapStream::with_buffers(&device, BufferType::VideoCapture, BUFFER_COUNT) { - Ok(s) => s, - Err(e) => { - error!("Failed to create capture stream: {}", e); - self.mjpeg_handler.set_offline(); - set_state(StreamerState::Error); - self.direct_active.store(false, Ordering::SeqCst); - self.current_fps.store(0, Ordering::Relaxed); - return; - } - }; - let buffer_pool = Arc::new(FrameBufferPool::new(BUFFER_COUNT.max(4) as usize)); let mut signal_present = true; - let mut sequence: u64 = 0; let mut validate_counter: u64 = 0; let mut idle_since: Option = None; let mut fps_frame_count: u64 = 0; let mut last_fps_time = std::time::Instant::now(); + let capture_error_throttler = LogThrottler::with_secs(5); + let mut suppressed_capture_errors: HashMap = 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 mjpeg_clients = self.mjpeg_handler.client_count(); @@ -768,8 +736,9 @@ impl Streamer { idle_since = None; } - let (buf, meta) = match stream.next() { - Ok(frame_data) => frame_data, + 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() == std::io::ErrorKind::TimedOut { if signal_present { @@ -811,35 +780,43 @@ impl Streamer { break; } - error!("Capture error: {}", e); + 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.bytesused as usize; + 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 % JPEG_VALIDATE_INTERVAL == 0 - && !VideoFrame::is_valid_jpeg_bytes(&buf[..frame_size]) + && validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL) + && !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size]) { continue; } - let mut owned = buffer_pool.take(frame_size); - owned.resize(frame_size, 0); - owned[..frame_size].copy_from_slice(&buf[..frame_size]); + owned.truncate(frame_size); let frame = VideoFrame::from_pooled( Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))), resolution, pixel_format, - actual_format.stride, - sequence, + stride, + meta.sequence, ); - sequence = sequence.wrapping_add(1); if !signal_present { signal_present = true; @@ -985,7 +962,7 @@ impl Streamer { *streamer.state.write().await = StreamerState::Recovering; // Publish reconnecting event (every 5 attempts to avoid spam) - if attempt == 1 || attempt % 5 == 0 { + if attempt == 1 || attempt.is_multiple_of(5) { streamer .publish_event(SystemEvent::StreamReconnecting { device: device_path.clone(), diff --git a/src/video/v4l2r_capture.rs b/src/video/v4l2r_capture.rs new file mode 100644 index 00000000..a44b841a --- /dev/null +++ b/src/video/v4l2r_capture.rs @@ -0,0 +1,277 @@ +//! V4L2 capture implementation using v4l2r (ioctl layer). + +use std::fs::File; +use std::io; +use std::os::fd::AsFd; +use std::path::Path; +use std::time::Duration; + +use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; +use tracing::{debug, warn}; +use v4l2r::bindings::{v4l2_requestbuffers, v4l2_streamparm, v4l2_streamparm__bindgen_ty_1}; +use v4l2r::ioctl::{ + self, Capabilities, Capability as V4l2rCapability, MemoryConsistency, PlaneMapping, QBufPlane, + QBuffer, QueryBuffer, V4l2Buffer, +}; +use v4l2r::memory::{MemoryType, MmapHandle}; +use v4l2r::{Format as V4l2rFormat, PixelFormat as V4l2rPixelFormat, QueueType}; + +use crate::error::{AppError, Result}; +use crate::video::format::{PixelFormat, Resolution}; + +/// Metadata for a captured frame. +#[derive(Debug, Clone, Copy)] +pub struct CaptureMeta { + pub bytes_used: usize, + pub sequence: u64, +} + +/// V4L2 capture stream backed by v4l2r ioctl. +pub struct V4l2rCaptureStream { + fd: File, + queue: QueueType, + resolution: Resolution, + format: PixelFormat, + stride: u32, + timeout: Duration, + mappings: Vec>, +} + +impl V4l2rCaptureStream { + pub fn open( + device_path: impl AsRef, + resolution: Resolution, + format: PixelFormat, + fps: u32, + buffer_count: u32, + timeout: Duration, + ) -> Result { + let mut fd = File::options() + .read(true) + .write(true) + .open(device_path.as_ref()) + .map_err(|e| AppError::VideoError(format!("Failed to open device: {}", e)))?; + + let caps: V4l2rCapability = ioctl::querycap(&fd) + .map_err(|e| AppError::VideoError(format!("Failed to query capabilities: {}", e)))?; + let caps_flags = caps.device_caps(); + + // Prefer multi-planar capture when available, as it is required for some + // devices/pixel formats (e.g. NV12 via VIDEO_CAPTURE_MPLANE). + let queue = if caps_flags.contains(Capabilities::VIDEO_CAPTURE_MPLANE) { + QueueType::VideoCaptureMplane + } else if caps_flags.contains(Capabilities::VIDEO_CAPTURE) { + QueueType::VideoCapture + } else { + return Err(AppError::VideoError( + "Device does not support capture queues".to_string(), + )); + }; + + let mut fmt: V4l2rFormat = ioctl::g_fmt(&fd, queue) + .map_err(|e| AppError::VideoError(format!("Failed to get device format: {}", e)))?; + + fmt.width = resolution.width; + fmt.height = resolution.height; + fmt.pixelformat = V4l2rPixelFormat::from(&format.to_fourcc()); + + let actual_fmt: V4l2rFormat = ioctl::s_fmt(&mut fd, (queue, &fmt)) + .map_err(|e| AppError::VideoError(format!("Failed to set device format: {}", e)))?; + + let actual_resolution = Resolution::new(actual_fmt.width, actual_fmt.height); + let actual_format = PixelFormat::from_v4l2r(actual_fmt.pixelformat).unwrap_or(format); + + let stride = actual_fmt + .plane_fmt + .first() + .map(|p| p.bytesperline) + .unwrap_or_else(|| match actual_format.bytes_per_pixel() { + Some(bpp) => actual_resolution.width * bpp as u32, + None => actual_resolution.width, + }); + + if fps > 0 { + if let Err(e) = set_fps(&fd, queue, fps) { + warn!("Failed to set hardware FPS: {}", e); + } + } + + let req: v4l2_requestbuffers = ioctl::reqbufs( + &fd, + queue, + MemoryType::Mmap, + buffer_count, + MemoryConsistency::empty(), + ) + .map_err(|e| AppError::VideoError(format!("Failed to request buffers: {}", e)))?; + let allocated = req.count as usize; + if allocated == 0 { + return Err(AppError::VideoError( + "Driver returned zero capture buffers".to_string(), + )); + } + + let mut mappings = Vec::with_capacity(allocated); + for index in 0..allocated as u32 { + let query: QueryBuffer = ioctl::querybuf(&fd, queue, index as usize).map_err(|e| { + AppError::VideoError(format!("Failed to query buffer {}: {}", index, e)) + })?; + + if query.planes.is_empty() { + return Err(AppError::VideoError(format!( + "Driver returned zero planes for buffer {}", + index + ))); + } + + let mut plane_maps = Vec::with_capacity(query.planes.len()); + for plane in &query.planes { + let mapping = ioctl::mmap(&fd, plane.mem_offset, plane.length).map_err(|e| { + AppError::VideoError(format!("Failed to mmap buffer {}: {}", index, e)) + })?; + plane_maps.push(mapping); + } + mappings.push(plane_maps); + } + + let mut stream = Self { + fd, + queue, + resolution: actual_resolution, + format: actual_format, + stride, + timeout, + mappings, + }; + + stream.queue_all_buffers()?; + ioctl::streamon(&stream.fd, stream.queue) + .map_err(|e| AppError::VideoError(format!("Failed to start capture stream: {}", e)))?; + + Ok(stream) + } + + pub fn resolution(&self) -> Resolution { + self.resolution + } + + pub fn format(&self) -> PixelFormat { + self.format + } + + pub fn stride(&self) -> u32 { + self.stride + } + + pub fn next_into(&mut self, dst: &mut Vec) -> io::Result { + self.wait_ready()?; + + let dqbuf: V4l2Buffer = ioctl::dqbuf(&self.fd, self.queue) + .map_err(|e| io::Error::other(format!("dqbuf failed: {}", e)))?; + let index = dqbuf.as_v4l2_buffer().index as usize; + let sequence = dqbuf.as_v4l2_buffer().sequence as u64; + + let mut total = 0usize; + for (plane_idx, plane) in dqbuf.planes_iter().enumerate() { + let bytes_used = *plane.bytesused as usize; + let data_offset = plane.data_offset.copied().unwrap_or(0) as usize; + if bytes_used == 0 { + continue; + } + let mapping = &self.mappings[index][plane_idx]; + let start = data_offset.min(mapping.len()); + let end = (data_offset + bytes_used).min(mapping.len()); + total += end.saturating_sub(start); + } + + dst.resize(total, 0); + let mut cursor = 0usize; + for (plane_idx, plane) in dqbuf.planes_iter().enumerate() { + let bytes_used = *plane.bytesused as usize; + let data_offset = plane.data_offset.copied().unwrap_or(0) as usize; + if bytes_used == 0 { + continue; + } + let mapping = &self.mappings[index][plane_idx]; + let start = data_offset.min(mapping.len()); + let end = (data_offset + bytes_used).min(mapping.len()); + let len = end.saturating_sub(start); + if len == 0 { + continue; + } + dst[cursor..cursor + len].copy_from_slice(&mapping[start..end]); + cursor += len; + } + + self.queue_buffer(index as u32) + .map_err(|e| io::Error::other(e.to_string()))?; + + Ok(CaptureMeta { + bytes_used: total, + sequence, + }) + } + + fn wait_ready(&self) -> io::Result<()> { + if self.timeout.is_zero() { + return Ok(()); + } + let mut fds = [PollFd::new(self.fd.as_fd(), PollFlags::POLLIN)]; + let timeout_ms = self.timeout.as_millis().min(u16::MAX as u128) as u16; + let ready = poll(&mut fds, PollTimeout::from(timeout_ms))?; + if ready == 0 { + return Err(io::Error::new(io::ErrorKind::TimedOut, "capture timeout")); + } + Ok(()) + } + + fn queue_all_buffers(&mut self) -> Result<()> { + for index in 0..self.mappings.len() as u32 { + self.queue_buffer(index)?; + } + Ok(()) + } + + fn queue_buffer(&mut self, index: u32) -> Result<()> { + let handle = MmapHandle; + let planes = self.mappings[index as usize] + .iter() + .map(|mapping| { + let mut plane = QBufPlane::new_from_handle(&handle, 0); + plane.0.length = mapping.len() as u32; + plane + }) + .collect(); + let mut qbuf: QBuffer = QBuffer::new(self.queue, index); + qbuf.planes = planes; + ioctl::qbuf::<_, ()>(&self.fd, qbuf) + .map_err(|e| AppError::VideoError(format!("Failed to queue buffer: {}", e)))?; + Ok(()) + } +} + +impl Drop for V4l2rCaptureStream { + fn drop(&mut self) { + if let Err(e) = ioctl::streamoff(&self.fd, self.queue) { + debug!("Failed to stop capture stream: {}", e); + } + } +} + +fn set_fps(fd: &File, queue: QueueType, fps: u32) -> Result<()> { + let mut params = unsafe { std::mem::zeroed::() }; + params.type_ = queue as u32; + params.parm = v4l2_streamparm__bindgen_ty_1 { + capture: v4l2r::bindings::v4l2_captureparm { + timeperframe: v4l2r::bindings::v4l2_fract { + numerator: 1, + denominator: fps, + }, + ..unsafe { std::mem::zeroed() } + }, + }; + + let _actual: v4l2_streamparm = ioctl::s_parm(fd, params) + .map_err(|e| AppError::VideoError(format!("Failed to set FPS: {}", e)))?; + Ok(()) +} diff --git a/src/video/video_session.rs b/src/video/video_session.rs index 7b51f725..07af5f01 100644 --- a/src/video/video_session.rs +++ b/src/video/video_session.rs @@ -326,7 +326,6 @@ impl VideoSessionManager { bitrate_preset: self.config.bitrate_preset, fps: self.config.fps, encoder_backend: self.config.encoder_backend, - ..Default::default() }; // Create new pipeline diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index d7dd88e0..e4a16036 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -7,7 +7,11 @@ use std::sync::Arc; use crate::config::*; use crate::error::{AppError, Result}; use crate::events::SystemEvent; +use crate::rtsp::RtspService; use crate::state::AppState; +use crate::video::codec_constraints::{ + enforce_constraints_with_stream_manager, StreamCodecConstraints, +}; /// 应用 Video 配置变更 pub async fn apply_video_config( @@ -191,9 +195,7 @@ pub async fn apply_hid_config( // 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 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", @@ -446,6 +448,15 @@ pub async fn apply_audio_config( Ok(()) } +/// Apply stream codec constraints derived from global config. +pub async fn enforce_stream_codec_constraints(state: &Arc) -> Result> { + let config = state.config.get(); + let constraints = StreamCodecConstraints::from_config(&config); + let enforcement = + enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await?; + Ok(enforcement.message) +} + /// 应用 RustDesk 配置变更 pub async fn apply_rustdesk_config( state: &Arc, @@ -455,6 +466,7 @@ pub async fn apply_rustdesk_config( tracing::info!("Applying RustDesk config changes..."); let mut rustdesk_guard = state.rustdesk.write().await; + let mut credentials_to_save = None; // Check if service needs to be stopped if old_config.enabled && !new_config.enabled { @@ -466,7 +478,6 @@ pub async fn apply_rustdesk_config( tracing::info!("RustDesk service stopped"); } *rustdesk_guard = None; - return Ok(()); } // Check if service needs to be started or restarted @@ -475,8 +486,6 @@ pub async fn apply_rustdesk_config( || old_config.device_id != new_config.device_id || old_config.device_password != new_config.device_password; - let mut credentials_to_save = None; - if rustdesk_guard.is_none() { // Create new service tracing::info!("Initializing RustDesk service..."); @@ -509,28 +518,82 @@ pub async fn apply_rustdesk_config( } } } + } - // Save credentials to persistent config store (outside the lock) - drop(rustdesk_guard); - if let Some(updated_config) = credentials_to_save { - tracing::info!("Saving RustDesk credentials to config store..."); - if let Err(e) = state - .config - .update(|cfg| { - cfg.rustdesk.public_key = updated_config.public_key.clone(); - cfg.rustdesk.private_key = updated_config.private_key.clone(); - cfg.rustdesk.signing_public_key = updated_config.signing_public_key.clone(); - cfg.rustdesk.signing_private_key = updated_config.signing_private_key.clone(); - cfg.rustdesk.uuid = updated_config.uuid.clone(); - }) - .await - { - tracing::warn!("Failed to save RustDesk credentials: {}", e); - } else { - tracing::info!("RustDesk credentials saved successfully"); - } + // Save credentials to persistent config store (outside the lock) + drop(rustdesk_guard); + if let Some(updated_config) = credentials_to_save { + tracing::info!("Saving RustDesk credentials to config store..."); + if let Err(e) = state + .config + .update(|cfg| { + cfg.rustdesk.public_key = updated_config.public_key.clone(); + cfg.rustdesk.private_key = updated_config.private_key.clone(); + cfg.rustdesk.signing_public_key = updated_config.signing_public_key.clone(); + cfg.rustdesk.signing_private_key = updated_config.signing_private_key.clone(); + cfg.rustdesk.uuid = updated_config.uuid.clone(); + }) + .await + { + tracing::warn!("Failed to save RustDesk credentials: {}", e); + } else { + tracing::info!("RustDesk credentials saved successfully"); } } + if let Some(message) = enforce_stream_codec_constraints(state).await? { + tracing::info!("{}", message); + } + + Ok(()) +} + +/// 应用 RTSP 配置变更 +pub async fn apply_rtsp_config( + state: &Arc, + old_config: &RtspConfig, + new_config: &RtspConfig, +) -> Result<()> { + tracing::info!("Applying RTSP config changes..."); + + let mut rtsp_guard = state.rtsp.write().await; + + if old_config.enabled && !new_config.enabled { + if let Some(ref service) = *rtsp_guard { + if let Err(e) = service.stop().await { + tracing::error!("Failed to stop RTSP service: {}", e); + } + } + *rtsp_guard = None; + } + + if new_config.enabled { + let need_restart = old_config.bind != new_config.bind + || old_config.port != new_config.port + || old_config.path != new_config.path + || old_config.codec != new_config.codec + || old_config.username != new_config.username + || old_config.password != new_config.password + || old_config.allow_one_client != new_config.allow_one_client; + + if rtsp_guard.is_none() { + let service = RtspService::new(new_config.clone(), state.stream_manager.clone()); + service.start().await?; + tracing::info!("RTSP service started"); + *rtsp_guard = Some(Arc::new(service)); + } else if need_restart { + if let Some(ref service) = *rtsp_guard { + service.restart(new_config.clone()).await?; + tracing::info!("RTSP service restarted"); + } + } + } + + drop(rtsp_guard); + + if let Some(message) = enforce_stream_codec_constraints(state).await? { + tracing::info!("{}", message); + } + Ok(()) } diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index 6748ac20..b2a9d872 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -24,6 +24,7 @@ mod audio; mod auth; mod hid; mod msd; +mod rtsp; mod rustdesk; mod stream; pub(crate) mod video; @@ -35,6 +36,7 @@ pub use audio::{get_audio_config, update_audio_config}; pub use auth::{get_auth_config, update_auth_config}; pub use hid::{get_hid_config, update_hid_config}; pub use msd::{get_msd_config, update_msd_config}; +pub use rtsp::{get_rtsp_config, get_rtsp_status, update_rtsp_config}; pub use rustdesk::{ get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id, regenerate_device_password, update_rustdesk_config, @@ -50,10 +52,29 @@ use std::sync::Arc; use crate::config::AppConfig; use crate::state::AppState; +fn sanitize_config_for_api(config: &mut AppConfig) { + // Auth secrets + config.auth.totp_secret = None; + + // Stream secrets + config.stream.turn_password = None; + + // RustDesk secrets + config.rustdesk.device_password.clear(); + config.rustdesk.relay_key = None; + config.rustdesk.public_key = None; + config.rustdesk.private_key = None; + config.rustdesk.signing_public_key = None; + config.rustdesk.signing_private_key = None; + + // RTSP secrets + config.rtsp.password = None; +} + /// 获取完整配置 pub async fn get_all_config(State(state): State>) -> Json { let mut config = (*state.config.get()).clone(); // 不暴露敏感信息 - config.auth.totp_secret = None; + sanitize_config_for_api(&mut config); Json(config) } diff --git a/src/web/handlers/config/rtsp.rs b/src/web/handlers/config/rtsp.rs new file mode 100644 index 00000000..d8dcc846 --- /dev/null +++ b/src/web/handlers/config/rtsp.rs @@ -0,0 +1,70 @@ +use axum::{extract::State, Json}; +use std::sync::Arc; + +use crate::error::{AppError, Result}; +use crate::state::AppState; + +use super::apply::apply_rtsp_config; +use super::types::{RtspConfigResponse, RtspConfigUpdate, RtspStatusResponse}; + +/// Get RTSP config +pub async fn get_rtsp_config(State(state): State>) -> Json { + let config = state.config.get(); + Json(RtspConfigResponse::from(&config.rtsp)) +} + +/// Get RTSP status (config + service status) +pub async fn get_rtsp_status(State(state): State>) -> Json { + let config = state.config.get().rtsp.clone(); + let status = { + let guard = state.rtsp.read().await; + if let Some(ref service) = *guard { + service.status().await + } else { + crate::rtsp::RtspServiceStatus::Stopped + } + }; + + Json(RtspStatusResponse::new(&config, status)) +} + +/// Update RTSP config +pub async fn update_rtsp_config( + State(state): State>, + Json(req): Json, +) -> Result> { + req.validate()?; + + let old_config = state.config.get().rtsp.clone(); + + state + .config + .update(|config| { + req.apply_to(&mut config.rtsp); + }) + .await?; + + let new_config = state.config.get().rtsp.clone(); + if let Err(err) = apply_rtsp_config(&state, &old_config, &new_config).await { + tracing::error!("Failed to apply RTSP config: {}", err); + if let Err(rollback_err) = state + .config + .update(|config| { + config.rtsp = old_config.clone(); + }) + .await + { + tracing::error!( + "Failed to rollback RTSP config after apply failure: {}", + rollback_err + ); + return Err(AppError::ServiceUnavailable(format!( + "RTSP apply failed: {}; rollback failed: {}", + err, rollback_err + ))); + } + return Err(err); + } + + Ok(Json(RtspConfigResponse::from(&new_config))) +} diff --git a/src/web/handlers/config/rustdesk.rs b/src/web/handlers/config/rustdesk.rs index 29dbf3c9..9e1e0460 100644 --- a/src/web/handlers/config/rustdesk.rs +++ b/src/web/handlers/config/rustdesk.rs @@ -106,6 +106,15 @@ pub async fn update_rustdesk_config( tracing::error!("Failed to apply RustDesk config: {}", e); } + // Share a non-sensitive summary for frontend UX + let constraints = state.stream_manager.codec_constraints().await; + if constraints.rustdesk_enabled || constraints.rtsp_enabled { + tracing::info!( + "Stream codec constraints active after RustDesk update: {}", + constraints.reason + ); + } + Ok(Json(RustDeskConfigResponse::from(&new_config))) } @@ -139,7 +148,7 @@ pub async fn regenerate_device_password( Ok(Json(RustDeskConfigResponse::from(&new_config))) } -/// 获取设备密码(管理员专用) +/// 获取设备密码(已认证用户) pub async fn get_device_password(State(state): State>) -> Json { let config = state.config.get().rustdesk.clone(); Json(serde_json::json!({ diff --git a/src/web/handlers/config/stream.rs b/src/web/handlers/config/stream.rs index a5705ac7..5f0c3234 100644 --- a/src/web/handlers/config/stream.rs +++ b/src/web/handlers/config/stream.rs @@ -42,5 +42,10 @@ pub async fn update_stream_config( tracing::error!("Failed to apply stream config: {}", e); } + // 6. Enforce codec constraints after any stream config update + if let Err(e) = super::apply::enforce_stream_codec_constraints(&state).await { + tracing::error!("Failed to enforce stream codec constraints: {}", e); + } + Ok(Json(StreamConfigResponse::from(&new_stream_config))) } diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 3e500085..cffef3a1 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -1,5 +1,6 @@ use crate::config::*; use crate::error::AppError; +use crate::rtsp::RtspServiceStatus; use crate::rustdesk::config::RustDeskConfig; use crate::video::encoder::BitratePreset; use serde::Deserialize; @@ -604,6 +605,124 @@ impl RustDeskConfigUpdate { } } +// ===== RTSP Config ===== +#[typeshare] +#[derive(Debug, serde::Serialize)] +pub struct RtspConfigResponse { + pub enabled: bool, + pub bind: String, + pub port: u16, + pub path: String, + pub allow_one_client: bool, + pub codec: RtspCodec, + pub username: Option, + pub has_password: bool, +} + +impl From<&RtspConfig> for RtspConfigResponse { + fn from(config: &RtspConfig) -> Self { + Self { + enabled: config.enabled, + bind: config.bind.clone(), + port: config.port, + path: config.path.clone(), + allow_one_client: config.allow_one_client, + codec: config.codec.clone(), + username: config.username.clone(), + has_password: config.password.is_some(), + } + } +} + +#[typeshare] +#[derive(Debug, serde::Serialize)] +pub struct RtspStatusResponse { + pub config: RtspConfigResponse, + pub service_status: String, +} + +impl RtspStatusResponse { + pub fn new(config: &RtspConfig, status: RtspServiceStatus) -> Self { + Self { + config: RtspConfigResponse::from(config), + service_status: status.to_string(), + } + } +} + +#[typeshare] +#[derive(Debug, Deserialize)] +pub struct RtspConfigUpdate { + pub enabled: Option, + pub bind: Option, + pub port: Option, + pub path: Option, + pub allow_one_client: Option, + pub codec: Option, + pub username: Option, + pub password: Option, +} + +impl RtspConfigUpdate { + pub fn validate(&self) -> crate::error::Result<()> { + if let Some(port) = self.port { + if port == 0 { + return Err(AppError::BadRequest("RTSP port cannot be 0".into())); + } + } + + if let Some(ref bind) = self.bind { + if bind.parse::().is_err() { + return Err(AppError::BadRequest("RTSP bind must be a valid IP".into())); + } + } + + if let Some(ref path) = self.path { + let normalized = path.trim_matches('/'); + if normalized.is_empty() { + return Err(AppError::BadRequest("RTSP path cannot be empty".into())); + } + } + + Ok(()) + } + + pub fn apply_to(&self, config: &mut RtspConfig) { + if let Some(enabled) = self.enabled { + config.enabled = enabled; + } + if let Some(ref bind) = self.bind { + config.bind = bind.clone(); + } + if let Some(port) = self.port { + config.port = port; + } + if let Some(ref path) = self.path { + config.path = path.trim_matches('/').to_string(); + } + if let Some(allow_one_client) = self.allow_one_client { + config.allow_one_client = allow_one_client; + } + if let Some(codec) = self.codec.clone() { + config.codec = codec; + } + if let Some(ref username) = self.username { + config.username = if username.is_empty() { + None + } else { + Some(username.clone()) + }; + } + if let Some(ref password) = self.password { + config.password = if password.is_empty() { + None + } else { + Some(password.clone()) + }; + } + } +} + // ===== Web Config ===== #[typeshare] #[derive(Debug, Deserialize)] diff --git a/src/web/handlers/extensions.rs b/src/web/handlers/extensions.rs index f91cdb2b..ac0bf39d 100644 --- a/src/web/handlers/extensions.rs +++ b/src/web/handlers/extensions.rs @@ -86,7 +86,7 @@ pub async fn start_extension( // Start the extension mgr.start(ext_id, &config.extensions) .await - .map_err(|e| AppError::Internal(e))?; + .map_err(AppError::Internal)?; // Return updated status Ok(Json(ExtensionInfo { @@ -108,7 +108,7 @@ pub async fn stop_extension( let mgr = &state.extensions; // Stop the extension - mgr.stop(ext_id).await.map_err(|e| AppError::Internal(e))?; + mgr.stop(ext_id).await.map_err(AppError::Internal)?; // Return updated status Ok(Json(ExtensionInfo { @@ -156,7 +156,6 @@ pub struct TtydConfigUpdate { pub enabled: Option, pub port: Option, pub shell: Option, - pub credential: Option, } /// Update gostc config @@ -203,9 +202,6 @@ pub async fn update_ttyd_config( if let Some(ref shell) = req.shell { ttyd.shell = shell.clone(); } - if req.credential.is_some() { - ttyd.credential = req.credential.clone(); - } }) .await?; @@ -263,14 +259,16 @@ pub async fn update_gostc_config( if was_enabled && !is_enabled { state.extensions.stop(ExtensionId::Gostc).await.ok(); - } else if !was_enabled && is_enabled && has_key { - if state.extensions.check_available(ExtensionId::Gostc) { - state - .extensions - .start(ExtensionId::Gostc, &new_config.extensions) - .await - .ok(); - } + } else if !was_enabled + && is_enabled + && has_key + && state.extensions.check_available(ExtensionId::Gostc) + { + state + .extensions + .start(ExtensionId::Gostc, &new_config.extensions) + .await + .ok(); } Ok(Json(new_config.extensions.gostc.clone())) @@ -312,14 +310,16 @@ pub async fn update_easytier_config( if was_enabled && !is_enabled { state.extensions.stop(ExtensionId::Easytier).await.ok(); - } else if !was_enabled && is_enabled && has_name { - if state.extensions.check_available(ExtensionId::Easytier) { - state - .extensions - .start(ExtensionId::Easytier, &new_config.extensions) - .await - .ok(); - } + } else if !was_enabled + && is_enabled + && has_name + && state.extensions.check_available(ExtensionId::Easytier) + { + state + .extensions + .start(ExtensionId::Easytier, &new_config.extensions) + .await + .ok(); } Ok(Json(new_config.extensions.easytier.clone())) diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 7321d7bf..c2ad509d 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -14,6 +14,8 @@ use crate::config::{AppConfig, StreamMode}; use crate::error::{AppError, Result}; use crate::events::SystemEvent; use crate::state::AppState; +use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest}; +use crate::video::codec_constraints::codec_to_id; use crate::video::encoder::BitratePreset; // ============================================================================ @@ -181,31 +183,59 @@ fn get_hostname() -> String { .unwrap_or_else(|_| "unknown".to_string()) } -/// Get CPU model name from /proc/cpuinfo +/// Get CPU model name from /proc/cpuinfo, fallback to device-tree model fn get_cpu_model() -> String { - std::fs::read_to_string("/proc/cpuinfo") + let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").ok(); + + if let Some(model) = cpuinfo + .as_deref() + .and_then(parse_cpu_model_from_cpuinfo_content) + { + return model; + } + + if let Some(model) = read_device_tree_model() { + return model; + } + + if let Some(content) = cpuinfo.as_deref() { + let cores = content + .lines() + .filter(|line| line.starts_with("processor")) + .count(); + if cores > 0 { + return format!("{} {}C", std::env::consts::ARCH, cores); + } + } + + std::env::consts::ARCH.to_string() +} + +fn parse_cpu_model_from_cpuinfo_content(content: &str) -> Option { + content + .lines() + .find(|line| line.starts_with("model name") || line.starts_with("Model")) + .and_then(|line| line.split(':').nth(1)) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn read_device_tree_model() -> Option { + std::fs::read("/proc/device-tree/model") .ok() - .and_then(|content| { - // Try to get model name - let model = content - .lines() - .find(|line| line.starts_with("model name") || line.starts_with("Model")) - .and_then(|line| line.split(':').nth(1)) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); + .and_then(|bytes| parse_device_tree_model_bytes(&bytes)) +} - if model.is_some() { - return model; - } +fn parse_device_tree_model_bytes(bytes: &[u8]) -> Option { + let model = String::from_utf8_lossy(bytes) + .trim_matches(|c: char| c == '\0' || c.is_whitespace()) + .to_string(); - // Fallback: show arch and core count - let cores = content - .lines() - .filter(|line| line.starts_with("processor")) - .count(); - Some(format!("{} {}C", std::env::consts::ARCH, cores)) - }) - .unwrap_or_else(|| format!("{}", std::env::consts::ARCH)) + if model.is_empty() { + None + } else { + Some(model) + } } /// CPU usage state for calculating usage between samples @@ -387,6 +417,38 @@ fn get_network_addresses() -> Vec { addresses } +#[cfg(test)] +mod tests { + use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes}; + + #[test] + fn parse_cpu_model_from_model_name_field() { + let input = "processor\t: 0\nmodel name\t: Intel(R) Xeon(R)\n"; + assert_eq!( + parse_cpu_model_from_cpuinfo_content(input), + Some("Intel(R) Xeon(R)".to_string()) + ); + } + + #[test] + fn parse_cpu_model_from_model_field() { + let input = "processor\t: 0\nModel\t\t: Raspberry Pi 4 Model B Rev 1.4\n"; + assert_eq!( + parse_cpu_model_from_cpuinfo_content(input), + Some("Raspberry Pi 4 Model B Rev 1.4".to_string()) + ); + } + + #[test] + fn parse_device_tree_model_trimmed() { + let input = b"Onething OEC Box\0\n"; + assert_eq!( + parse_device_tree_model_bytes(input), + Some("Onething OEC Box".to_string()) + ); + } +} + // ============================================================================ // Authentication // ============================================================================ @@ -589,11 +651,8 @@ pub async fn setup_init( )); } - // Create admin user - state - .users - .create(&req.username, &req.password, true) - .await?; + // Create single system user + state.users.create(&req.username, &req.password).await?; // Update config state @@ -686,8 +745,7 @@ pub async fn setup_init( if matches!(new_config.hid.backend, crate::config::HidBackend::Otg) { let mut hid_functions = new_config.hid.effective_otg_functions(); - if let Some(udc) = - crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref()) + if let Some(udc) = crate::otg::configfs::resolve_udc_name(new_config.hid.otg_udc.as_deref()) { if crate::otg::configfs::is_low_endpoint_udc(&udc) && hid_functions.consumer { tracing::warn!( @@ -751,6 +809,18 @@ pub async fn setup_init( } } + // Start RTSP if enabled + if new_config.rtsp.enabled { + let empty_config = crate::config::RtspConfig::default(); + if let Err(e) = + config::apply::apply_rtsp_config(&state, &empty_config, &new_config.rtsp).await + { + tracing::warn!("Failed to start RTSP during setup: {}", e); + } else { + tracing::info!("RTSP started during setup"); + } + } + // Start audio streaming if audio device was selected during setup if new_config.audio.enabled { let audio_config = crate::audio::AudioControllerConfig { @@ -772,10 +842,7 @@ pub async fn setup_init( } } - tracing::info!( - "System initialized successfully with admin user: {}", - req.username - ); + tracing::info!("System initialized successfully"); Ok(Json(LoginResponse { success: true, @@ -800,7 +867,7 @@ pub async fn update_config( // Keep old config for rollback let old_config = state.config.get(); - tracing::info!("Received config update: {:?}", req.updates); + tracing::info!("Received config update request"); // Validate and merge config first (outside the update closure) let config_json = serde_json::to_value(&old_config) @@ -809,8 +876,6 @@ pub async fn update_config( let merged = merge_json(config_json, req.updates.clone()) .map_err(|_| AppError::Internal("Failed to merge config".to_string()))?; - tracing::debug!("Merged config: {:?}", merged); - let new_config: AppConfig = serde_json::from_value(merged) .map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?; @@ -1448,6 +1513,8 @@ pub async fn stream_mode_set( ) -> Result> { use crate::video::encoder::VideoCodecType; + let constraints = state.stream_manager.codec_constraints().await; + let mode_lower = req.mode.to_lowercase(); let (new_mode, video_codec) = match mode_lower.as_str() { "mjpeg" => (StreamMode::Mjpeg, None), @@ -1463,6 +1530,23 @@ pub async fn stream_mode_set( } }; + if new_mode == StreamMode::Mjpeg && !constraints.is_mjpeg_allowed() { + return Err(AppError::BadRequest(format!( + "Codec 'mjpeg' is not allowed: {}", + constraints.reason + ))); + } + + if let Some(codec) = video_codec { + if !constraints.is_webrtc_codec_allowed(codec) { + return Err(AppError::BadRequest(format!( + "Codec '{}' is not allowed: {}", + codec_to_id(codec), + constraints.reason + ))); + } + } + // Set video codec if switching to WebRTC mode with specific codec if let Some(codec) = video_codec { info!("Setting WebRTC video codec to {:?}", codec); @@ -1569,6 +1653,70 @@ pub struct AvailableCodecsResponse { pub codecs: Vec, } +/// Stream constraints response +#[derive(Serialize)] +pub struct StreamConstraintsResponse { + pub success: bool, + pub allowed_codecs: Vec, + pub locked_codec: Option, + pub disallow_mjpeg: bool, + pub sources: ConstraintSources, + pub reason: String, + pub current_mode: String, +} + +#[derive(Serialize)] +pub struct ConstraintSources { + pub rustdesk: bool, + pub rtsp: bool, +} + +/// Get stream codec constraints derived from enabled services. +pub async fn stream_constraints_get( + State(state): State>, +) -> Json { + use crate::video::encoder::VideoCodecType; + + let constraints = state.stream_manager.codec_constraints().await; + let current_mode = state.stream_manager.current_mode().await; + let current_mode = match current_mode { + StreamMode::Mjpeg => "mjpeg".to_string(), + StreamMode::WebRTC => { + let codec = state + .stream_manager + .webrtc_streamer() + .current_video_codec() + .await; + match codec { + VideoCodecType::H264 => "h264".to_string(), + VideoCodecType::H265 => "h265".to_string(), + VideoCodecType::VP8 => "vp8".to_string(), + VideoCodecType::VP9 => "vp9".to_string(), + } + } + }; + + Json(StreamConstraintsResponse { + success: true, + allowed_codecs: constraints + .allowed_codecs_for_api() + .into_iter() + .map(str::to_string) + .collect(), + locked_codec: constraints + .locked_codec + .map(codec_to_id) + .map(str::to_string), + disallow_mjpeg: !constraints.allow_mjpeg, + sources: ConstraintSources { + rustdesk: constraints.rustdesk_enabled, + rtsp: constraints.rtsp_enabled, + }, + reason: constraints.reason, + current_mode, + }) +} + /// Set bitrate request #[derive(Deserialize)] pub struct SetBitrateRequest { @@ -1842,12 +1990,14 @@ pub async fn mjpeg_stream( break; } // Send last frame again to keep connection alive - if let Some(frame) = handler_clone.current_frame() { - if frame.is_valid_jpeg() { - if tx.send(create_mjpeg_part(frame.data())).await.is_err() { - break; - } - } + let Some(frame) = handler_clone.current_frame() else { + continue; + }; + + if frame.is_valid_jpeg() + && tx.send(create_mjpeg_part(frame.data())).await.is_err() + { + break; } } } @@ -1866,7 +2016,7 @@ pub async fn mjpeg_stream( yield Ok::(data); // Record FPS after yield - data has been handed to Axum/hyper // This is closer to actual TCP send than recording at tx.send() - handler_for_stream.record_frame_sent(&guard_for_stream.id()); + handler_for_stream.record_frame_sent(guard_for_stream.id()); } }; @@ -1963,10 +2113,11 @@ pub async fn webrtc_offer( )); } - // Create session if client_id not provided + // Backward compatibility: `client_id` is treated as an existing session_id hint. + // New clients should not pass it; each offer creates a fresh session. let webrtc = state.stream_manager.webrtc_streamer(); let session_id = if let Some(client_id) = &req.client_id { - // Check if session exists + // Reuse only when it matches an active session ID. if webrtc.get_session(client_id).await.is_some() { client_id.clone() } else { @@ -2152,6 +2303,762 @@ pub struct HidStatus { pub screen_resolution: Option<(u32, u32)>, } +#[derive(Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum OtgSelfCheckLevel { + Info, + Warn, + Error, +} + +#[derive(Serialize)] +pub struct OtgSelfCheckItem { + pub id: &'static str, + pub ok: bool, + pub level: OtgSelfCheckLevel, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Serialize)] +pub struct OtgSelfCheckResponse { + pub overall_ok: bool, + pub error_count: usize, + pub warning_count: usize, + pub hid_backend: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_udc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bound_udc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub udc_state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub udc_speed: Option, + pub available_udcs: Vec, + pub other_gadgets: Vec, + pub checks: Vec, +} + +fn push_otg_check( + checks: &mut Vec, + id: &'static str, + ok: bool, + level: OtgSelfCheckLevel, + message: impl Into, + hint: Option>, + path: Option>, +) { + checks.push(OtgSelfCheckItem { + id, + ok, + level, + message: message.into(), + hint: hint.map(|v| v.into()), + path: path.map(|v| v.into()), + }); +} + +fn list_dir_names(path: &std::path::Path) -> Vec { + let mut names = std::fs::read_dir(path) + .ok() + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect::>(); + names.sort(); + names +} + +fn read_trimmed(path: &std::path::Path) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|value| value.trim().to_string()) +} + +fn proc_modules_has(module_name: &str) -> bool { + std::fs::read_to_string("/proc/modules") + .ok() + .map(|content| { + content + .lines() + .filter_map(|line| line.split_whitespace().next()) + .any(|name| name == module_name) + }) + .unwrap_or(false) +} + +fn modules_metadata_has(module_name: &str) -> bool { + let kernel_release = match read_trimmed(std::path::Path::new("/proc/sys/kernel/osrelease")) { + Some(value) if !value.is_empty() => value, + _ => return false, + }; + + let module_dir = std::path::Path::new("/lib/modules").join(kernel_release); + let candidates = ["modules.builtin", "modules.builtin.modinfo", "modules.dep"]; + + candidates.iter().any(|filename| { + let path = module_dir.join(filename); + std::fs::read_to_string(path) + .ok() + .map(|content| { + let module_token = format!("/{module_name}.ko"); + content.lines().any(|line| { + line.contains(&module_token) + || line.contains(module_name) + || line.contains(&module_name.replace('_', "-")) + }) + }) + .unwrap_or(false) + }) +} + +fn kernel_config_option_enabled(option_name: &str) -> bool { + let kernel_release = match read_trimmed(std::path::Path::new("/proc/sys/kernel/osrelease")) { + Some(value) if !value.is_empty() => value, + _ => return false, + }; + + let config_paths = [ + std::path::PathBuf::from(format!("/boot/config-{kernel_release}")), + std::path::PathBuf::from("/boot/config"), + std::path::PathBuf::from(format!("/lib/modules/{kernel_release}/build/.config")), + ]; + + config_paths.iter().any(|path| { + std::fs::read_to_string(path) + .ok() + .map(|content| { + let enabled_y = format!("{option_name}=y"); + let enabled_m = format!("{option_name}=m"); + content + .lines() + .any(|line| line == enabled_y || line == enabled_m) + }) + .unwrap_or(false) + }) +} + +fn detect_libcomposite_available(gadget_root: &std::path::Path) -> bool { + let sys_module = std::path::Path::new("/sys/module/libcomposite").exists(); + if sys_module { + return true; + } + + if proc_modules_has("libcomposite") { + return true; + } + + if modules_metadata_has("libcomposite") { + return true; + } + + if kernel_config_option_enabled("CONFIG_USB_LIBCOMPOSITE") + || kernel_config_option_enabled("CONFIG_USB_CONFIGFS") + { + return true; + } + + // Fallback: if usb_gadget path exists, libcomposite may be built-in and already active. + gadget_root.exists() +} + +/// OTG self-check status for troubleshooting USB gadget issues +pub async fn hid_otg_self_check(State(state): State>) -> Json { + let config = state.config.get(); + let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg); + let mut checks = Vec::new(); + + let build_response = |checks: Vec, + selected_udc: Option, + bound_udc: Option, + udc_state: Option, + udc_speed: Option, + available_udcs: Vec, + other_gadgets: Vec| { + let error_count = checks + .iter() + .filter(|item| item.level == OtgSelfCheckLevel::Error) + .count(); + let warning_count = checks + .iter() + .filter(|item| item.level == OtgSelfCheckLevel::Warn) + .count(); + + Json(OtgSelfCheckResponse { + overall_ok: error_count == 0, + error_count, + warning_count, + hid_backend: format!("{:?}", config.hid.backend).to_lowercase(), + selected_udc, + bound_udc, + udc_state, + udc_speed, + available_udcs, + other_gadgets, + checks, + }) + }; + + let udc_root = std::path::Path::new("/sys/class/udc"); + let available_udcs = list_dir_names(udc_root); + let selected_udc = config + .hid + .otg_udc + .clone() + .filter(|udc| !udc.trim().is_empty()) + .or_else(|| available_udcs.first().cloned()); + let mut udc_stage_ok = true; + if !udc_root.exists() { + udc_stage_ok = false; + push_otg_check( + &mut checks, + "udc_dir_exists", + false, + OtgSelfCheckLevel::Error, + "Check /sys/class/udc existence", + Some("Ensure UDC/OTG kernel drivers are enabled"), + Some("/sys/class/udc"), + ); + } else if available_udcs.is_empty() { + udc_stage_ok = false; + push_otg_check( + &mut checks, + "udc_has_entries", + false, + OtgSelfCheckLevel::Error, + "Check available UDC entries", + Some("Ensure OTG controller is enabled in device tree"), + Some("/sys/class/udc"), + ); + } else { + push_otg_check( + &mut checks, + "udc_has_entries", + true, + OtgSelfCheckLevel::Info, + "Check available UDC entries", + None::, + Some("/sys/class/udc"), + ); + } + + let mut configured_udc_ok = true; + if let Some(config_udc) = config + .hid + .otg_udc + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + if available_udcs.iter().any(|item| item == &config_udc) { + push_otg_check( + &mut checks, + "configured_udc_valid", + true, + OtgSelfCheckLevel::Info, + "Check configured UDC validity", + None::, + Some("/sys/class/udc"), + ); + } else { + configured_udc_ok = false; + push_otg_check( + &mut checks, + "configured_udc_valid", + false, + OtgSelfCheckLevel::Error, + "Check configured UDC validity", + Some("Please reselect UDC in HID OTG settings"), + Some("/sys/class/udc"), + ); + } + } else { + push_otg_check( + &mut checks, + "configured_udc_valid", + !available_udcs.is_empty(), + if available_udcs.is_empty() { + OtgSelfCheckLevel::Warn + } else { + OtgSelfCheckLevel::Info + }, + "Check configured UDC validity", + Some( + "You can set hid_otg_udc in settings to avoid ambiguity in multi-controller setups", + ), + Some("/sys/class/udc"), + ); + } + + if !udc_stage_ok || !configured_udc_ok { + return build_response( + checks, + selected_udc, + None, + None, + None, + available_udcs, + vec![], + ); + } + + let gadget_root = std::path::Path::new("/sys/kernel/config/usb_gadget"); + let configfs_mounted = std::fs::read_to_string("/proc/mounts") + .ok() + .map(|mounts| { + mounts.lines().any(|line| { + let mut parts = line.split_whitespace(); + let _src = parts.next(); + let mount_point = parts.next(); + let fs_type = parts.next(); + mount_point == Some("/sys/kernel/config") && fs_type == Some("configfs") + }) + }) + .unwrap_or(false); + + let mut gadget_config_ok = true; + + if configfs_mounted { + push_otg_check( + &mut checks, + "configfs_mounted", + true, + OtgSelfCheckLevel::Info, + "Check configfs mount status", + None::, + Some("/sys/kernel/config"), + ); + } else { + gadget_config_ok = false; + push_otg_check( + &mut checks, + "configfs_mounted", + false, + OtgSelfCheckLevel::Error, + "Check configfs mount status", + Some("Try: mount -t configfs none /sys/kernel/config"), + Some("/sys/kernel/config"), + ); + } + + if gadget_root.exists() { + push_otg_check( + &mut checks, + "usb_gadget_dir_exists", + true, + OtgSelfCheckLevel::Info, + "Check /sys/kernel/config/usb_gadget access", + None::, + Some("/sys/kernel/config/usb_gadget"), + ); + } else { + gadget_config_ok = false; + push_otg_check( + &mut checks, + "usb_gadget_dir_exists", + false, + OtgSelfCheckLevel::Error, + "Check /sys/kernel/config/usb_gadget access", + Some("Ensure configfs and USB gadget support are enabled"), + Some("/sys/kernel/config/usb_gadget"), + ); + } + + let libcomposite_available = detect_libcomposite_available(gadget_root); + if libcomposite_available { + push_otg_check( + &mut checks, + "libcomposite_loaded", + true, + OtgSelfCheckLevel::Info, + "Check libcomposite module status", + None::, + Some("/sys/module/libcomposite"), + ); + } else { + gadget_config_ok = false; + push_otg_check( + &mut checks, + "libcomposite_loaded", + false, + OtgSelfCheckLevel::Error, + "Check libcomposite module status", + Some("Try: modprobe libcomposite"), + Some("/sys/module/libcomposite"), + ); + } + + if !gadget_config_ok { + return build_response( + checks, + selected_udc, + None, + None, + None, + available_udcs, + vec![], + ); + } + + let gadget_names = list_dir_names(gadget_root); + let one_kvm_path = gadget_root.join("one-kvm"); + let one_kvm_exists = one_kvm_path.exists(); + if one_kvm_exists { + push_otg_check( + &mut checks, + "one_kvm_gadget_exists", + true, + OtgSelfCheckLevel::Info, + "Check one-kvm gadget presence", + None::, + Some(one_kvm_path.display().to_string()), + ); + } else { + push_otg_check( + &mut checks, + "one_kvm_gadget_exists", + false, + if hid_backend_is_otg { + OtgSelfCheckLevel::Error + } else { + OtgSelfCheckLevel::Warn + }, + "Check one-kvm gadget presence", + Some("Enable OTG HID or MSD to let one-kvm gadget be created automatically"), + Some(one_kvm_path.display().to_string()), + ); + } + + let other_gadgets = gadget_names + .iter() + .filter(|name| name.as_str() != "one-kvm") + .cloned() + .collect::>(); + if other_gadgets.is_empty() { + push_otg_check( + &mut checks, + "other_gadgets", + true, + OtgSelfCheckLevel::Info, + "Check for other gadget services", + None::, + Some("/sys/kernel/config/usb_gadget"), + ); + } else { + push_otg_check( + &mut checks, + "other_gadgets", + false, + OtgSelfCheckLevel::Warn, + "Check for other gadget services", + Some("Potential UDC contention with one-kvm; check other OTG services"), + Some("/sys/kernel/config/usb_gadget"), + ); + } + + let mut bound_udc = None; + + if one_kvm_exists { + let one_kvm_udc_path = one_kvm_path.join("UDC"); + let current_udc = read_trimmed(&one_kvm_udc_path).unwrap_or_default(); + if current_udc.is_empty() { + push_otg_check( + &mut checks, + "one_kvm_bound_udc", + false, + OtgSelfCheckLevel::Warn, + "Check one-kvm UDC binding", + Some("Ensure HID/MSD is enabled and initialized successfully"), + Some(one_kvm_udc_path.display().to_string()), + ); + } else { + push_otg_check( + &mut checks, + "one_kvm_bound_udc", + true, + OtgSelfCheckLevel::Info, + "Check one-kvm UDC binding", + None::, + Some(one_kvm_udc_path.display().to_string()), + ); + bound_udc = Some(current_udc); + } + + let functions_path = one_kvm_path.join("functions"); + let function_names = list_dir_names(&functions_path) + .into_iter() + .filter(|name| name.contains(".usb")) + .collect::>(); + let hid_functions = function_names + .iter() + .filter(|name| name.starts_with("hid.usb")) + .cloned() + .collect::>(); + if hid_functions.is_empty() { + push_otg_check( + &mut checks, + "hid_functions_present", + false, + if hid_backend_is_otg { + OtgSelfCheckLevel::Error + } else { + OtgSelfCheckLevel::Warn + }, + "Check HID function creation", + Some("Check OTG HID config and enable at least one HID function"), + Some(functions_path.display().to_string()), + ); + } else { + push_otg_check( + &mut checks, + "hid_functions_present", + true, + OtgSelfCheckLevel::Info, + "Check HID function creation", + None::, + Some(functions_path.display().to_string()), + ); + } + + let config_path = one_kvm_path.join("configs/c.1"); + if !config_path.exists() { + push_otg_check( + &mut checks, + "config_c1_exists", + false, + OtgSelfCheckLevel::Error, + "Check configs/c.1 structure", + Some("Gadget structure is incomplete; try restarting One-KVM"), + Some(config_path.display().to_string()), + ); + } else { + push_otg_check( + &mut checks, + "config_c1_exists", + true, + OtgSelfCheckLevel::Info, + "Check configs/c.1 structure", + None::, + Some(config_path.display().to_string()), + ); + + let linked_functions = list_dir_names(&config_path) + .into_iter() + .filter(|name| name.contains(".usb")) + .collect::>(); + let missing_links = function_names + .iter() + .filter(|func| !linked_functions.iter().any(|link| link == *func)) + .cloned() + .collect::>(); + + if missing_links.is_empty() { + push_otg_check( + &mut checks, + "function_links_ok", + true, + OtgSelfCheckLevel::Info, + "Check function links in configs/c.1", + None::, + Some(config_path.display().to_string()), + ); + } else { + push_otg_check( + &mut checks, + "function_links_ok", + false, + OtgSelfCheckLevel::Warn, + "Check function links in configs/c.1", + Some("Reinitialize OTG (toggle HID backend once or restart service)"), + Some(config_path.display().to_string()), + ); + } + } + + let missing_hid_devices = hid_functions + .iter() + .filter_map(|name| { + let index = name.strip_prefix("hid.usb")?.parse::().ok()?; + let dev_path = std::path::PathBuf::from(format!("/dev/hidg{}", index)); + if dev_path.exists() { + None + } else { + Some(dev_path.display().to_string()) + } + }) + .collect::>(); + + if !hid_functions.is_empty() { + if missing_hid_devices.is_empty() { + push_otg_check( + &mut checks, + "hid_device_nodes", + true, + OtgSelfCheckLevel::Info, + "Check /dev/hidg* device nodes", + None::, + Some("/dev/hidg*"), + ); + } else { + push_otg_check( + &mut checks, + "hid_device_nodes", + false, + OtgSelfCheckLevel::Warn, + "Check /dev/hidg* device nodes", + Some("Ensure gadget is bound and check kernel logs"), + Some("/dev/hidg*"), + ); + } + } + } + + if !other_gadgets.is_empty() { + let check_udc = bound_udc.clone().or_else(|| selected_udc.clone()); + if let Some(target_udc) = check_udc { + let conflicting_gadgets = other_gadgets + .iter() + .filter_map(|name| { + let udc_file = gadget_root.join(name).join("UDC"); + let udc = read_trimmed(&udc_file)?; + if udc == target_udc { + Some(name.clone()) + } else { + None + } + }) + .collect::>(); + + if conflicting_gadgets.is_empty() { + push_otg_check( + &mut checks, + "udc_conflict", + true, + OtgSelfCheckLevel::Info, + "Check UDC binding conflicts", + None::, + Some("/sys/kernel/config/usb_gadget/*/UDC"), + ); + } else { + push_otg_check( + &mut checks, + "udc_conflict", + false, + OtgSelfCheckLevel::Error, + "Check UDC binding conflicts", + Some("Stop other OTG services or switch one-kvm to an idle UDC"), + Some("/sys/kernel/config/usb_gadget/*/UDC"), + ); + } + } + } + + let active_udc = bound_udc.clone().or_else(|| selected_udc.clone()); + let mut udc_state = None; + let mut udc_speed = None; + + if let Some(udc) = active_udc.clone() { + let state_path = udc_root.join(&udc).join("state"); + match read_trimmed(&state_path) { + Some(state_name) if state_name.eq_ignore_ascii_case("configured") => { + udc_state = Some(state_name.clone()); + push_otg_check( + &mut checks, + "udc_state", + true, + OtgSelfCheckLevel::Info, + "Check UDC connection state", + None::, + Some(state_path.display().to_string()), + ); + } + Some(state_name) => { + udc_state = Some(state_name.clone()); + push_otg_check( + &mut checks, + "udc_state", + false, + OtgSelfCheckLevel::Warn, + "Check UDC connection state", + Some("Ensure target host is connected and has recognized the USB device"), + Some(state_path.display().to_string()), + ); + } + None => { + push_otg_check( + &mut checks, + "udc_state", + false, + OtgSelfCheckLevel::Warn, + "Check UDC connection state", + Some("Ensure UDC name is valid and check kernel permissions"), + Some(state_path.display().to_string()), + ); + } + } + + let speed_path = udc_root.join(&udc).join("current_speed"); + if let Some(speed) = read_trimmed(&speed_path) { + udc_speed = Some(speed.clone()); + let is_unknown = speed.eq_ignore_ascii_case("unknown"); + push_otg_check( + &mut checks, + "udc_speed", + !is_unknown, + if is_unknown { + OtgSelfCheckLevel::Warn + } else { + OtgSelfCheckLevel::Info + }, + "Check UDC current link speed", + if is_unknown { + Some("Device may not be fully enumerated; try reconnecting USB".to_string()) + } else { + None + }, + Some(speed_path.display().to_string()), + ); + } + } else { + push_otg_check( + &mut checks, + "udc_state", + false, + OtgSelfCheckLevel::Warn, + "Check UDC connection state", + Some("Ensure UDC is available and one-kvm gadget is bound first"), + Some("/sys/class/udc"), + ); + } + + let error_count = checks + .iter() + .filter(|item| item.level == OtgSelfCheckLevel::Error) + .count(); + let warning_count = checks + .iter() + .filter(|item| item.level == OtgSelfCheckLevel::Warn) + .count(); + + Json(OtgSelfCheckResponse { + overall_ok: error_count == 0, + error_count, + warning_count, + hid_backend: format!("{:?}", config.hid.backend).to_lowercase(), + selected_udc, + bound_udc, + udc_state, + udc_speed, + available_udcs, + other_gadgets, + checks, + }) +} + /// Get HID status pub async fn hid_status(State(state): State>) -> Json { let info = state.hid.info().await; @@ -2516,7 +3423,7 @@ pub async fn msd_drive_download( let (file_size, mut rx) = drive.read_file_stream(&file_path).await?; // Extract filename for Content-Disposition - let filename = file_path.split('/').last().unwrap_or("download"); + let filename = file_path.split('/').next_back().unwrap_or("download"); // Create a stream from the channel receiver let body_stream = async_stream::stream! { @@ -2577,6 +3484,10 @@ pub async fn msd_drive_mkdir( use crate::atx::{AtxState, PowerStatus}; +const WOL_HISTORY_MAX_ENTRIES: i64 = 50; +const WOL_HISTORY_DEFAULT_LIMIT: usize = 5; +const WOL_HISTORY_MAX_LIMIT: usize = 50; + /// ATX state response #[derive(Serialize)] pub struct AtxStateResponse { @@ -2683,11 +3594,78 @@ pub struct WolRequest { pub mac_address: String, } +#[derive(Debug, Deserialize, Default)] +pub struct WolHistoryQuery { + /// Maximum history entries to return + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct WolHistoryEntry { + pub mac_address: String, + pub updated_at: i64, +} + +#[derive(Debug, Serialize)] +pub struct WolHistoryResponse { + pub history: Vec, +} + +fn normalize_wol_mac_address(mac_address: &str) -> String { + let normalized = mac_address.trim().to_uppercase().replace('-', ":"); + + if normalized.len() == 12 && normalized.chars().all(|c| c.is_ascii_hexdigit()) { + let mut mac_with_separator = String::with_capacity(17); + for (index, chunk) in normalized.as_bytes().chunks(2).enumerate() { + if index > 0 { + mac_with_separator.push(':'); + } + mac_with_separator.push(chunk[0] as char); + mac_with_separator.push(chunk[1] as char); + } + mac_with_separator + } else { + normalized + } +} + +async fn record_wol_history(state: &Arc, mac_address: &str) -> Result<()> { + sqlx::query( + r#" + INSERT INTO wol_history (mac_address, updated_at) + VALUES (?1, CAST(strftime('%s', 'now') AS INTEGER)) + ON CONFLICT(mac_address) DO UPDATE SET + updated_at = excluded.updated_at + "#, + ) + .bind(mac_address) + .execute(state.config.pool()) + .await?; + + sqlx::query( + r#" + DELETE FROM wol_history + WHERE mac_address NOT IN ( + SELECT mac_address FROM wol_history + ORDER BY updated_at DESC + LIMIT ?1 + ) + "#, + ) + .bind(WOL_HISTORY_MAX_ENTRIES) + .execute(state.config.pool()) + .await?; + + Ok(()) +} + /// Send Wake-on-LAN magic packet pub async fn atx_wol( State(state): State>, Json(req): Json, ) -> Result> { + let mac_address = normalize_wol_mac_address(&req.mac_address); + // Get WOL interface from config let config = state.config.get(); let interface = if config.atx.wol_interface.is_empty() { @@ -2697,14 +3675,51 @@ pub async fn atx_wol( }; // Send WOL packet - crate::atx::send_wol(&req.mac_address, interface)?; + crate::atx::send_wol(&mac_address, interface)?; + + if let Err(error) = record_wol_history(&state, &mac_address).await { + warn!("Failed to persist WOL history: {}", error); + } Ok(Json(LoginResponse { success: true, - message: Some(format!("WOL packet sent to {}", req.mac_address)), + message: Some(format!("WOL packet sent to {}", mac_address)), })) } +/// Get WOL history +pub async fn atx_wol_history( + State(state): State>, + Query(query): Query, +) -> Result> { + let limit = query + .limit + .unwrap_or(WOL_HISTORY_DEFAULT_LIMIT) + .clamp(1, WOL_HISTORY_MAX_LIMIT); + + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT mac_address, updated_at + FROM wol_history + ORDER BY updated_at DESC + LIMIT ?1 + "#, + ) + .bind(limit as i64) + .fetch_all(state.config.pool()) + .await?; + + let history = rows + .into_iter() + .map(|(mac_address, updated_at)| WolHistoryEntry { + mac_address, + updated_at, + }) + .collect(); + + Ok(Json(WolHistoryResponse { history })) +} + // ============================================================================ // Audio Control // ============================================================================ @@ -2940,3 +3955,37 @@ pub async fn system_restart(State(state): State>) -> Json, +} + +pub async fn update_overview( + State(state): State>, + axum::extract::Query(query): axum::extract::Query, +) -> Result> { + let channel = query.channel.unwrap_or(UpdateChannel::Stable); + let response = state.update.overview(channel).await?; + Ok(Json(response)) +} + +pub async fn update_upgrade( + State(state): State>, + Json(req): Json, +) -> Result> { + state.update.start_upgrade(req, state.shutdown_tx.clone())?; + + Ok(Json(LoginResponse { + success: true, + message: Some("Upgrade started".to_string()), + })) +} + +pub async fn update_status(State(state): State>) -> Json { + Json(state.update.status().await) +} diff --git a/src/web/routes.rs b/src/web/routes.rs index 02e74bf5..06489a2a 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -50,6 +50,7 @@ pub fn create_router(state: Arc) -> Router { .route("/stream/mode", post(handlers::stream_mode_set)) .route("/stream/bitrate", post(handlers::stream_set_bitrate)) .route("/stream/codecs", get(handlers::stream_codecs_list)) + .route("/stream/constraints", get(handlers::stream_constraints_get)) // WebRTC endpoints .route("/webrtc/session", post(handlers::webrtc_create_session)) .route("/webrtc/offer", post(handlers::webrtc_offer)) @@ -59,6 +60,7 @@ pub fn create_router(state: Arc) -> Router { .route("/webrtc/close", post(handlers::webrtc_close_session)) // HID endpoints .route("/hid/status", get(handlers::hid_status)) + .route("/hid/otg/self-check", get(handlers::hid_otg_self_check)) .route("/hid/reset", post(handlers::hid_reset)) // WebSocket HID endpoint (for MJPEG mode) .route("/ws/hid", any(ws_hid_handler)) @@ -120,6 +122,13 @@ pub fn create_router(state: Arc) -> Router { "/config/rustdesk/regenerate-password", post(handlers::config::regenerate_device_password), ) + // RTSP configuration endpoints + .route("/config/rtsp", get(handlers::config::get_rtsp_config)) + .route("/config/rtsp", patch(handlers::config::update_rtsp_config)) + .route( + "/config/rtsp/status", + get(handlers::config::get_rtsp_status), + ) // Web server configuration .route("/config/web", get(handlers::config::get_web_config)) .route("/config/web", patch(handlers::config::update_web_config)) @@ -128,6 +137,9 @@ pub fn create_router(state: Arc) -> Router { .route("/config/auth", patch(handlers::config::update_auth_config)) // System control .route("/system/restart", post(handlers::system_restart)) + .route("/update/overview", get(handlers::update_overview)) + .route("/update/upgrade", post(handlers::update_upgrade)) + .route("/update/status", get(handlers::update_status)) // MSD (Mass Storage Device) endpoints .route("/msd/status", get(handlers::msd_status)) .route("/msd/images", get(handlers::msd_images_list)) @@ -158,6 +170,7 @@ pub fn create_router(state: Arc) -> Router { .route("/atx/status", get(handlers::atx_status)) .route("/atx/power", post(handlers::atx_power)) .route("/atx/wol", post(handlers::atx_wol)) + .route("/atx/wol/history", get(handlers::atx_wol_history)) // Device discovery endpoints .route("/devices/atx", get(handlers::devices::list_atx_devices)) // Extension management endpoints diff --git a/src/web/static_files.rs b/src/web/static_files.rs index 3fb84bbf..44af069b 100644 --- a/src/web/static_files.rs +++ b/src/web/static_files.rs @@ -127,14 +127,14 @@ fn try_serve_file(path: &str) -> Option> { .first_or_octet_stream() .to_string(); - return Some( + Some( Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, mime) .header(header::CACHE_CONTROL, "public, max-age=86400") .body(Body::from(data)) .unwrap(), - ); + ) } Err(e) => { tracing::debug!( @@ -143,7 +143,7 @@ fn try_serve_file(path: &str) -> Option> { file_path.display(), e ); - return None; + None } } } diff --git a/src/webrtc/config.rs b/src/webrtc/config.rs index 3b5c79c3..86b41a91 100644 --- a/src/webrtc/config.rs +++ b/src/webrtc/config.rs @@ -108,19 +108,15 @@ impl TurnServer { /// Video codec preference #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum VideoCodec { + #[default] H264, VP8, VP9, AV1, } -impl Default for VideoCodec { - fn default() -> Self { - Self::H264 - } -} - impl std::fmt::Display for VideoCodec { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/webrtc/mdns.rs b/src/webrtc/mdns.rs index 61e65c2e..7cf80907 100644 --- a/src/webrtc/mdns.rs +++ b/src/webrtc/mdns.rs @@ -18,7 +18,9 @@ pub fn mdns_mode_from_env() -> Option { } pub fn mdns_mode() -> MulticastDnsMode { - mdns_mode_from_env().unwrap_or(MulticastDnsMode::QueryAndGather) + // Default to QueryOnly to avoid gathering .local host candidates by default. + // This is generally more stable for LAN first-connection while preserving mDNS queries. + mdns_mode_from_env().unwrap_or(MulticastDnsMode::QueryOnly) } pub fn mdns_mode_label(mode: MulticastDnsMode) -> &'static str { diff --git a/src/webrtc/peer.rs b/src/webrtc/peer.rs index 767a0ba3..5dbbb8e0 100644 --- a/src/webrtc/peer.rs +++ b/src/webrtc/peer.rs @@ -93,7 +93,6 @@ impl PeerConnection { urls: turn.urls.clone(), username: turn.username.clone(), credential: turn.credential.clone(), - ..Default::default() }); } @@ -318,14 +317,26 @@ impl PeerConnection { .await .map_err(|e| AppError::VideoError(format!("Failed to create answer: {}", e)))?; + // Wait for ICE gathering complete (or timeout) after setting local description. + // This improves first-connection robustness by returning a fuller initial candidate set. + let mut gather_complete = self.pc.gathering_complete_promise().await; + // Set local description self.pc .set_local_description(answer.clone()) .await .map_err(|e| AppError::VideoError(format!("Failed to set local description: {}", e)))?; - // Wait a bit for ICE candidates to gather - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + const ICE_GATHER_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_millis(2500); + if tokio::time::timeout(ICE_GATHER_TIMEOUT, gather_complete.recv()) + .await + .is_err() + { + debug!( + "ICE gathering timeout after {:?} for session {}", + ICE_GATHER_TIMEOUT, self.session_id + ); + } // Get gathered ICE candidates let candidates = self.ice_candidates.lock().await.clone(); diff --git a/src/webrtc/rtp.rs b/src/webrtc/rtp.rs index e8dac17b..f576f4e9 100644 --- a/src/webrtc/rtp.rs +++ b/src/webrtc/rtp.rs @@ -330,9 +330,7 @@ impl OpusAudioTrack { stream_id.to_string(), )); - Self { - track, - } + Self { track } } /// Get the underlying WebRTC track @@ -365,13 +363,10 @@ impl OpusAudioTrack { ..Default::default() }; - self.track - .write_sample(&sample) - .await - .map_err(|e| { - error!("Failed to write Opus sample: {}", e); - AppError::WebRtcError(format!("Failed to write audio sample: {}", e)) - }) + self.track.write_sample(&sample).await.map_err(|e| { + error!("Failed to write Opus sample: {}", e); + AppError::WebRtcError(format!("Failed to write audio sample: {}", e)) + }) } } diff --git a/src/webrtc/track.rs b/src/webrtc/track.rs index f9617df2..d3787c63 100644 --- a/src/webrtc/track.rs +++ b/src/webrtc/track.rs @@ -199,7 +199,7 @@ impl VideoTrack { let data = frame.data(); let max_payload_size = 1200; // MTU - headers - let packet_count = (data.len() + max_payload_size - 1) / max_payload_size; + let packet_count = data.len().div_ceil(max_payload_size); let mut bytes_sent = 0u64; for i in 0..packet_count { diff --git a/src/webrtc/universal_session.rs b/src/webrtc/universal_session.rs index b62bc89f..eafda89f 100644 --- a/src/webrtc/universal_session.rs +++ b/src/webrtc/universal_session.rs @@ -292,7 +292,6 @@ impl UniversalSession { urls: turn.urls.clone(), username: turn.username.clone(), credential: turn.credential.clone(), - ..Default::default() }); } @@ -430,7 +429,9 @@ impl UniversalSession { let candidate = IceCandidate { candidate: candidate_str, sdp_mid: candidate_json.as_ref().and_then(|j| j.sdp_mid.clone()), - sdp_mline_index: candidate_json.as_ref().and_then(|j| j.sdp_mline_index), + sdp_mline_index: candidate_json + .as_ref() + .and_then(|j| j.sdp_mline_index), username_fragment: candidate_json .as_ref() .and_then(|j| j.username_fragment.clone()), @@ -615,20 +616,15 @@ impl UniversalSession { }; // Verify codec matches - let frame_codec = match encoded_frame.codec { - VideoEncoderType::H264 => VideoEncoderType::H264, - VideoEncoderType::H265 => VideoEncoderType::H265, - VideoEncoderType::VP8 => VideoEncoderType::VP8, - VideoEncoderType::VP9 => VideoEncoderType::VP9, - }; + let frame_codec = encoded_frame.codec; if frame_codec != expected_codec { continue; } // Debug log for H265 frames - if expected_codec == VideoEncoderType::H265 { - if encoded_frame.is_keyframe || frames_sent % 30 == 0 { + if expected_codec == VideoEncoderType::H265 + && (encoded_frame.is_keyframe || frames_sent.is_multiple_of(30)) { debug!( "[Session-H265] Received frame #{}: size={}, keyframe={}, seq={}", frames_sent, @@ -637,7 +633,6 @@ impl UniversalSession { encoded_frame.sequence ); } - } // Ensure decoder starts from a keyframe and recover on gaps. let mut gap_detected = false; @@ -768,7 +763,7 @@ impl UniversalSession { // 20ms at 48kHz = 960 samples let samples = 960u32; if let Err(e) = audio_track.write_packet(&opus_frame.data, samples).await { - if packets_sent % 100 == 0 { + if packets_sent.is_multiple_of(100) { debug!("Failed to write audio packet: {}", e); } } else { @@ -838,13 +833,24 @@ impl UniversalSession { } } + let mut gather_complete = self.pc.gathering_complete_promise().await; + self.pc .set_local_description(answer.clone()) .await .map_err(|e| AppError::VideoError(format!("Failed to set local description: {}", e)))?; - // Wait for ICE candidates - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + // Wait for ICE gathering complete (or timeout) to return a fuller initial candidate set. + const ICE_GATHER_TIMEOUT: Duration = Duration::from_millis(2500); + if tokio::time::timeout(ICE_GATHER_TIMEOUT, gather_complete.recv()) + .await + .is_err() + { + debug!( + "ICE gathering timeout after {:?} for session {}", + ICE_GATHER_TIMEOUT, self.session_id + ); + } let candidates = self.ice_candidates.lock().await.clone(); Ok(SdpAnswer::with_candidates(answer.sdp, candidates)) diff --git a/src/webrtc/video_track.rs b/src/webrtc/video_track.rs index 7ad4c99c..7fe5a8b3 100644 --- a/src/webrtc/video_track.rs +++ b/src/webrtc/video_track.rs @@ -285,7 +285,7 @@ impl UniversalVideoTrack { } /// Get current statistics - + /// /// Write an encoded frame to the track /// /// Handles codec-specific processing: @@ -464,7 +464,6 @@ impl UniversalVideoTrack { if let Err(e) = rtp_track.write_rtp(&packet).await { trace!("H265 write_rtp failed: {}", e); } - } Ok(()) diff --git a/src/webrtc/webrtc_streamer.rs b/src/webrtc/webrtc_streamer.rs index 44ed2b13..b3e6e276 100644 --- a/src/webrtc/webrtc_streamer.rs +++ b/src/webrtc/webrtc_streamer.rs @@ -35,8 +35,8 @@ use tokio::sync::RwLock; use tracing::{debug, info, trace, warn}; use crate::audio::{AudioController, OpusFrame}; -use crate::events::EventBus; use crate::error::{AppError, Result}; +use crate::events::EventBus; use crate::hid::HidController; use crate::video::encoder::registry::EncoderBackend; use crate::video::encoder::registry::VideoEncoderType; @@ -250,6 +250,33 @@ impl WebRtcStreamer { } } + fn should_stop_pipeline(session_count: usize, subscriber_count: usize) -> bool { + session_count == 0 && subscriber_count == 0 + } + + async fn stop_pipeline_if_idle(&self, reason: &str) { + let session_count = self.sessions.read().await.len(); + let pipeline = self.video_pipeline.read().await.clone(); + + let Some(pipeline) = pipeline else { + return; + }; + + let subscriber_count = pipeline.subscriber_count(); + if Self::should_stop_pipeline(session_count, subscriber_count) { + info!( + "{} stopping video pipeline (sessions={}, subscribers={})", + reason, session_count, subscriber_count + ); + pipeline.stop(); + } else { + debug!( + "Keeping video pipeline alive (reason={}, sessions={}, subscribers={})", + reason, session_count, subscriber_count + ); + } + } + /// Ensure video pipeline is initialized and running async fn ensure_video_pipeline(self: &Arc) -> Result> { let mut pipeline_guard = self.video_pipeline.write().await; @@ -270,7 +297,6 @@ impl WebRtcStreamer { bitrate_preset: config.bitrate_preset, fps: config.fps, encoder_backend: config.encoder_backend, - ..Default::default() }; info!("Creating shared video pipeline for {:?}", codec); @@ -311,7 +337,9 @@ impl WebRtcStreamer { } drop(pipeline_guard); - info!("Video pipeline stopped, but keeping capture config for new sessions"); + info!( + "Video pipeline stopped, but keeping capture config for new sessions" + ); } break; } @@ -739,13 +767,7 @@ impl WebRtcStreamer { session.close().await?; } - // Stop pipeline if no more sessions - if self.sessions.read().await.is_empty() { - if let Some(ref pipeline) = *self.video_pipeline.read().await { - info!("No more sessions, stopping video pipeline"); - pipeline.stop(); - } - } + self.stop_pipeline_if_idle("After close_session").await; Ok(()) } @@ -762,11 +784,8 @@ impl WebRtcStreamer { } } - // Stop pipeline drop(sessions); - if let Some(ref pipeline) = *self.video_pipeline.read().await { - pipeline.stop(); - } + self.stop_pipeline_if_idle("After close_all_sessions").await; count } @@ -825,14 +844,9 @@ impl WebRtcStreamer { sessions.remove(id); } - // Stop pipeline if no more sessions - if sessions.is_empty() { - drop(sessions); - if let Some(ref pipeline) = *self.video_pipeline.read().await { - info!("No more sessions after cleanup, stopping video pipeline"); - pipeline.stop(); - } - } + drop(sessions); + self.stop_pipeline_if_idle("After cleanup_closed_sessions") + .await; } } @@ -926,10 +940,7 @@ impl WebRtcStreamer { let pipeline = pipeline_for_callback.clone(); let sid = sid.clone(); tokio::spawn(async move { - info!( - "Requesting keyframe for session {} after reconnect", - sid - ); + info!("Requesting keyframe for session {} after reconnect", sid); pipeline.request_keyframe().await; }); }); @@ -992,4 +1003,12 @@ mod tests { let codecs = streamer.supported_video_codecs(); assert!(codecs.contains(&VideoCodecType::H264)); } + + #[test] + fn stop_pipeline_requires_no_sessions_and_no_subscribers() { + assert!(WebRtcStreamer::should_stop_pipeline(0, 0)); + assert!(!WebRtcStreamer::should_stop_pipeline(1, 0)); + assert!(!WebRtcStreamer::should_stop_pipeline(0, 1)); + assert!(!WebRtcStreamer::should_stop_pipeline(2, 3)); + } } diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 8edef184..6198fb08 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -136,6 +136,15 @@ export const msdConfigApi = { // ===== ATX 配置 API ===== import type { AtxDevices } from '@/types/generated' +export interface WolHistoryEntry { + mac_address: string + updated_at: number +} + +export interface WolHistoryResponse { + history: WolHistoryEntry[] +} + export const atxConfigApi = { /** * 获取 ATX 配置 @@ -166,6 +175,13 @@ export const atxConfigApi = { method: 'POST', body: JSON.stringify({ mac_address: macAddress }), }), + + /** + * 获取 WOL 历史记录(服务端持久化) + * @param limit 返回条数(1-50) + */ + getWolHistory: (limit = 5) => + request(`/atx/wol/history?limit=${Math.max(1, Math.min(50, limit))}`), } // ===== Audio 配置 API ===== @@ -330,6 +346,49 @@ export const rustdeskConfigApi = { }), } +// ===== RTSP 配置 API ===== + +export type RtspCodec = 'h264' | 'h265' + +export interface RtspConfigResponse { + enabled: boolean + bind: string + port: number + path: string + allow_one_client: boolean + codec: RtspCodec + username?: string | null + has_password: boolean +} + +export interface RtspConfigUpdate { + enabled?: boolean + bind?: string + port?: number + path?: string + allow_one_client?: boolean + codec?: RtspCodec + username?: string + password?: string +} + +export interface RtspStatusResponse { + config: RtspConfigResponse + service_status: string +} + +export const rtspConfigApi = { + get: () => request('/config/rtsp'), + + update: (config: RtspConfigUpdate) => + request('/config/rtsp', { + method: 'PATCH', + body: JSON.stringify(config), + }), + + getStatus: () => request('/config/rtsp/status'), +} + // ===== Web 服务器配置 API ===== /** Web 服务器配置 */ diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 5cc503ae..d3522ee5 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -101,6 +101,46 @@ export const systemApi = { }), } +export type UpdateChannel = 'stable' | 'beta' + +export interface UpdateOverviewResponse { + success: boolean + current_version: string + channel: UpdateChannel + latest_version: string + upgrade_available: boolean + target_version?: string + notes_between: Array<{ + version: string + published_at: string + notes: string[] + }> +} + +export interface UpdateStatusResponse { + success: boolean + phase: 'idle' | 'checking' | 'downloading' | 'verifying' | 'installing' | 'restarting' | 'success' | 'failed' + progress: number + current_version: string + target_version?: string + message?: string + last_error?: string +} + +export const updateApi = { + overview: (channel: UpdateChannel = 'stable') => + request(`/update/overview?channel=${encodeURIComponent(channel)}`), + + upgrade: (payload: { channel?: UpdateChannel; target_version?: string }) => + request<{ success: boolean; message?: string }>('/update/upgrade', { + method: 'POST', + body: JSON.stringify(payload), + }), + + status: () => + request('/update/status'), +} + // Stream API export interface VideoCodecInfo { id: string @@ -124,6 +164,19 @@ export interface AvailableCodecsResponse { codecs: VideoCodecInfo[] } +export interface StreamConstraintsResponse { + success: boolean + allowed_codecs: string[] + locked_codec: string | null + disallow_mjpeg: boolean + sources: { + rustdesk: boolean + rtsp: boolean + } + reason: string + current_mode: string +} + export const streamApi = { status: () => request<{ @@ -161,6 +214,9 @@ export const streamApi = { getCodecs: () => request('/stream/codecs'), + getConstraints: () => + request('/stream/constraints'), + setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) => request<{ success: boolean; message?: string }>('/stream/bitrate', { method: 'POST', @@ -186,10 +242,10 @@ export const webrtcApi = { createSession: () => request<{ session_id: string }>('/webrtc/session', { method: 'POST' }), - offer: (sdp: string, clientId?: string) => + offer: (sdp: string) => request<{ sdp: string; session_id: string; ice_candidates: IceCandidate[] }>('/webrtc/offer', { method: 'POST', - body: JSON.stringify({ sdp, client_id: clientId }), + body: JSON.stringify({ sdp }), }), addIceCandidate: (sessionId: string, candidate: IceCandidate) => @@ -247,17 +303,34 @@ export const hidApi = { screen_resolution: [number, number] | null }>('/hid/status'), - keyboard: async (type: 'down' | 'up', key: number, modifiers?: { - ctrl?: boolean - shift?: boolean - alt?: boolean - meta?: boolean - }) => { + otgSelfCheck: () => + request<{ + overall_ok: boolean + error_count: number + warning_count: number + hid_backend: string + selected_udc: string | null + bound_udc: string | null + udc_state: string | null + udc_speed: string | null + available_udcs: string[] + other_gadgets: string[] + checks: Array<{ + id: string + ok: boolean + level: 'info' | 'warn' | 'error' + message: string + hint?: string + path?: string + }> + }>('/hid/otg/self-check'), + + keyboard: async (type: 'down' | 'up', key: number, modifier?: number) => { await ensureHidConnection() const event: HidKeyboardEvent = { type: type === 'down' ? 'keydown' : 'keyup', key, - modifiers, + modifier: (modifier ?? 0) & 0xff, } await hidWs.sendKeyboard(event) return { success: true } @@ -481,6 +554,25 @@ export const msdApi = { }), } +interface SerialDeviceOption { + path: string + name: string +} + +function getSerialDevicePriority(path: string): number { + if (/^\/dev\/ttyUSB/i.test(path)) return 0 + if (/^\/dev\/(ttyS|S)/i.test(path)) return 2 + return 1 +} + +function sortSerialDevices(serialDevices: SerialDeviceOption[]): SerialDeviceOption[] { + return [...serialDevices].sort((a, b) => { + const priorityDiff = getSerialDevicePriority(a.path) - getSerialDevicePriority(b.path) + if (priorityDiff !== 0) return priorityDiff + return a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' }) + }) +} + // Config API /** @deprecated 使用域特定 API(videoConfigApi, hidConfigApi 等)替代 */ export const configApi = { @@ -493,8 +585,8 @@ export const configApi = { body: JSON.stringify(updates), }), - listDevices: () => - request<{ + listDevices: async () => { + const result = await request<{ video: Array<{ path: string name: string @@ -522,7 +614,13 @@ export const configApi = { ttyd_available: boolean rustdesk_available: boolean } - }>('/devices'), + }>('/devices') + + return { + ...result, + serial: sortSerialDevices(result.serial), + } + }, } // 导出新的域分离配置 API @@ -536,11 +634,15 @@ export { audioConfigApi, extensionsApi, rustdeskConfigApi, + rtspConfigApi, webConfigApi, type RustDeskConfigResponse, type RustDeskStatusResponse, type RustDeskConfigUpdate, type RustDeskPasswordResponse, + type RtspConfigResponse, + type RtspConfigUpdate, + type RtspStatusResponse, type WebConfig, } from './config' diff --git a/web/src/components/AppLayout.vue b/web/src/components/AppLayout.vue index ec22a9a7..879c5123 100644 --- a/web/src/components/AppLayout.vue +++ b/web/src/components/AppLayout.vue @@ -52,7 +52,7 @@ async function handleLogout() {