mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
572bf859ba | ||
|
|
9f34b4109b | ||
|
|
dcfa3eadaf | ||
|
|
1647e70243 | ||
|
|
a4073d64cb | ||
|
|
16400df182 | ||
|
|
47af17bebc | ||
|
|
814f23a27c | ||
|
|
6bbe137f09 | ||
|
|
6fdcf5c7c9 | ||
|
|
ce1712ff2e | ||
|
|
8b09824e04 | ||
|
|
9fb23476ac | ||
|
|
971c263bf8 | ||
|
|
cb3fd3e646 | ||
|
|
d414557d74 | ||
|
|
fbcd95b617 | ||
|
|
887f29096f | ||
|
|
e0bddc2faa | ||
|
|
4f770a1eea | ||
|
|
376dc97134 | ||
|
|
27c8da9a75 | ||
|
|
5963dfa01a | ||
|
|
f86cba6ce5 | ||
|
|
e1b82519b9 | ||
|
|
32d8a62fec | ||
|
|
4cd622ff07 | ||
|
|
6ec4361f43 | ||
|
|
e9bed3688f | ||
|
|
8d5366444b | ||
|
|
4e32b05124 | ||
|
|
e0055bf491 |
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -34,13 +34,11 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: web/package-lock.json
|
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: web
|
working-directory: web
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Upload frontend dist
|
- name: Upload frontend dist
|
||||||
@@ -66,8 +64,16 @@ jobs:
|
|||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
# The Docker workflow builds the same targets with the same Cross images.
|
||||||
|
shared-key: linux-cross-release
|
||||||
|
key: ${{ hashFiles('Cross.toml', 'build/cross/Dockerfile.*') }}
|
||||||
|
cache-all-crates: true
|
||||||
|
|
||||||
- name: Install cross
|
- name: Install cross
|
||||||
run: cargo install cross --locked
|
run: cargo install cross --version 0.2.5 --locked
|
||||||
|
|
||||||
- name: Build linux binary
|
- name: Build linux binary
|
||||||
run: bash build/build-images.sh
|
run: bash build/build-images.sh
|
||||||
@@ -127,6 +133,12 @@ jobs:
|
|||||||
"TURBOJPEG_LIB_DIR=$env:TURBOJPEG_LIB_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append
|
"TURBOJPEG_LIB_DIR=$env:TURBOJPEG_LIB_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||||
"TURBOJPEG_INCLUDE_DIR=$env:TURBOJPEG_INCLUDE_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append
|
"TURBOJPEG_INCLUDE_DIR=$env:TURBOJPEG_INCLUDE_DIR" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: windows-msvc-release
|
||||||
|
key: ${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }}
|
||||||
|
|
||||||
- name: Build Windows exe
|
- name: Build Windows exe
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: .\build\windows\build.ps1 -Configuration release -Package
|
run: .\build\windows\build.ps1 -Configuration release -Package
|
||||||
|
|||||||
14
.github/workflows/docker.yml
vendored
14
.github/workflows/docker.yml
vendored
@@ -37,21 +37,27 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: web/package-lock.json
|
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
# Reuse dependency artifacts produced by the deb job and vice versa.
|
||||||
|
shared-key: linux-cross-release
|
||||||
|
key: ${{ hashFiles('Cross.toml', 'build/cross/Dockerfile.*') }}
|
||||||
|
cache-all-crates: true
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y unzip xz-utils
|
sudo apt-get install -y unzip xz-utils
|
||||||
cargo install cross --locked
|
cargo install cross --version 0.2.5 --locked
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: web
|
working-directory: web
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "one-kvm"
|
name = "one-kvm"
|
||||||
version = "0.2.5"
|
version = "0.2.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["SilentWind"]
|
authors = ["SilentWind"]
|
||||||
description = "A open and lightweight IP-KVM solution written in Rust"
|
description = "A open and lightweight IP-KVM solution written in Rust"
|
||||||
@@ -56,6 +56,7 @@ desktop = [
|
|||||||
"dep:serialport",
|
"dep:serialport",
|
||||||
"dep:async-trait",
|
"dep:async-trait",
|
||||||
"dep:libc",
|
"dep:libc",
|
||||||
|
"dep:libloading",
|
||||||
"dep:ventoy-img",
|
"dep:ventoy-img",
|
||||||
"dep:protobuf",
|
"dep:protobuf",
|
||||||
"dep:sodiumoxide",
|
"dep:sodiumoxide",
|
||||||
@@ -156,6 +157,7 @@ sdp-types = { version = "0.1", optional = true }
|
|||||||
serialport = { version = "4", optional = true }
|
serialport = { version = "4", optional = true }
|
||||||
async-trait = { version = "0.1", optional = true }
|
async-trait = { version = "0.1", optional = true }
|
||||||
libc = { version = "0.2", optional = true }
|
libc = { version = "0.2", optional = true }
|
||||||
|
libloading = { version = "0.8", optional = true }
|
||||||
|
|
||||||
# Ventoy bootable image support
|
# Ventoy bootable image support
|
||||||
ventoy-img = { path = "libs/ventoy-img-rs", optional = true }
|
ventoy-img = { path = "libs/ventoy-img-rs", optional = true }
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
libudev1 \
|
libudev1 \
|
||||||
libasound2 \
|
libasound2 \
|
||||||
|
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)
|
||||||
|
network-manager \
|
||||||
|
iproute2 \
|
||||||
|
iputils-ping \
|
||||||
# v4l2 is handled by kernel, minimal userspace needed
|
# v4l2 is handled by kernel, minimal userspace needed
|
||||||
libv4l-0 \
|
libv4l-0 \
|
||||||
&& \
|
&& \
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
libudev1 \
|
libudev1 \
|
||||||
libasound2 \
|
libasound2 \
|
||||||
|
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)
|
||||||
|
network-manager \
|
||||||
|
iproute2 \
|
||||||
|
iputils-ping \
|
||||||
# v4l2 is handled by kernel, minimal userspace needed
|
# v4l2 is handled by kernel, minimal userspace needed
|
||||||
libv4l-0 \
|
libv4l-0 \
|
||||||
&& \
|
&& \
|
||||||
|
|||||||
@@ -1119,25 +1119,73 @@ pub fn mjpg_size(src: &[u8]) -> Result<(i32, i32)> {
|
|||||||
|
|
||||||
/// Decode MJPEG directly to NV12.
|
/// Decode MJPEG directly to NV12.
|
||||||
pub fn mjpg_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> {
|
pub fn mjpg_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> {
|
||||||
if width % 2 != 0 || height % 2 != 0 {
|
let (y_size, output_size) = mjpg_nv12_plane_sizes(width, height)?;
|
||||||
return Err(YuvError::InvalidDimensions);
|
if dst.len() < output_size {
|
||||||
}
|
|
||||||
|
|
||||||
let w = width as usize;
|
|
||||||
let h = height as usize;
|
|
||||||
if dst.len() < nv12_size(w, h) {
|
|
||||||
return Err(YuvError::BufferTooSmall);
|
return Err(YuvError::BufferTooSmall);
|
||||||
}
|
}
|
||||||
|
|
||||||
let y_size = w * h;
|
|
||||||
let (dst_y, dst_uv) = dst.split_at_mut(y_size);
|
let (dst_y, dst_uv) = dst.split_at_mut(y_size);
|
||||||
|
// SAFETY: the length check above guarantees writable storage for both planes.
|
||||||
|
unsafe { mjpg_to_nv12_raw(src, dst_y.as_mut_ptr(), dst_uv.as_mut_ptr(), width, height) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode MJPEG directly into a reusable `Vec` without zero-filling the output first.
|
||||||
|
///
|
||||||
|
/// `Vec::resize` must initialize every byte before libyuv immediately overwrites the
|
||||||
|
/// complete NV12 frame. This variant lets libyuv initialize spare capacity directly
|
||||||
|
/// and publishes the new length only after a successful conversion.
|
||||||
|
pub fn mjpg_to_nv12_vec(src: &[u8], dst: &mut Vec<u8>, width: i32, height: i32) -> Result<()> {
|
||||||
|
let (y_size, output_size) = mjpg_nv12_plane_sizes(width, height)?;
|
||||||
|
|
||||||
|
dst.clear();
|
||||||
|
dst.reserve(output_size);
|
||||||
|
|
||||||
|
// SAFETY: reserve above guarantees writable capacity for the Y and UV planes.
|
||||||
|
// MJPGToNV12 writes the complete output on success; set_len is deliberately
|
||||||
|
// delayed until then so callers can never observe partially initialized bytes.
|
||||||
|
let result = unsafe {
|
||||||
|
let dst_y = dst.as_mut_ptr();
|
||||||
|
mjpg_to_nv12_raw(src, dst_y, dst_y.add(y_size), width, height)
|
||||||
|
};
|
||||||
|
result?;
|
||||||
|
|
||||||
|
// SAFETY: a successful MJPGToNV12 call initialized exactly output_size bytes.
|
||||||
|
unsafe { dst.set_len(output_size) };
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn mjpg_nv12_plane_sizes(width: i32, height: i32) -> Result<(usize, usize)> {
|
||||||
|
if width % 2 != 0 || height % 2 != 0 || width <= 0 || height <= 0 {
|
||||||
|
return Err(YuvError::InvalidDimensions);
|
||||||
|
}
|
||||||
|
let y_size = (width as usize)
|
||||||
|
.checked_mul(height as usize)
|
||||||
|
.ok_or(YuvError::InvalidDimensions)?;
|
||||||
|
let output_size = y_size
|
||||||
|
.checked_mul(3)
|
||||||
|
.map(|size| size / 2)
|
||||||
|
.ok_or(YuvError::InvalidDimensions)?;
|
||||||
|
Ok((y_size, output_size))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
///
|
||||||
|
/// `dst_y` and `dst_uv` must point to writable planes sized for `width` x `height` NV12.
|
||||||
|
#[inline]
|
||||||
|
unsafe fn mjpg_to_nv12_raw(
|
||||||
|
src: &[u8],
|
||||||
|
dst_y: *mut u8,
|
||||||
|
dst_uv: *mut u8,
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
) -> Result<()> {
|
||||||
call_yuv!(MJPGToNV12(
|
call_yuv!(MJPGToNV12(
|
||||||
src.as_ptr(),
|
src.as_ptr(),
|
||||||
usize_to_size_t(src.len()),
|
usize_to_size_t(src.len()),
|
||||||
dst_y.as_mut_ptr(),
|
dst_y,
|
||||||
width,
|
width,
|
||||||
dst_uv.as_mut_ptr(),
|
dst_uv,
|
||||||
width,
|
width,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
//! Platform-neutral capture lifecycle and PCM frame types.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use tokio::sync::{broadcast, watch, Mutex};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::utils::LogThrottler;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[path = "capture_linux.rs"]
|
#[path = "capture_linux.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
@@ -6,4 +18,146 @@ mod imp;
|
|||||||
#[path = "capture_windows.rs"]
|
#[path = "capture_windows.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
|
|
||||||
pub use imp::*;
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AudioConfig {
|
||||||
|
pub device_name: String,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u32,
|
||||||
|
pub buffer_frames: u32,
|
||||||
|
pub period_frames: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
device_name: String::new(),
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
buffer_frames: 4096,
|
||||||
|
period_frames: 960,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AudioFrame {
|
||||||
|
pub data: Bytes,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioFrame {
|
||||||
|
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
data,
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CaptureState {
|
||||||
|
Stopped,
|
||||||
|
Starting,
|
||||||
|
Running,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AudioCapturer {
|
||||||
|
config: AudioConfig,
|
||||||
|
state: watch::Sender<CaptureState>,
|
||||||
|
state_rx: watch::Receiver<CaptureState>,
|
||||||
|
frame_tx: broadcast::Sender<AudioFrame>,
|
||||||
|
stop_flag: Arc<AtomicBool>,
|
||||||
|
task: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||||
|
lifecycle: Mutex<()>,
|
||||||
|
log_throttler: LogThrottler,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioCapturer {
|
||||||
|
pub fn new(config: AudioConfig) -> Self {
|
||||||
|
let (state, state_rx) = watch::channel(CaptureState::Stopped);
|
||||||
|
let (frame_tx, _) = broadcast::channel(16);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
state_rx,
|
||||||
|
frame_tx,
|
||||||
|
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||||
|
task: Mutex::new(None),
|
||||||
|
lifecycle: Mutex::new(()),
|
||||||
|
log_throttler: LogThrottler::with_secs(5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(&self) -> CaptureState {
|
||||||
|
*self.state_rx.borrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
|
||||||
|
self.state_rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
|
||||||
|
self.frame_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self) -> Result<()> {
|
||||||
|
let _lifecycle = self.lifecycle.lock().await;
|
||||||
|
if matches!(self.state(), CaptureState::Starting | CaptureState::Running) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(previous) = self.task.lock().await.take() {
|
||||||
|
let _ = previous.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Starting audio capture on {} at {}Hz {}ch",
|
||||||
|
self.config.device_name, self.config.sample_rate, self.config.channels
|
||||||
|
);
|
||||||
|
|
||||||
|
self.stop_flag.store(false, Ordering::Release);
|
||||||
|
let _ = self.state.send(CaptureState::Starting);
|
||||||
|
|
||||||
|
let config = self.config.clone();
|
||||||
|
let state = self.state.clone();
|
||||||
|
let frame_tx = self.frame_tx.clone();
|
||||||
|
let stop_flag = Arc::clone(&self.stop_flag);
|
||||||
|
let log_throttler = self.log_throttler.clone();
|
||||||
|
|
||||||
|
let task = tokio::task::spawn_blocking(move || {
|
||||||
|
match imp::run_capture(&config, &state, &frame_tx, &stop_flag, &log_throttler) {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = state.send(CaptureState::Stopped);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
crate::error_throttled!(
|
||||||
|
log_throttler,
|
||||||
|
"capture_error",
|
||||||
|
"Audio capture error: {}",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
let _ = state.send(CaptureState::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*self.task.lock().await = Some(task);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&self) -> Result<()> {
|
||||||
|
let _lifecycle = self.lifecycle.lock().await;
|
||||||
|
self.stop_flag.store(true, Ordering::Release);
|
||||||
|
|
||||||
|
if let Some(task) = self.task.lock().await.take() {
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = self.state.send(CaptureState::Stopped);
|
||||||
|
info!("Audio capture stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,271 +1,60 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use alsa::pcm::{Access, Format, Frames, HwParams, State, IO};
|
use alsa::pcm::{Access, Format, Frames, HwParams, State, IO};
|
||||||
use alsa::{Direction, ValueOr, PCM};
|
use alsa::{Direction, ValueOr, PCM};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use tokio::sync::{broadcast, watch};
|
||||||
use std::sync::Arc;
|
use tracing::debug;
|
||||||
use std::time::Instant;
|
|
||||||
use tokio::sync::{broadcast, watch, Mutex};
|
|
||||||
use tracing::{debug, info};
|
|
||||||
|
|
||||||
use crate::audio::device::AudioDeviceInfo;
|
use super::{AudioConfig, AudioFrame, CaptureState};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::utils::LogThrottler;
|
use crate::utils::LogThrottler;
|
||||||
use crate::{error_throttled, warn_throttled};
|
use crate::warn_throttled;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
const RETRY_DELAY: Duration = Duration::from_millis(5);
|
||||||
pub struct AudioConfig {
|
const MAX_CONSECUTIVE_READ_ERRORS: u32 = 10;
|
||||||
pub device_name: String,
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u32,
|
|
||||||
pub frame_size: u32,
|
|
||||||
pub buffer_frames: u32,
|
|
||||||
pub period_frames: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AudioConfig {
|
pub(super) fn run_capture(
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
device_name: String::new(),
|
|
||||||
sample_rate: 48000,
|
|
||||||
channels: 2,
|
|
||||||
frame_size: 960,
|
|
||||||
buffer_frames: 4096,
|
|
||||||
period_frames: 960,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioConfig {
|
|
||||||
pub fn for_device(device: &AudioDeviceInfo) -> Self {
|
|
||||||
Self {
|
|
||||||
device_name: device.name.clone(),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bytes_per_sample(&self) -> u32 {
|
|
||||||
2 * self.channels
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bytes_per_frame(&self) -> usize {
|
|
||||||
(self.frame_size * self.bytes_per_sample()) as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AudioFrame {
|
|
||||||
pub data: Bytes,
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u32,
|
|
||||||
pub samples: u32,
|
|
||||||
pub sequence: u64,
|
|
||||||
pub timestamp: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioFrame {
|
|
||||||
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
|
|
||||||
let bps = 2 * channels;
|
|
||||||
Self {
|
|
||||||
samples: data.len() as u32 / bps,
|
|
||||||
data,
|
|
||||||
sample_rate,
|
|
||||||
channels,
|
|
||||||
sequence,
|
|
||||||
timestamp: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum CaptureState {
|
|
||||||
Stopped,
|
|
||||||
Running,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AudioCapturer {
|
|
||||||
config: AudioConfig,
|
|
||||||
state: Arc<watch::Sender<CaptureState>>,
|
|
||||||
state_rx: watch::Receiver<CaptureState>,
|
|
||||||
frame_tx: broadcast::Sender<AudioFrame>,
|
|
||||||
stop_flag: Arc<AtomicBool>,
|
|
||||||
sequence: Arc<AtomicU64>,
|
|
||||||
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
|
||||||
log_throttler: LogThrottler,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioCapturer {
|
|
||||||
pub fn new(config: AudioConfig) -> Self {
|
|
||||||
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
|
|
||||||
let (frame_tx, _) = broadcast::channel(16);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
state: Arc::new(state_tx),
|
|
||||||
state_rx,
|
|
||||||
frame_tx,
|
|
||||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
|
||||||
sequence: Arc::new(AtomicU64::new(0)),
|
|
||||||
capture_handle: Mutex::new(None),
|
|
||||||
log_throttler: LogThrottler::with_secs(5),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state(&self) -> CaptureState {
|
|
||||||
*self.state_rx.borrow()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
|
|
||||||
self.state_rx.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
|
|
||||||
self.frame_tx.subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start(&self) -> Result<()> {
|
|
||||||
if self.state() == CaptureState::Running {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Starting audio capture on {} at {}Hz {}ch",
|
|
||||||
self.config.device_name, self.config.sample_rate, self.config.channels
|
|
||||||
);
|
|
||||||
|
|
||||||
self.stop_flag.store(false, Ordering::SeqCst);
|
|
||||||
|
|
||||||
let config = self.config.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let frame_tx = self.frame_tx.clone();
|
|
||||||
let stop_flag = self.stop_flag.clone();
|
|
||||||
let sequence = self.sequence.clone();
|
|
||||||
let log_throttler = self.log_throttler.clone();
|
|
||||||
|
|
||||||
let handle = tokio::task::spawn_blocking(move || {
|
|
||||||
let result = run_capture(
|
|
||||||
&config,
|
|
||||||
&state,
|
|
||||||
&frame_tx,
|
|
||||||
&stop_flag,
|
|
||||||
&sequence,
|
|
||||||
&log_throttler,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
|
|
||||||
let _ = state.send(CaptureState::Error);
|
|
||||||
} else {
|
|
||||||
let _ = state.send(CaptureState::Stopped);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
*self.capture_handle.lock().await = Some(handle);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop(&self) -> Result<()> {
|
|
||||||
info!("Stopping audio capture");
|
|
||||||
self.stop_flag.store(true, Ordering::SeqCst);
|
|
||||||
|
|
||||||
if let Some(handle) = self.capture_handle.lock().await.take() {
|
|
||||||
let _ = handle.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = self.state.send(CaptureState::Stopped);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_running(&self) -> bool {
|
|
||||||
self.state() == CaptureState::Running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_capture(
|
|
||||||
config: &AudioConfig,
|
config: &AudioConfig,
|
||||||
state: &watch::Sender<CaptureState>,
|
state: &watch::Sender<CaptureState>,
|
||||||
frame_tx: &broadcast::Sender<AudioFrame>,
|
frame_tx: &broadcast::Sender<AudioFrame>,
|
||||||
stop_flag: &AtomicBool,
|
stop_flag: &AtomicBool,
|
||||||
sequence: &AtomicU64,
|
|
||||||
log_throttler: &LogThrottler,
|
log_throttler: &LogThrottler,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| {
|
// Non-blocking mode guarantees that stop() can always join the worker.
|
||||||
|
let pcm = PCM::new(&config.device_name, Direction::Capture, true).map_err(|error| {
|
||||||
AppError::AudioError(format!(
|
AppError::AudioError(format!(
|
||||||
"Failed to open audio device {}: {}",
|
"Failed to open audio device {}: {}",
|
||||||
config.device_name, e
|
config.device_name, error
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
{
|
configure_pcm(&pcm, config)?;
|
||||||
let hwp = HwParams::any(&pcm)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_channels(config.channels)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_rate(config.sample_rate, ValueOr::Nearest)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_format(Format::s16())
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_access(Access::RWInterleaved)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_buffer_size_near(config.buffer_frames as Frames)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?;
|
|
||||||
|
|
||||||
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
|
|
||||||
|
|
||||||
pcm.hw_params(&hwp)
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hw_now = pcm.hw_params_current().map_err(|e| {
|
|
||||||
AppError::AudioError(format!("Failed to read hw_params after apply: {}", e))
|
|
||||||
})?;
|
|
||||||
let actual_rate = hw_now
|
|
||||||
.get_rate()
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?;
|
|
||||||
let actual_ch = hw_now
|
|
||||||
.get_channels()
|
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?;
|
|
||||||
if actual_rate != 48_000 {
|
|
||||||
return Err(AppError::AudioError(format!(
|
|
||||||
"Audio capture requires 48000 Hz; device is {} Hz",
|
|
||||||
actual_rate
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if actual_ch != 2 {
|
|
||||||
return Err(AppError::AudioError(format!(
|
|
||||||
"Audio capture requires 2 channels (stereo); device has {}",
|
|
||||||
actual_ch
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
debug!("Audio capture: 48000 Hz, 2 ch");
|
|
||||||
|
|
||||||
pcm.prepare()
|
pcm.prepare()
|
||||||
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
|
.map_err(|error| AppError::AudioError(format!("Failed to prepare PCM: {error}")))?;
|
||||||
|
|
||||||
let _ = state.send(CaptureState::Running);
|
let _ = state.send(CaptureState::Running);
|
||||||
|
|
||||||
let period_frames = pcm
|
let period_frames = pcm
|
||||||
.hw_params_current()
|
.hw_params_current()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|h| h.get_period_size().ok())
|
.and_then(|params| params.get_period_size().ok())
|
||||||
.map(|f| f as usize)
|
.map(|frames| frames as usize)
|
||||||
.unwrap_or(1024)
|
.unwrap_or(config.period_frames as usize)
|
||||||
.max(256);
|
.max(256);
|
||||||
let buf_frames = period_frames.saturating_mul(4).max(2048);
|
let mut buffer = vec![0u8; period_frames * config.channels as usize * 2];
|
||||||
let bytes_per_frame = (config.channels as usize) * 2;
|
let io: IO<u8> = pcm.io_bytes();
|
||||||
let mut buffer = vec![0u8; buf_frames * bytes_per_frame];
|
let mut consecutive_errors = 0;
|
||||||
|
|
||||||
while !stop_flag.load(Ordering::Relaxed) {
|
while !stop_flag.load(Ordering::Acquire) {
|
||||||
match pcm.state() {
|
match pcm.state() {
|
||||||
State::XRun => {
|
State::XRun => {
|
||||||
warn_throttled!(log_throttler, "xrun", "Audio buffer overrun, recovering");
|
warn_throttled!(log_throttler, "xrun", "Audio buffer overrun, recovering");
|
||||||
let _ = pcm.prepare();
|
pcm.prepare().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to recover audio xrun: {error}"))
|
||||||
|
})?;
|
||||||
|
consecutive_errors = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
State::Suspended => {
|
State::Suspended => {
|
||||||
@@ -274,61 +63,95 @@ fn run_capture(
|
|||||||
"suspended",
|
"suspended",
|
||||||
"Audio device suspended, recovering"
|
"Audio device suspended, recovering"
|
||||||
);
|
);
|
||||||
let _ = pcm.resume();
|
if pcm.resume().is_err() {
|
||||||
|
pcm.prepare().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to resume audio capture: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
consecutive_errors = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// io_bytes: USB capture often lacks mmap (io_checked requires it).
|
|
||||||
let io: IO<u8> = pcm.io_bytes();
|
|
||||||
|
|
||||||
match io.readi(&mut buffer) {
|
match io.readi(&mut buffer) {
|
||||||
|
Ok(0) => thread::sleep(RETRY_DELAY),
|
||||||
Ok(frames_read) => {
|
Ok(frames_read) => {
|
||||||
if frames_read == 0 {
|
consecutive_errors = 0;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let byte_count = frames_read * config.channels as usize * 2;
|
let byte_count = frames_read * config.channels as usize * 2;
|
||||||
|
|
||||||
let seq = sequence.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let frame = AudioFrame::new_interleaved(
|
let frame = AudioFrame::new_interleaved(
|
||||||
Bytes::copy_from_slice(&buffer[..byte_count]),
|
Bytes::copy_from_slice(&buffer[..byte_count]),
|
||||||
config.channels,
|
config.channels,
|
||||||
48_000,
|
config.sample_rate,
|
||||||
seq,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if frame_tx.receiver_count() > 0 {
|
if frame_tx.receiver_count() > 0 {
|
||||||
if let Err(e) = frame_tx.send(frame) {
|
let _ = frame_tx.send(frame);
|
||||||
debug!("No audio receivers: {}", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Err(error) if error.errno() == libc::EAGAIN => thread::sleep(RETRY_DELAY),
|
||||||
Err(e) => {
|
Err(error) if is_device_lost_errno(error.errno()) => {
|
||||||
let desc = e.to_string();
|
|
||||||
if is_device_lost_error(&desc) {
|
|
||||||
return Err(AppError::AudioError(format!(
|
return Err(AppError::AudioError(format!(
|
||||||
"Audio device lost while reading {}: {}",
|
"Audio device lost while reading {}: {}",
|
||||||
config.device_name, e
|
config.device_name, error
|
||||||
)));
|
)));
|
||||||
} else if desc.contains("EPIPE") || desc.contains("Broken pipe") {
|
|
||||||
warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun");
|
|
||||||
let _ = pcm.prepare();
|
|
||||||
} else {
|
|
||||||
error_throttled!(log_throttler, "read_error", "Audio read error: {}", e);
|
|
||||||
}
|
}
|
||||||
|
Err(error) if error.errno() == libc::EPIPE => {
|
||||||
|
warn_throttled!(log_throttler, "buffer_overrun", "Audio buffer overrun");
|
||||||
|
pcm.prepare().map_err(|prepare_error| {
|
||||||
|
AppError::AudioError(format!(
|
||||||
|
"Failed to recover after audio overrun ({error}): {prepare_error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
consecutive_errors = 0;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
consecutive_errors += 1;
|
||||||
|
warn_throttled!(log_throttler, "read_error", "Audio read error: {}", error);
|
||||||
|
if consecutive_errors >= MAX_CONSECUTIVE_READ_ERRORS {
|
||||||
|
return Err(AppError::AudioError(format!(
|
||||||
|
"Audio capture failed {consecutive_errors} times consecutively: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
thread::sleep(RETRY_DELAY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Audio capture stopped");
|
debug!("ALSA capture worker stopped");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_device_lost_error(desc: &str) -> bool {
|
fn configure_pcm(pcm: &PCM, config: &AudioConfig) -> Result<()> {
|
||||||
desc.contains("No such device")
|
let params = HwParams::any(pcm)
|
||||||
|| desc.contains("ENODEV")
|
.map_err(|error| AppError::AudioError(format!("Failed to get HwParams: {error}")))?;
|
||||||
|| desc.contains("ENXIO")
|
params
|
||||||
|| desc.contains("ESHUTDOWN")
|
.set_channels(config.channels)
|
||||||
|
.and_then(|_| params.set_rate(config.sample_rate, ValueOr::Nearest))
|
||||||
|
.and_then(|_| params.set_format(Format::s16()))
|
||||||
|
.and_then(|_| params.set_access(Access::RWInterleaved))
|
||||||
|
.and_then(|_| params.set_buffer_size_near(config.buffer_frames as Frames))
|
||||||
|
.and_then(|_| params.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest))
|
||||||
|
.and_then(|_| pcm.hw_params(¶ms))
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to configure audio PCM: {error}")))?;
|
||||||
|
|
||||||
|
let actual = pcm
|
||||||
|
.hw_params_current()
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to read PCM parameters: {error}")))?;
|
||||||
|
let actual_rate = actual
|
||||||
|
.get_rate()
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to read sample rate: {error}")))?;
|
||||||
|
let actual_channels = actual
|
||||||
|
.get_channels()
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to read channels: {error}")))?;
|
||||||
|
if actual_rate != config.sample_rate || actual_channels != config.channels {
|
||||||
|
return Err(AppError::AudioError(format!(
|
||||||
|
"Audio device negotiated {actual_rate} Hz/{actual_channels} ch; expected {} Hz/{} ch",
|
||||||
|
config.sample_rate, config.channels
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_device_lost_errno(errno: i32) -> bool {
|
||||||
|
matches!(errno, libc::ENODEV | libc::ENXIO | libc::ESHUTDOWN)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,198 +1,23 @@
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||||
use cpal::{BufferSize, SampleFormat, StreamConfig};
|
use cpal::{BufferSize, SampleFormat, StreamConfig};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
use tokio::sync::{broadcast, watch, Mutex};
|
use tokio::sync::{broadcast, watch};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use crate::audio::device::{find_wasapi_device, AudioDeviceInfo};
|
use super::{AudioConfig, AudioFrame, CaptureState};
|
||||||
|
use crate::audio::device::find_wasapi_device;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::error_throttled;
|
|
||||||
use crate::utils::LogThrottler;
|
use crate::utils::LogThrottler;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
pub(super) fn run_capture(
|
||||||
pub struct AudioConfig {
|
|
||||||
pub device_name: String,
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u32,
|
|
||||||
pub frame_size: u32,
|
|
||||||
pub buffer_frames: u32,
|
|
||||||
pub period_frames: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AudioConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
device_name: String::new(),
|
|
||||||
sample_rate: 48000,
|
|
||||||
channels: 2,
|
|
||||||
frame_size: 960,
|
|
||||||
buffer_frames: 4096,
|
|
||||||
period_frames: 960,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioConfig {
|
|
||||||
pub fn for_device(device: &AudioDeviceInfo) -> Self {
|
|
||||||
Self {
|
|
||||||
device_name: device.name.clone(),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bytes_per_sample(&self) -> u32 {
|
|
||||||
2 * self.channels
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bytes_per_frame(&self) -> usize {
|
|
||||||
(self.frame_size * self.bytes_per_sample()) as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AudioFrame {
|
|
||||||
pub data: Bytes,
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u32,
|
|
||||||
pub samples: u32,
|
|
||||||
pub sequence: u64,
|
|
||||||
pub timestamp: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioFrame {
|
|
||||||
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
|
|
||||||
let bps = 2 * channels;
|
|
||||||
Self {
|
|
||||||
samples: data.len() as u32 / bps,
|
|
||||||
data,
|
|
||||||
sample_rate,
|
|
||||||
channels,
|
|
||||||
sequence,
|
|
||||||
timestamp: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum CaptureState {
|
|
||||||
Stopped,
|
|
||||||
Running,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AudioCapturer {
|
|
||||||
config: AudioConfig,
|
|
||||||
state: Arc<watch::Sender<CaptureState>>,
|
|
||||||
state_rx: watch::Receiver<CaptureState>,
|
|
||||||
frame_tx: broadcast::Sender<AudioFrame>,
|
|
||||||
stop_flag: Arc<AtomicBool>,
|
|
||||||
sequence: Arc<AtomicU64>,
|
|
||||||
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
|
||||||
log_throttler: LogThrottler,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioCapturer {
|
|
||||||
pub fn new(config: AudioConfig) -> Self {
|
|
||||||
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
|
|
||||||
let (frame_tx, _) = broadcast::channel(16);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
state: Arc::new(state_tx),
|
|
||||||
state_rx,
|
|
||||||
frame_tx,
|
|
||||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
|
||||||
sequence: Arc::new(AtomicU64::new(0)),
|
|
||||||
capture_handle: Mutex::new(None),
|
|
||||||
log_throttler: LogThrottler::with_secs(5),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state(&self) -> CaptureState {
|
|
||||||
*self.state_rx.borrow()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
|
|
||||||
self.state_rx.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
|
|
||||||
self.frame_tx.subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start(&self) -> Result<()> {
|
|
||||||
if self.state() == CaptureState::Running {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Starting WASAPI audio capture on {} at {}Hz {}ch",
|
|
||||||
self.config.device_name, self.config.sample_rate, self.config.channels
|
|
||||||
);
|
|
||||||
|
|
||||||
self.stop_flag.store(false, Ordering::SeqCst);
|
|
||||||
|
|
||||||
let config = self.config.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let frame_tx = self.frame_tx.clone();
|
|
||||||
let stop_flag = self.stop_flag.clone();
|
|
||||||
let sequence = self.sequence.clone();
|
|
||||||
let log_throttler = self.log_throttler.clone();
|
|
||||||
|
|
||||||
let handle = tokio::task::spawn_blocking(move || {
|
|
||||||
let result = run_capture(
|
|
||||||
&config,
|
|
||||||
&state,
|
|
||||||
&frame_tx,
|
|
||||||
&stop_flag,
|
|
||||||
&sequence,
|
|
||||||
&log_throttler,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
error_throttled!(
|
|
||||||
log_throttler,
|
|
||||||
"capture_error",
|
|
||||||
"WASAPI audio capture error: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
let _ = state.send(CaptureState::Error);
|
|
||||||
} else {
|
|
||||||
let _ = state.send(CaptureState::Stopped);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
*self.capture_handle.lock().await = Some(handle);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop(&self) -> Result<()> {
|
|
||||||
info!("Stopping WASAPI audio capture");
|
|
||||||
self.stop_flag.store(true, Ordering::SeqCst);
|
|
||||||
|
|
||||||
if let Some(handle) = self.capture_handle.lock().await.take() {
|
|
||||||
let _ = handle.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = self.state.send(CaptureState::Stopped);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_running(&self) -> bool {
|
|
||||||
self.state() == CaptureState::Running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_capture(
|
|
||||||
config: &AudioConfig,
|
config: &AudioConfig,
|
||||||
state: &watch::Sender<CaptureState>,
|
state: &watch::Sender<CaptureState>,
|
||||||
frame_tx: &broadcast::Sender<AudioFrame>,
|
frame_tx: &broadcast::Sender<AudioFrame>,
|
||||||
stop_flag: &AtomicBool,
|
stop_flag: &AtomicBool,
|
||||||
sequence: &AtomicU64,
|
|
||||||
log_throttler: &LogThrottler,
|
log_throttler: &LogThrottler,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let device = find_wasapi_device(&config.device_name)?;
|
let device = find_wasapi_device(&config.device_name)?;
|
||||||
@@ -272,12 +97,10 @@ fn run_capture(
|
|||||||
if samples.is_empty() {
|
if samples.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let seq = sequence.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let frame = AudioFrame::new_interleaved(
|
let frame = AudioFrame::new_interleaved(
|
||||||
Bytes::copy_from_slice(bytemuck::cast_slice(&samples)),
|
Bytes::copy_from_slice(bytemuck::cast_slice(&samples)),
|
||||||
2,
|
2,
|
||||||
48_000,
|
48_000,
|
||||||
seq,
|
|
||||||
);
|
);
|
||||||
if frame_tx.receiver_count() > 0 {
|
if frame_tx.receiver_count() > 0 {
|
||||||
if let Err(e) = frame_tx.send(frame) {
|
if let Err(e) = frame_tx.send(frame) {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
//! Device selection, quality presets, streaming.
|
//! Device selection, quality presets, streaming.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use super::capture::AudioConfig;
|
use super::capture::AudioConfig;
|
||||||
@@ -22,23 +21,37 @@ pub(super) type AudioRecoveredCallback = Arc<dyn Fn() + Send + Sync>;
|
|||||||
pub struct AudioController {
|
pub struct AudioController {
|
||||||
config: Arc<RwLock<AudioControllerConfig>>,
|
config: Arc<RwLock<AudioControllerConfig>>,
|
||||||
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
||||||
devices: Arc<RwLock<Vec<AudioDeviceInfo>>>,
|
|
||||||
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
||||||
monitor: Arc<AudioHealthMonitor>,
|
monitor: Arc<AudioHealthMonitor>,
|
||||||
recovery_in_progress: Arc<AtomicBool>,
|
recovery: recovery::AudioRecovery,
|
||||||
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
||||||
|
operation: Arc<Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioController {
|
impl AudioController {
|
||||||
pub fn new(config: AudioControllerConfig) -> Self {
|
pub fn new(config: AudioControllerConfig) -> Self {
|
||||||
|
let config = Arc::new(RwLock::new(config));
|
||||||
|
let streamer = Arc::new(RwLock::new(None));
|
||||||
|
let event_bus = Arc::new(RwLock::new(None));
|
||||||
|
let monitor = Arc::new(AudioHealthMonitor::new());
|
||||||
|
let recovered_callback = Arc::new(RwLock::new(None));
|
||||||
|
let operation = Arc::new(Mutex::new(()));
|
||||||
|
let recovery = recovery::AudioRecovery::new(
|
||||||
|
config.clone(),
|
||||||
|
streamer.clone(),
|
||||||
|
event_bus.clone(),
|
||||||
|
monitor.clone(),
|
||||||
|
recovered_callback.clone(),
|
||||||
|
operation.clone(),
|
||||||
|
);
|
||||||
Self {
|
Self {
|
||||||
config: Arc::new(RwLock::new(config)),
|
config,
|
||||||
streamer: Arc::new(RwLock::new(None)),
|
streamer,
|
||||||
devices: Arc::new(RwLock::new(Vec::new())),
|
event_bus,
|
||||||
event_bus: Arc::new(RwLock::new(None)),
|
monitor,
|
||||||
monitor: Arc::new(AudioHealthMonitor::new()),
|
recovery,
|
||||||
recovery_in_progress: Arc::new(AtomicBool::new(false)),
|
recovered_callback,
|
||||||
recovered_callback: Arc::new(RwLock::new(None)),
|
operation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,31 +68,6 @@ impl AudioController {
|
|||||||
bus.mark_device_info_dirty();
|
bus.mark_device_info_dirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn spawn_recovery_task(&self, lost_device: String, reason: String) {
|
|
||||||
recovery::spawn_recovery_task(
|
|
||||||
self.config.clone(),
|
|
||||||
self.streamer.clone(),
|
|
||||||
self.event_bus.clone(),
|
|
||||||
self.monitor.clone(),
|
|
||||||
self.recovery_in_progress.clone(),
|
|
||||||
self.recovered_callback.clone(),
|
|
||||||
lost_device,
|
|
||||||
reason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_stream_monitor(&self, streamer: Arc<AudioStreamer>, device: String) {
|
|
||||||
recovery::spawn_stream_monitor(
|
|
||||||
self.config.clone(),
|
|
||||||
self.streamer.clone(),
|
|
||||||
self.event_bus.clone(),
|
|
||||||
self.monitor.clone(),
|
|
||||||
self.recovery_in_progress.clone(),
|
|
||||||
self.recovered_callback.clone(),
|
|
||||||
streamer,
|
|
||||||
device,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_devices(&self) -> Result<Vec<AudioDeviceInfo>> {
|
pub async fn list_devices(&self) -> Result<Vec<AudioDeviceInfo>> {
|
||||||
let current_device = if self.is_streaming().await {
|
let current_device = if self.is_streaming().await {
|
||||||
@@ -88,26 +76,19 @@ impl AudioController {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let devices = enumerate_audio_devices_with_current(current_device.as_deref())?;
|
enumerate_audio_devices_with_current(current_device.as_deref())
|
||||||
*self.devices.write().await = devices.clone();
|
|
||||||
Ok(devices)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_cached_devices(&self) -> Vec<AudioDeviceInfo> {
|
|
||||||
self.devices.read().await.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn select_device(&self, device: &str) -> Result<()> {
|
pub async fn select_device(&self, device: &str) -> Result<()> {
|
||||||
|
let _operation = self.operation.lock().await;
|
||||||
|
self.recovery.cancel();
|
||||||
let devices = self.list_devices().await?;
|
let devices = self.list_devices().await?;
|
||||||
let found = devices
|
let found = devices
|
||||||
.iter()
|
.iter()
|
||||||
.any(|d| d.name == device || d.description.contains(device));
|
.any(|d| d.name == device || d.description.contains(device));
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
return Err(AppError::AudioError(format!(
|
return Err(AppError::NotFound(format!("audio device {device}")));
|
||||||
"Audio device not found: {}",
|
|
||||||
device
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -118,14 +99,15 @@ impl AudioController {
|
|||||||
info!("Audio device selected: {}", device);
|
info!("Audio device selected: {}", device);
|
||||||
|
|
||||||
if self.is_streaming().await {
|
if self.is_streaming().await {
|
||||||
self.stop_streaming().await?;
|
self.stop_streaming_inner().await?;
|
||||||
self.start_streaming().await?;
|
self.start_streaming_inner().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_quality(&self, quality: AudioQuality) -> Result<()> {
|
pub async fn set_quality(&self, quality: AudioQuality) -> Result<()> {
|
||||||
|
let _operation = self.operation.lock().await;
|
||||||
{
|
{
|
||||||
let mut config = self.config.write().await;
|
let mut config = self.config.write().await;
|
||||||
config.quality = quality;
|
config.quality = quality;
|
||||||
@@ -144,6 +126,12 @@ impl AudioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_streaming(&self) -> Result<()> {
|
pub async fn start_streaming(&self) -> Result<()> {
|
||||||
|
let _operation = self.operation.lock().await;
|
||||||
|
self.recovery.cancel();
|
||||||
|
self.start_streaming_inner().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_streaming_inner(&self) -> Result<()> {
|
||||||
{
|
{
|
||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
if !config.enabled {
|
if !config.enabled {
|
||||||
@@ -171,7 +159,7 @@ impl AudioController {
|
|||||||
|
|
||||||
if let Some(error_msg) = select_error {
|
if let Some(error_msg) = select_error {
|
||||||
self.monitor.report_error(&error_msg, "start_failed").await;
|
self.monitor.report_error(&error_msg, "start_failed").await;
|
||||||
self.spawn_recovery_task("auto".to_string(), error_msg.clone());
|
self.recovery.start("auto".to_string(), error_msg.clone());
|
||||||
self.mark_device_info_dirty().await;
|
self.mark_device_info_dirty().await;
|
||||||
return Err(AppError::AudioError(error_msg));
|
return Err(AppError::AudioError(error_msg));
|
||||||
}
|
}
|
||||||
@@ -194,7 +182,7 @@ impl AudioController {
|
|||||||
let error_msg = format!("Failed to start audio: {}", e);
|
let error_msg = format!("Failed to start audio: {}", e);
|
||||||
|
|
||||||
self.monitor.report_error(&error_msg, "start_failed").await;
|
self.monitor.report_error(&error_msg, "start_failed").await;
|
||||||
self.spawn_recovery_task(device_name.clone(), error_msg.clone());
|
self.recovery.start(device_name.clone(), error_msg.clone());
|
||||||
|
|
||||||
self.mark_device_info_dirty().await;
|
self.mark_device_info_dirty().await;
|
||||||
|
|
||||||
@@ -203,14 +191,13 @@ impl AudioController {
|
|||||||
|
|
||||||
let streamer_for_monitor = streamer.clone();
|
let streamer_for_monitor = streamer.clone();
|
||||||
*self.streamer.write().await = Some(streamer);
|
*self.streamer.write().await = Some(streamer);
|
||||||
self.spawn_stream_monitor(streamer_for_monitor, device_name.clone());
|
self.recovery
|
||||||
|
.monitor(streamer_for_monitor, device_name.clone());
|
||||||
|
|
||||||
if self.monitor.is_error().await {
|
if self.monitor.is_error().await {
|
||||||
self.monitor.report_recovered().await;
|
self.monitor.report_recovered().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recovery_in_progress.store(false, Ordering::SeqCst);
|
|
||||||
|
|
||||||
self.mark_device_info_dirty().await;
|
self.mark_device_info_dirty().await;
|
||||||
|
|
||||||
info!("Audio streaming started");
|
info!("Audio streaming started");
|
||||||
@@ -218,7 +205,12 @@ impl AudioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop_streaming(&self) -> Result<()> {
|
pub async fn stop_streaming(&self) -> Result<()> {
|
||||||
self.recovery_in_progress.store(false, Ordering::SeqCst);
|
let _operation = self.operation.lock().await;
|
||||||
|
self.stop_streaming_inner().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_streaming_inner(&self) -> Result<()> {
|
||||||
|
self.recovery.cancel();
|
||||||
|
|
||||||
if let Some(streamer) = self.streamer.write().await.take() {
|
if let Some(streamer) = self.streamer.write().await.take() {
|
||||||
streamer.stop().await?;
|
streamer.stop().await?;
|
||||||
@@ -249,7 +241,7 @@ impl AudioController {
|
|||||||
let (streaming, subscriber_count) = if let Some(ref streamer) = *self.streamer.read().await
|
let (streaming, subscriber_count) = if let Some(ref streamer) = *self.streamer.read().await
|
||||||
{
|
{
|
||||||
let streaming = streamer.is_running();
|
let streaming = streamer.is_running();
|
||||||
let subscriber_count = streamer.stats().subscriber_count;
|
let subscriber_count = streamer.subscriber_count();
|
||||||
(streaming, subscriber_count)
|
(streaming, subscriber_count)
|
||||||
} else {
|
} else {
|
||||||
(false, 0)
|
(false, 0)
|
||||||
@@ -278,13 +270,15 @@ impl AudioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_enabled(&self, enabled: bool) -> Result<()> {
|
pub async fn set_enabled(&self, enabled: bool) -> Result<()> {
|
||||||
|
let _operation = self.operation.lock().await;
|
||||||
|
self.recovery.cancel();
|
||||||
{
|
{
|
||||||
let mut config = self.config.write().await;
|
let mut config = self.config.write().await;
|
||||||
config.enabled = enabled;
|
config.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !enabled && self.is_streaming().await {
|
if !enabled && self.is_streaming().await {
|
||||||
self.stop_streaming().await?;
|
self.stop_streaming_inner().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Audio enabled: {}", enabled);
|
info!("Audio enabled: {}", enabled);
|
||||||
@@ -292,16 +286,18 @@ impl AudioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
|
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
|
||||||
|
let _operation = self.operation.lock().await;
|
||||||
|
self.recovery.cancel();
|
||||||
let was_streaming = self.is_streaming().await;
|
let was_streaming = self.is_streaming().await;
|
||||||
|
|
||||||
if was_streaming {
|
if was_streaming {
|
||||||
self.stop_streaming().await?;
|
self.stop_streaming_inner().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
*self.config.write().await = new_config.clone();
|
*self.config.write().await = new_config.clone();
|
||||||
|
|
||||||
if new_config.enabled {
|
if new_config.enabled {
|
||||||
self.start_streaming().await?;
|
self.start_streaming_inner().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//! Shared device description with platform-specific enumeration backends.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[path = "device_linux.rs"]
|
#[path = "device_linux.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
@@ -6,4 +12,32 @@ mod imp;
|
|||||||
#[path = "device_windows.rs"]
|
#[path = "device_windows.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
|
|
||||||
pub use imp::*;
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AudioDeviceInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub card_index: i32,
|
||||||
|
pub device_index: i32,
|
||||||
|
pub sample_rates: Vec<u32>,
|
||||||
|
pub channels: Vec<u32>,
|
||||||
|
pub is_capture: bool,
|
||||||
|
pub is_hdmi: bool,
|
||||||
|
pub usb_bus: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
|
||||||
|
imp::enumerate_audio_devices_with_current(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enumerate_audio_devices_with_current(
|
||||||
|
current_device: Option<&str>,
|
||||||
|
) -> Result<Vec<AudioDeviceInfo>> {
|
||||||
|
imp::enumerate_audio_devices_with_current(current_device)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
||||||
|
imp::find_best_audio_device()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub(crate) use imp::find_wasapi_device;
|
||||||
|
|||||||
@@ -1,23 +1,10 @@
|
|||||||
use alsa::pcm::HwParams;
|
use alsa::pcm::HwParams;
|
||||||
use alsa::{Direction, PCM};
|
use alsa::{Direction, PCM};
|
||||||
use serde::Serialize;
|
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use super::AudioDeviceInfo;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct AudioDeviceInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub card_index: i32,
|
|
||||||
pub device_index: i32,
|
|
||||||
pub sample_rates: Vec<u32>,
|
|
||||||
pub channels: Vec<u32>,
|
|
||||||
pub is_capture: bool,
|
|
||||||
pub is_hdmi: bool,
|
|
||||||
pub usb_bus: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_usb_bus_info(card_index: i32) -> Option<String> {
|
fn get_usb_bus_info(card_index: i32) -> Option<String> {
|
||||||
if card_index < 0 {
|
if card_index < 0 {
|
||||||
return None;
|
return None;
|
||||||
@@ -28,26 +15,18 @@ fn get_usb_bus_info(card_index: i32) -> Option<String> {
|
|||||||
let link_str = link_target.to_string_lossy();
|
let link_str = link_target.to_string_lossy();
|
||||||
|
|
||||||
for component in link_str.split('/') {
|
for component in link_str.split('/') {
|
||||||
if component.contains('-') && !component.contains(':') {
|
if component.contains('-')
|
||||||
if component
|
&& !component.contains(':')
|
||||||
.chars()
|
&& component.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||||
.next()
|
|
||||||
.map(|c| c.is_ascii_digit())
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
{
|
||||||
return Some(component.to_string());
|
return Some(component.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
|
pub(super) fn enumerate_audio_devices_with_current(
|
||||||
enumerate_audio_devices_with_current(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enumerate_audio_devices_with_current(
|
|
||||||
current_device: Option<&str>,
|
current_device: Option<&str>,
|
||||||
) -> Result<Vec<AudioDeviceInfo>> {
|
) -> Result<Vec<AudioDeviceInfo>> {
|
||||||
let mut devices = Vec::new();
|
let mut devices = Vec::new();
|
||||||
@@ -153,8 +132,8 @@ fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
|
|||||||
(supported_rates, supported_channels)
|
(supported_rates, supported_channels)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
pub(super) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
||||||
let devices = enumerate_audio_devices()?;
|
let devices = enumerate_audio_devices_with_current(None)?;
|
||||||
|
|
||||||
if devices.is_empty() {
|
if devices.is_empty() {
|
||||||
return Err(AppError::AudioError(
|
return Err(AppError::AudioError(
|
||||||
@@ -194,7 +173,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_enumerate_devices() {
|
fn test_enumerate_devices() {
|
||||||
let result = enumerate_audio_devices();
|
let result = enumerate_audio_devices_with_current(None);
|
||||||
println!("Audio devices: {:?}", result);
|
println!("Audio devices: {:?}", result);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,12 @@
|
|||||||
use cpal::traits::{DeviceTrait, HostTrait};
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
use cpal::DeviceId;
|
use cpal::DeviceId;
|
||||||
use serde::Serialize;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use super::AudioDeviceInfo;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
pub(super) fn enumerate_audio_devices_with_current(
|
||||||
pub struct AudioDeviceInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub card_index: i32,
|
|
||||||
pub device_index: i32,
|
|
||||||
pub sample_rates: Vec<u32>,
|
|
||||||
pub channels: Vec<u32>,
|
|
||||||
pub is_capture: bool,
|
|
||||||
pub is_hdmi: bool,
|
|
||||||
pub usb_bus: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
|
|
||||||
enumerate_audio_devices_with_current(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enumerate_audio_devices_with_current(
|
|
||||||
current_device: Option<&str>,
|
current_device: Option<&str>,
|
||||||
) -> Result<Vec<AudioDeviceInfo>> {
|
) -> Result<Vec<AudioDeviceInfo>> {
|
||||||
let host = cpal::default_host();
|
let host = cpal::default_host();
|
||||||
@@ -192,8 +175,8 @@ pub(crate) fn find_wasapi_device(requested_device: &str) -> Result<cpal::Device>
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
pub(super) fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
||||||
let devices = enumerate_audio_devices()?;
|
let devices = enumerate_audio_devices_with_current(None)?;
|
||||||
|
|
||||||
if devices.is_empty() {
|
if devices.is_empty() {
|
||||||
return Err(AppError::AudioError(
|
return Err(AppError::AudioError(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use audiopus::{coder::Encoder, Application, Bitrate, Channels, SampleRate};
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use super::capture::AudioFrame;
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -154,11 +153,6 @@ impl OpusEncoder {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encode_frame(&mut self, frame: &AudioFrame) -> Result<OpusFrame> {
|
|
||||||
let samples: &[i16] = bytemuck::cast_slice(&frame.data);
|
|
||||||
self.encode(samples)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn config(&self) -> &OpusConfig {
|
pub fn config(&self) -> &OpusConfig {
|
||||||
&self.config
|
&self.config
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
//! Platform audio capture, Opus encode, device enumeration, streaming, controller, health monitor.
|
//! Platform audio capture, Opus encode, device enumeration, streaming, controller, health monitor.
|
||||||
|
|
||||||
#[cfg(any(unix, windows))]
|
#[cfg(any(unix, windows))]
|
||||||
pub mod capture;
|
mod capture;
|
||||||
pub mod controller;
|
mod controller;
|
||||||
#[cfg(any(unix, windows))]
|
#[cfg(any(unix, windows))]
|
||||||
pub mod device;
|
mod device;
|
||||||
#[cfg(any(unix, windows))]
|
#[cfg(any(unix, windows))]
|
||||||
pub mod encoder;
|
mod encoder;
|
||||||
pub mod monitor;
|
mod monitor;
|
||||||
pub mod recovery;
|
mod recovery;
|
||||||
pub mod streamer;
|
mod streamer;
|
||||||
pub mod types;
|
mod types;
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub mod uac;
|
||||||
|
|
||||||
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
|
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
|
||||||
pub use controller::AudioController;
|
pub use controller::AudioController;
|
||||||
|
|||||||
@@ -71,14 +71,14 @@ impl AudioHealthMonitor {
|
|||||||
|
|
||||||
pub async fn report_recovered(&self) {
|
pub async fn report_recovered(&self) {
|
||||||
let prev_status = self.status.read().await.clone();
|
let prev_status = self.status.read().await.clone();
|
||||||
|
self.suppress_display.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
if prev_status != AudioHealthStatus::Healthy {
|
if prev_status != AudioHealthStatus::Healthy {
|
||||||
let retry_count = self.retry_count.load(Ordering::Relaxed);
|
let retry_count = self.retry_count.load(Ordering::Relaxed);
|
||||||
info!("Audio recovered after {} retries", retry_count);
|
info!("Audio recovered after {} retries", retry_count);
|
||||||
|
|
||||||
self.suppress_display.store(false, Ordering::Relaxed);
|
|
||||||
self.retry_count.store(0, Ordering::Relaxed);
|
self.retry_count.store(0, Ordering::Relaxed);
|
||||||
self.throttler.clear("audio_");
|
self.throttler.clear_all();
|
||||||
*self.last_error_code.write().await = None;
|
*self.last_error_code.write().await = None;
|
||||||
*self.status.write().await = AudioHealthStatus::Healthy;
|
*self.status.write().await = AudioHealthStatus::Healthy;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
//! Audio device-loss monitoring and serialized recovery.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::capture::AudioConfig;
|
use super::capture::AudioConfig;
|
||||||
@@ -9,42 +12,281 @@ use super::device::{enumerate_audio_devices, AudioDeviceInfo};
|
|||||||
use super::monitor::AudioHealthMonitor;
|
use super::monitor::AudioHealthMonitor;
|
||||||
use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
|
use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
|
||||||
use super::types::AudioControllerConfig;
|
use super::types::AudioControllerConfig;
|
||||||
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
|
use crate::events::{EventBus, StreamKind, SystemEvent};
|
||||||
|
|
||||||
const AUDIO_RECOVERY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
|
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
|
||||||
pub(super) fn select_recovery_device(
|
struct RecoveryControl {
|
||||||
devices: &[AudioDeviceInfo],
|
/// Even values are idle; the following odd value is that recovery's token.
|
||||||
preferred: &str,
|
/// A single compare-exchange therefore owns both activity and generation.
|
||||||
) -> Option<AudioDeviceInfo> {
|
state: AtomicU64,
|
||||||
if let Some(device) = devices
|
|
||||||
.iter()
|
|
||||||
.find(|d| !preferred.trim().is_empty() && d.name == preferred)
|
|
||||||
{
|
|
||||||
return Some(device.clone());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
devices
|
impl RecoveryControl {
|
||||||
.iter()
|
fn new() -> Self {
|
||||||
.find(|d| d.is_hdmi && d.sample_rates.contains(&48_000) && d.channels.contains(&2))
|
Self {
|
||||||
.or_else(|| {
|
state: AtomicU64::new(0),
|
||||||
devices
|
}
|
||||||
.iter()
|
}
|
||||||
.find(|d| d.sample_rates.contains(&48_000) && d.channels.contains(&2))
|
|
||||||
})
|
fn begin(&self) -> Option<u64> {
|
||||||
.or_else(|| devices.first())
|
let idle = self.state.load(Ordering::Acquire);
|
||||||
.cloned()
|
if !idle.is_multiple_of(2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = idle.wrapping_add(1);
|
||||||
|
self.state
|
||||||
|
.compare_exchange(idle, token, Ordering::AcqRel, Ordering::Acquire)
|
||||||
|
.ok()
|
||||||
|
.map(|_| token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_current(&self, token: u64) -> bool {
|
||||||
|
self.state.load(Ordering::Acquire) == token
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&self, token: u64) {
|
||||||
|
let _ = self.state.compare_exchange(
|
||||||
|
token,
|
||||||
|
token.wrapping_add(1),
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Acquire,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&self) {
|
||||||
|
let token = self.state.load(Ordering::Acquire);
|
||||||
|
if !token.is_multiple_of(2) {
|
||||||
|
let _ = self.state.compare_exchange(
|
||||||
|
token,
|
||||||
|
token.wrapping_add(1),
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Acquire,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecoveryLease {
|
||||||
|
control: Arc<RecoveryControl>,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RecoveryLease {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.control.finish(self.generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecoveryInner {
|
||||||
|
config: Arc<RwLock<AudioControllerConfig>>,
|
||||||
|
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
||||||
|
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
||||||
|
monitor: Arc<AudioHealthMonitor>,
|
||||||
|
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
||||||
|
operation: Arc<Mutex<()>>,
|
||||||
|
control: Arc<RecoveryControl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) struct AudioRecovery {
|
||||||
|
inner: Arc<RecoveryInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioRecovery {
|
||||||
|
pub(super) fn new(
|
||||||
|
config: Arc<RwLock<AudioControllerConfig>>,
|
||||||
|
streamer: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
||||||
|
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
||||||
|
monitor: Arc<AudioHealthMonitor>,
|
||||||
|
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
||||||
|
operation: Arc<Mutex<()>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RecoveryInner {
|
||||||
|
config,
|
||||||
|
streamer,
|
||||||
|
event_bus,
|
||||||
|
monitor,
|
||||||
|
recovered_callback,
|
||||||
|
operation,
|
||||||
|
control: Arc::new(RecoveryControl::new()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn cancel(&self) {
|
||||||
|
self.inner.control.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn monitor(&self, streamer: Arc<AudioStreamer>, device: String) {
|
||||||
|
let recovery = self.clone();
|
||||||
|
let mut state = streamer.state_watch();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let current_state = *state.borrow();
|
||||||
|
match current_state {
|
||||||
|
AudioStreamState::Error => {}
|
||||||
|
AudioStreamState::Stopped => return,
|
||||||
|
AudioStreamState::Starting | AudioStreamState::Running => {
|
||||||
|
if state.changed().await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the ownership check with user-driven start/stop
|
||||||
|
// operations. If a stop already owns the operation lock, it
|
||||||
|
// removes the streamer before this monitor may start recovery.
|
||||||
|
let _operation = recovery.inner.operation.lock().await;
|
||||||
|
let is_current = recovery
|
||||||
|
.inner
|
||||||
|
.streamer
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|current| Arc::ptr_eq(current, &streamer));
|
||||||
|
if !is_current {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reason = format!("Audio device lost: {device}");
|
||||||
|
recovery
|
||||||
|
.inner
|
||||||
|
.monitor
|
||||||
|
.report_error(&reason, "device_lost")
|
||||||
|
.await;
|
||||||
|
recovery.start(device, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn start(&self, lost_device: String, reason: String) {
|
||||||
|
let Some(generation) = self.inner.control.begin() else {
|
||||||
|
debug!("Audio recovery already in progress");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let recovery = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _lease = RecoveryLease {
|
||||||
|
control: recovery.inner.control.clone(),
|
||||||
|
generation,
|
||||||
|
};
|
||||||
|
recovery.run(generation, lost_device, reason).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, generation: u64, lost_device: String, reason: String) {
|
||||||
|
warn!("Audio recovery started for {lost_device}: {reason}");
|
||||||
|
self.publish_device_lost(&lost_device, &reason).await;
|
||||||
|
self.publish_state(
|
||||||
|
"device_lost",
|
||||||
|
Some(lost_device.clone()),
|
||||||
|
Some("audio_device_lost"),
|
||||||
|
Some(RETRY_DELAY.as_millis() as u64),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut attempt = 0u32;
|
||||||
|
while self.inner.control.is_current(generation) {
|
||||||
|
let config = self.inner.config.read().await.clone();
|
||||||
|
if !config.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.inner
|
||||||
|
.streamer
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|streamer| streamer.is_running())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt = attempt.saturating_add(1);
|
||||||
|
self.publish_reconnecting(&lost_device, attempt).await;
|
||||||
|
self.publish_state(
|
||||||
|
"device_lost",
|
||||||
|
Some(lost_device.clone()),
|
||||||
|
Some("audio_reconnecting"),
|
||||||
|
Some(RETRY_DELAY.as_millis() as u64),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
tokio::time::sleep(RETRY_DELAY).await;
|
||||||
|
if !self.inner.control.is_current(generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices = match enumerate_audio_devices() {
|
||||||
|
Ok(devices) => devices,
|
||||||
|
Err(error) => {
|
||||||
|
debug!("Audio recovery enumeration attempt {attempt} failed: {error}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(device) = select_recovery_device(&devices, &config.device) else {
|
||||||
|
debug!("No audio device found on recovery attempt {attempt}");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let streamer = Arc::new(AudioStreamer::with_config(AudioStreamerConfig {
|
||||||
|
capture: AudioConfig {
|
||||||
|
device_name: device.name.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
opus: config.quality.to_opus_config(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if let Err(error) = streamer.start().await {
|
||||||
|
debug!(
|
||||||
|
"Audio recovery attempt {attempt} failed with {}: {error}",
|
||||||
|
device.name
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit a recovered streamer under the same operation lock used by
|
||||||
|
// user-driven start/stop/config updates. Cancellation is rechecked
|
||||||
|
// after acquiring the lock so an old task cannot resurrect itself.
|
||||||
|
let _operation = self.inner.operation.lock().await;
|
||||||
|
if !self.inner.control.is_current(generation) || !self.inner.config.read().await.enabled
|
||||||
|
{
|
||||||
|
let _ = streamer.stop().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.inner.config.write().await.device = device.name.clone();
|
||||||
|
*self.inner.streamer.write().await = Some(streamer.clone());
|
||||||
|
self.inner.monitor.report_recovered().await;
|
||||||
|
self.publish_recovered(&device.name).await;
|
||||||
|
if let Some(callback) = self.inner.recovered_callback.read().await.clone() {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
self.publish_state("streaming", Some(device.name.clone()), None, None)
|
||||||
|
.await;
|
||||||
|
info!(
|
||||||
|
"Audio recovered with {} after {} attempts",
|
||||||
|
device.name, attempt
|
||||||
|
);
|
||||||
|
self.inner.control.finish(generation);
|
||||||
|
self.monitor(streamer, device.name);
|
||||||
|
drop(_operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_state(
|
async fn publish_state(
|
||||||
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
|
&self,
|
||||||
state: &str,
|
state: &str,
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
reason: Option<&str>,
|
reason: Option<&str>,
|
||||||
next_retry_ms: Option<u64>,
|
next_retry_ms: Option<u64>,
|
||||||
) {
|
) {
|
||||||
if let Some(bus) = event_bus.read().await.as_ref() {
|
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
|
||||||
bus.publish(SystemEvent::StreamStateChanged {
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Audio,
|
||||||
state: state.to_string(),
|
state: state.to_string(),
|
||||||
device,
|
device,
|
||||||
reason: reason.map(str::to_string),
|
reason: reason.map(str::to_string),
|
||||||
@@ -54,26 +296,18 @@ async fn publish_state(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_device_lost(
|
async fn publish_device_lost(&self, device: &str, reason: &str) {
|
||||||
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
|
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
|
||||||
device: &str,
|
|
||||||
reason: &str,
|
|
||||||
) {
|
|
||||||
if let Some(bus) = event_bus.read().await.as_ref() {
|
|
||||||
bus.publish(SystemEvent::StreamDeviceLost {
|
bus.publish(SystemEvent::StreamDeviceLost {
|
||||||
kind: StreamDeviceLostKind::Audio,
|
kind: StreamKind::Audio,
|
||||||
device: device.to_string(),
|
device: device.to_string(),
|
||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_reconnecting(
|
async fn publish_reconnecting(&self, device: &str, attempt: u32) {
|
||||||
event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>,
|
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
|
||||||
device: &str,
|
|
||||||
attempt: u32,
|
|
||||||
) {
|
|
||||||
if let Some(bus) = event_bus.read().await.as_ref() {
|
|
||||||
bus.publish(SystemEvent::StreamReconnecting {
|
bus.publish(SystemEvent::StreamReconnecting {
|
||||||
device: device.to_string(),
|
device: device.to_string(),
|
||||||
attempt,
|
attempt,
|
||||||
@@ -81,240 +315,92 @@ async fn publish_reconnecting(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_recovered(event_bus: &Arc<RwLock<Option<Arc<EventBus>>>>, device: &str) {
|
async fn publish_recovered(&self, device: &str) {
|
||||||
if let Some(bus) = event_bus.read().await.as_ref() {
|
if let Some(bus) = self.inner.event_bus.read().await.as_ref() {
|
||||||
bus.publish(SystemEvent::StreamRecovered {
|
bus.publish(SystemEvent::StreamRecovered {
|
||||||
device: device.to_string(),
|
device: device.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_stream_monitor_from_parts(
|
|
||||||
config: Arc<RwLock<AudioControllerConfig>>,
|
|
||||||
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
|
||||||
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
|
||||||
monitor: Arc<AudioHealthMonitor>,
|
|
||||||
recovery_in_progress: Arc<AtomicBool>,
|
|
||||||
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
|
||||||
streamer: Arc<AudioStreamer>,
|
|
||||||
device: String,
|
|
||||||
) {
|
|
||||||
let mut state_rx = streamer.state_watch();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
if state_rx.changed().await.is_err() {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if *state_rx.borrow() != AudioStreamState::Error {
|
pub(super) fn select_recovery_device(
|
||||||
continue;
|
devices: &[AudioDeviceInfo],
|
||||||
|
preferred: &str,
|
||||||
|
) -> Option<AudioDeviceInfo> {
|
||||||
|
devices
|
||||||
|
.iter()
|
||||||
|
.find(|device| !preferred.trim().is_empty() && device.name == preferred)
|
||||||
|
.or_else(|| {
|
||||||
|
devices.iter().find(|device| {
|
||||||
|
device.is_hdmi
|
||||||
|
&& device.sample_rates.contains(&48_000)
|
||||||
|
&& device.channels.contains(&2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
devices.iter().find(|device| {
|
||||||
|
device.sample_rates.contains(&48_000) && device.channels.contains(&2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.or_else(|| devices.first())
|
||||||
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
#[cfg(test)]
|
||||||
let current = streamer_slot.read().await;
|
mod tests {
|
||||||
if !current
|
use super::*;
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|current| Arc::ptr_eq(current, &streamer))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let reason = format!("Audio device lost: {}", device);
|
fn device(name: &str, compatible: bool, hdmi: bool) -> AudioDeviceInfo {
|
||||||
monitor.report_error(&reason, "device_lost").await;
|
AudioDeviceInfo {
|
||||||
spawn_recovery_task_from_parts(
|
name: name.to_string(),
|
||||||
config,
|
description: name.to_string(),
|
||||||
streamer_slot,
|
card_index: 0,
|
||||||
event_bus,
|
device_index: 0,
|
||||||
monitor,
|
sample_rates: if compatible {
|
||||||
recovery_in_progress,
|
vec![48_000]
|
||||||
recovered_callback,
|
} else {
|
||||||
device,
|
vec![44_100]
|
||||||
reason,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_recovery_task_from_parts(
|
|
||||||
config: Arc<RwLock<AudioControllerConfig>>,
|
|
||||||
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
|
||||||
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
|
||||||
monitor: Arc<AudioHealthMonitor>,
|
|
||||||
recovery_in_progress: Arc<AtomicBool>,
|
|
||||||
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
|
||||||
lost_device: String,
|
|
||||||
reason: String,
|
|
||||||
) {
|
|
||||||
if recovery_in_progress.swap(true, Ordering::SeqCst) {
|
|
||||||
debug!("Audio recovery already in progress");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
warn!("Audio recovery started for {}: {}", lost_device, reason);
|
|
||||||
publish_device_lost(&event_bus, &lost_device, &reason).await;
|
|
||||||
publish_state(
|
|
||||||
&event_bus,
|
|
||||||
"device_lost",
|
|
||||||
Some(lost_device.clone()),
|
|
||||||
Some("audio_device_lost"),
|
|
||||||
Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut attempt = 0u32;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if !recovery_in_progress.load(Ordering::SeqCst) {
|
|
||||||
debug!("Audio recovery canceled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if streamer_slot
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|s| s.is_running())
|
|
||||||
{
|
|
||||||
recovery_in_progress.store(false, Ordering::SeqCst);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cfg: AudioControllerConfig = config.read().await.clone();
|
|
||||||
if !cfg.enabled {
|
|
||||||
recovery_in_progress.store(false, Ordering::SeqCst);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
attempt = attempt.saturating_add(1);
|
|
||||||
publish_reconnecting(&event_bus, &lost_device, attempt).await;
|
|
||||||
publish_state(
|
|
||||||
&event_bus,
|
|
||||||
"device_lost",
|
|
||||||
Some(lost_device.clone()),
|
|
||||||
Some("audio_reconnecting"),
|
|
||||||
Some(AUDIO_RECOVERY_RETRY_DELAY.as_millis() as u64),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
tokio::time::sleep(AUDIO_RECOVERY_RETRY_DELAY).await;
|
|
||||||
|
|
||||||
let devices = match enumerate_audio_devices() {
|
|
||||||
Ok(devices) => devices,
|
|
||||||
Err(e) => {
|
|
||||||
debug!(
|
|
||||||
"Audio recovery enumerate failed (attempt {}): {}",
|
|
||||||
attempt, e
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(device) = select_recovery_device(&devices, &cfg.device) else {
|
|
||||||
debug!("No audio devices found during recovery attempt {}", attempt);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let streamer_config = AudioStreamerConfig {
|
|
||||||
capture: AudioConfig {
|
|
||||||
device_name: device.name.clone(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
},
|
||||||
opus: cfg.quality.to_opus_config(),
|
channels: vec![2],
|
||||||
};
|
is_capture: true,
|
||||||
let new_streamer = Arc::new(AudioStreamer::with_config(streamer_config));
|
is_hdmi: hdmi,
|
||||||
|
usb_bus: None,
|
||||||
match new_streamer.start().await {
|
|
||||||
Ok(()) => {
|
|
||||||
{
|
|
||||||
let mut cfg = config.write().await;
|
|
||||||
cfg.device = device.name.clone();
|
|
||||||
}
|
}
|
||||||
*streamer_slot.write().await = Some(new_streamer.clone());
|
|
||||||
monitor.report_recovered().await;
|
|
||||||
publish_recovered(&event_bus, &device.name).await;
|
|
||||||
if let Some(callback) = recovered_callback.read().await.clone() {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
publish_state(
|
|
||||||
&event_bus,
|
|
||||||
"streaming",
|
|
||||||
Some(device.name.clone()),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
recovery_in_progress.store(false, Ordering::SeqCst);
|
|
||||||
info!(
|
|
||||||
"Audio device recovered with {} after {} attempts",
|
|
||||||
device.name, attempt
|
|
||||||
);
|
|
||||||
spawn_stream_monitor_from_parts(
|
|
||||||
config,
|
|
||||||
streamer_slot,
|
|
||||||
event_bus,
|
|
||||||
monitor,
|
|
||||||
recovery_in_progress,
|
|
||||||
recovered_callback,
|
|
||||||
new_streamer,
|
|
||||||
device.name,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!(
|
|
||||||
"Audio recovery start failed with {} (attempt {}): {}",
|
|
||||||
device.name, attempt, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn spawn_stream_monitor(
|
#[test]
|
||||||
config: Arc<RwLock<AudioControllerConfig>>,
|
fn stale_recovery_cannot_finish_a_new_generation() {
|
||||||
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
let control = RecoveryControl::new();
|
||||||
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
let stale = control.begin().unwrap();
|
||||||
monitor: Arc<AudioHealthMonitor>,
|
control.cancel();
|
||||||
recovery_in_progress: Arc<AtomicBool>,
|
let current = control.begin().unwrap();
|
||||||
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
|
||||||
streamer: Arc<AudioStreamer>,
|
control.finish(stale);
|
||||||
device: String,
|
assert!(control.is_current(current));
|
||||||
) {
|
|
||||||
spawn_stream_monitor_from_parts(
|
|
||||||
config,
|
|
||||||
streamer_slot,
|
|
||||||
event_bus,
|
|
||||||
monitor,
|
|
||||||
recovery_in_progress,
|
|
||||||
recovered_callback,
|
|
||||||
streamer,
|
|
||||||
device,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn spawn_recovery_task(
|
#[test]
|
||||||
config: Arc<RwLock<AudioControllerConfig>>,
|
fn completed_recovery_cannot_finish_the_next_recovery() {
|
||||||
streamer_slot: Arc<RwLock<Option<Arc<AudioStreamer>>>>,
|
let control = RecoveryControl::new();
|
||||||
event_bus: Arc<RwLock<Option<Arc<EventBus>>>>,
|
let completed = control.begin().unwrap();
|
||||||
monitor: Arc<AudioHealthMonitor>,
|
control.finish(completed);
|
||||||
recovery_in_progress: Arc<AtomicBool>,
|
let current = control.begin().unwrap();
|
||||||
recovered_callback: Arc<RwLock<Option<AudioRecoveredCallback>>>,
|
|
||||||
lost_device: String,
|
control.finish(completed);
|
||||||
reason: String,
|
assert!(control.is_current(current));
|
||||||
) {
|
}
|
||||||
spawn_recovery_task_from_parts(
|
|
||||||
config,
|
#[test]
|
||||||
streamer_slot,
|
fn recovery_prefers_requested_then_compatible_hdmi() {
|
||||||
event_bus,
|
let devices = vec![device("fallback", true, false), device("hdmi", true, true)];
|
||||||
monitor,
|
assert_eq!(
|
||||||
recovery_in_progress,
|
select_recovery_device(&devices, "fallback").unwrap().name,
|
||||||
recovered_callback,
|
"fallback"
|
||||||
lost_device,
|
);
|
||||||
reason,
|
assert_eq!(
|
||||||
|
select_recovery_device(&devices, "missing").unwrap().name,
|
||||||
|
"hdmi"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::{broadcast, mpsc, watch, Mutex as AsyncMutex, RwLock};
|
use tokio::sync::{broadcast, mpsc, watch, Mutex as AsyncMutex, RwLock};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use super::capture::{AudioCapturer, AudioConfig, AudioFrame, CaptureState};
|
use super::capture::{AudioCapturer, AudioConfig, CaptureState};
|
||||||
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
|
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use bytemuck;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// 48 kHz stereo: 20 ms = 960 × 2 samples (S16LE).
|
/// 48 kHz stereo: 20 ms = 960 × 2 samples (S16LE).
|
||||||
const OPUS_STEREO_SAMPLES: usize = 960 * 2;
|
const OPUS_STEREO_SAMPLES: usize = 960 * 2;
|
||||||
@@ -40,16 +40,6 @@ impl AudioStreamerConfig {
|
|||||||
opus: OpusConfig::default(),
|
opus: OpusConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_bitrate(mut self, bitrate: u32) -> Self {
|
|
||||||
self.opus.bitrate = bitrate;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct AudioStreamStats {
|
|
||||||
pub subscriber_count: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioStreamer {
|
pub struct AudioStreamer {
|
||||||
@@ -60,6 +50,9 @@ pub struct AudioStreamer {
|
|||||||
encoder: Arc<AsyncMutex<Option<OpusEncoder>>>,
|
encoder: Arc<AsyncMutex<Option<OpusEncoder>>>,
|
||||||
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
||||||
stop_flag: Arc<AtomicBool>,
|
stop_flag: Arc<AtomicBool>,
|
||||||
|
shutdown_generation: watch::Sender<u64>,
|
||||||
|
lifecycle: AsyncMutex<()>,
|
||||||
|
stream_task: AsyncMutex<Option<JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioStreamer {
|
impl AudioStreamer {
|
||||||
@@ -69,6 +62,7 @@ impl AudioStreamer {
|
|||||||
|
|
||||||
pub fn with_config(config: AudioStreamerConfig) -> Self {
|
pub fn with_config(config: AudioStreamerConfig) -> Self {
|
||||||
let (state_tx, state_rx) = watch::channel(AudioStreamState::Stopped);
|
let (state_tx, state_rx) = watch::channel(AudioStreamState::Stopped);
|
||||||
|
let (shutdown_generation, _) = watch::channel(0);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config: RwLock::new(config),
|
config: RwLock::new(config),
|
||||||
@@ -78,6 +72,9 @@ impl AudioStreamer {
|
|||||||
encoder: Arc::new(AsyncMutex::new(None)),
|
encoder: Arc::new(AsyncMutex::new(None)),
|
||||||
opus_subscribers: Arc::new(Mutex::new(Vec::new())),
|
opus_subscribers: Arc::new(Mutex::new(Vec::new())),
|
||||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||||
|
shutdown_generation,
|
||||||
|
lifecycle: AsyncMutex::new(()),
|
||||||
|
stream_task: AsyncMutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +87,9 @@ impl AudioStreamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscribe_opus(&self) -> mpsc::Receiver<Arc<OpusFrame>> {
|
pub fn subscribe_opus(&self) -> mpsc::Receiver<Arc<OpusFrame>> {
|
||||||
let (tx, rx) = mpsc::channel::<Arc<OpusFrame>>(128);
|
// Keep latency bounded for real-time consumers. Slow receivers lose
|
||||||
|
// new frames instead of accumulating seconds of stale audio.
|
||||||
|
let (tx, rx) = mpsc::channel::<Arc<OpusFrame>>(4);
|
||||||
self.opus_subscribers.lock().unwrap().push(tx);
|
self.opus_subscribers.lock().unwrap().push(tx);
|
||||||
rx
|
rx
|
||||||
}
|
}
|
||||||
@@ -104,22 +103,6 @@ impl AudioStreamer {
|
|||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stats(&self) -> AudioStreamStats {
|
|
||||||
AudioStreamStats {
|
|
||||||
subscriber_count: self.subscriber_count(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_config(&self, config: AudioStreamerConfig) -> Result<()> {
|
|
||||||
if self.state() != AudioStreamState::Stopped {
|
|
||||||
return Err(AppError::AudioError(
|
|
||||||
"Cannot change config while streaming".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
*self.config.write().await = config;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_bitrate(&self, bitrate: u32) -> Result<()> {
|
pub async fn set_bitrate(&self, bitrate: u32) -> Result<()> {
|
||||||
self.config.write().await.opus.bitrate = bitrate;
|
self.config.write().await.opus.bitrate = bitrate;
|
||||||
|
|
||||||
@@ -132,10 +115,25 @@ impl AudioStreamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(&self) -> Result<()> {
|
pub async fn start(&self) -> Result<()> {
|
||||||
if self.state() == AudioStreamState::Running {
|
let _lifecycle = self.lifecycle.lock().await;
|
||||||
|
if matches!(
|
||||||
|
self.state(),
|
||||||
|
AudioStreamState::Starting | AudioStreamState::Running
|
||||||
|
) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error and stopped states may still own completed task handles. Reap
|
||||||
|
// them before installing a new capture pipeline so restart is a clean
|
||||||
|
// lifecycle transition rather than an overwrite of old resources.
|
||||||
|
if let Some(capturer) = self.capturer.write().await.take() {
|
||||||
|
let _ = capturer.stop().await;
|
||||||
|
}
|
||||||
|
if let Some(task) = self.stream_task.lock().await.take() {
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
*self.encoder.lock().await = None;
|
||||||
|
|
||||||
let _ = self.state.send(AudioStreamState::Starting);
|
let _ = self.state.send(AudioStreamState::Starting);
|
||||||
self.stop_flag.store(false, Ordering::SeqCst);
|
self.stop_flag.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
@@ -149,13 +147,21 @@ impl AudioStreamer {
|
|||||||
config.opus.bitrate
|
config.opus.bitrate
|
||||||
);
|
);
|
||||||
|
|
||||||
let capturer = Arc::new(AudioCapturer::new(config.capture.clone()));
|
let encoder = match OpusEncoder::new(config.opus.clone()) {
|
||||||
*self.capturer.write().await = Some(capturer.clone());
|
Ok(encoder) => encoder,
|
||||||
|
Err(error) => {
|
||||||
let encoder = OpusEncoder::new(config.opus.clone())?;
|
let _ = self.state.send(AudioStreamState::Error);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
*self.encoder.lock().await = Some(encoder);
|
*self.encoder.lock().await = Some(encoder);
|
||||||
|
|
||||||
capturer.start().await?;
|
let capturer = Arc::new(AudioCapturer::new(config.capture.clone()));
|
||||||
|
*self.capturer.write().await = Some(capturer.clone());
|
||||||
|
if let Err(error) = capturer.start().await {
|
||||||
|
self.cleanup_failed_start(&capturer).await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
let mut capture_state = capturer.state_watch();
|
let mut capture_state = capturer.state_watch();
|
||||||
let startup_result = tokio::time::timeout(Duration::from_secs(2), async {
|
let startup_result = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
@@ -168,7 +174,7 @@ impl AudioStreamer {
|
|||||||
"Audio capture failed to start".to_string(),
|
"Audio capture failed to start".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
CaptureState::Stopped => {
|
CaptureState::Stopped | CaptureState::Starting => {
|
||||||
if capture_state.changed().await.is_err() {
|
if capture_state.changed().await.is_err() {
|
||||||
return Err(AppError::AudioError(
|
return Err(AppError::AudioError(
|
||||||
"Audio capture stopped during startup".to_string(),
|
"Audio capture stopped during startup".to_string(),
|
||||||
@@ -183,17 +189,11 @@ impl AudioStreamer {
|
|||||||
match startup_result {
|
match startup_result {
|
||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
let _ = capturer.stop().await;
|
self.cleanup_failed_start(&capturer).await;
|
||||||
*self.capturer.write().await = None;
|
|
||||||
*self.encoder.lock().await = None;
|
|
||||||
let _ = self.state.send(AudioStreamState::Error);
|
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = capturer.stop().await;
|
self.cleanup_failed_start(&capturer).await;
|
||||||
*self.capturer.write().await = None;
|
|
||||||
*self.encoder.lock().await = None;
|
|
||||||
let _ = self.state.send(AudioStreamState::Error);
|
|
||||||
return Err(AppError::AudioError(
|
return Err(AppError::AudioError(
|
||||||
"Timed out waiting for audio capture to start".to_string(),
|
"Timed out waiting for audio capture to start".to_string(),
|
||||||
));
|
));
|
||||||
@@ -205,22 +205,27 @@ impl AudioStreamer {
|
|||||||
let opus_subscribers = self.opus_subscribers.clone();
|
let opus_subscribers = self.opus_subscribers.clone();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
let stop_flag = self.stop_flag.clone();
|
let stop_flag = self.stop_flag.clone();
|
||||||
|
let shutdown_rx = self.shutdown_generation.subscribe();
|
||||||
|
let _ = self.state.send(AudioStreamState::Running);
|
||||||
|
|
||||||
tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
Self::stream_task(
|
Self::stream_task(
|
||||||
capturer_for_task,
|
capturer_for_task,
|
||||||
encoder,
|
encoder,
|
||||||
opus_subscribers,
|
opus_subscribers,
|
||||||
state,
|
state,
|
||||||
stop_flag,
|
stop_flag,
|
||||||
|
shutdown_rx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
*self.stream_task.lock().await = Some(task);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(&self) -> Result<()> {
|
pub async fn stop(&self) -> Result<()> {
|
||||||
|
let _lifecycle = self.lifecycle.lock().await;
|
||||||
if self.state() == AudioStreamState::Stopped {
|
if self.state() == AudioStreamState::Stopped {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -228,10 +233,16 @@ impl AudioStreamer {
|
|||||||
info!("Stopping audio stream");
|
info!("Stopping audio stream");
|
||||||
|
|
||||||
self.stop_flag.store(true, Ordering::SeqCst);
|
self.stop_flag.store(true, Ordering::SeqCst);
|
||||||
|
self.shutdown_generation.send_modify(|generation| {
|
||||||
|
*generation = generation.wrapping_add(1);
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(ref capturer) = *self.capturer.read().await {
|
if let Some(ref capturer) = *self.capturer.read().await {
|
||||||
capturer.stop().await?;
|
capturer.stop().await?;
|
||||||
}
|
}
|
||||||
|
if let Some(task) = self.stream_task.lock().await.take() {
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
|
||||||
*self.capturer.write().await = None;
|
*self.capturer.write().await = None;
|
||||||
*self.encoder.lock().await = None;
|
*self.encoder.lock().await = None;
|
||||||
@@ -242,28 +253,26 @@ impl AudioStreamer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cleanup_failed_start(&self, capturer: &AudioCapturer) {
|
||||||
|
let _ = capturer.stop().await;
|
||||||
|
*self.capturer.write().await = None;
|
||||||
|
*self.encoder.lock().await = None;
|
||||||
|
let _ = self.state.send(AudioStreamState::Error);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_running(&self) -> bool {
|
pub fn is_running(&self) -> bool {
|
||||||
self.state() == AudioStreamState::Running
|
self.state() == AudioStreamState::Running
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fanout_opus(
|
fn fanout_opus(
|
||||||
subscribers: &Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
subscribers: &Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
||||||
frame: Arc<OpusFrame>,
|
frame: Arc<OpusFrame>,
|
||||||
) {
|
) {
|
||||||
let txs: Vec<_> = {
|
let mut subscribers = subscribers.lock().unwrap();
|
||||||
let g = subscribers.lock().unwrap();
|
subscribers.retain(|subscriber| match subscriber.try_send(frame.clone()) {
|
||||||
if g.is_empty() {
|
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true,
|
||||||
return;
|
Err(mpsc::error::TrySendError::Closed(_)) => false,
|
||||||
}
|
});
|
||||||
g.clone()
|
|
||||||
};
|
|
||||||
for tx in &txs {
|
|
||||||
let _ = tx.send(frame.clone()).await;
|
|
||||||
}
|
|
||||||
if txs.iter().any(|tx| tx.is_closed()) {
|
|
||||||
let mut g = subscribers.lock().unwrap();
|
|
||||||
g.retain(|tx| !tx.is_closed());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_task(
|
async fn stream_task(
|
||||||
@@ -272,9 +281,9 @@ impl AudioStreamer {
|
|||||||
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
opus_subscribers: Arc<Mutex<Vec<mpsc::Sender<Arc<OpusFrame>>>>>,
|
||||||
state: watch::Sender<AudioStreamState>,
|
state: watch::Sender<AudioStreamState>,
|
||||||
stop_flag: Arc<AtomicBool>,
|
stop_flag: Arc<AtomicBool>,
|
||||||
|
mut shutdown_rx: watch::Receiver<u64>,
|
||||||
) {
|
) {
|
||||||
let mut pcm_rx = capturer.subscribe();
|
let mut pcm_rx = capturer.subscribe();
|
||||||
let _ = state.send(AudioStreamState::Running);
|
|
||||||
|
|
||||||
debug!("Audio stream task started (48 kHz stereo → Opus, mpsc fan-out)");
|
debug!("Audio stream task started (48 kHz stereo → Opus, mpsc fan-out)");
|
||||||
|
|
||||||
@@ -291,8 +300,19 @@ impl AudioStreamer {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let recv_result =
|
let recv_result = tokio::select! {
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(2), pcm_rx.recv()).await;
|
biased;
|
||||||
|
changed = shutdown_rx.changed() => {
|
||||||
|
if changed.is_ok() || stop_flag.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
pcm_rx.recv(),
|
||||||
|
) => result,
|
||||||
|
};
|
||||||
|
|
||||||
match recv_result {
|
match recv_result {
|
||||||
Ok(Ok(audio_frame)) => {
|
Ok(Ok(audio_frame)) => {
|
||||||
@@ -316,23 +336,17 @@ impl AudioStreamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while pending.len() >= OPUS_STEREO_SAMPLES {
|
while pending.len() >= OPUS_STEREO_SAMPLES {
|
||||||
let pcm_20ms = Bytes::copy_from_slice(bytemuck::cast_slice(
|
|
||||||
&pending[..OPUS_STEREO_SAMPLES],
|
|
||||||
));
|
|
||||||
pending.drain(..OPUS_STEREO_SAMPLES);
|
|
||||||
|
|
||||||
let frame_48k = AudioFrame::new_interleaved(pcm_20ms, 2, 48_000, 0);
|
|
||||||
|
|
||||||
let opus_result = {
|
let opus_result = {
|
||||||
let mut enc_guard = encoder.lock().await;
|
let mut enc_guard = encoder.lock().await;
|
||||||
(*enc_guard)
|
(*enc_guard)
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.map(|enc| enc.encode_frame(&frame_48k))
|
.map(|enc| enc.encode(&pending[..OPUS_STEREO_SAMPLES]))
|
||||||
};
|
};
|
||||||
|
pending.drain(..OPUS_STEREO_SAMPLES);
|
||||||
|
|
||||||
match opus_result {
|
match opus_result {
|
||||||
Some(Ok(opus_frame)) => {
|
Some(Ok(opus_frame)) => {
|
||||||
Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame)).await;
|
Self::fanout_opus(&opus_subscribers, Arc::new(opus_frame));
|
||||||
}
|
}
|
||||||
Some(Err(e)) => {
|
Some(Err(e)) => {
|
||||||
error!("Opus encode error: {}", e);
|
error!("Opus encode error: {}", e);
|
||||||
@@ -365,6 +379,7 @@ impl AudioStreamer {
|
|||||||
let _ = state.send(AudioStreamState::Stopped);
|
let _ = state.send(AudioStreamState::Stopped);
|
||||||
} else {
|
} else {
|
||||||
opus_subscribers.lock().unwrap().clear();
|
opus_subscribers.lock().unwrap().clear();
|
||||||
|
let _ = capturer.stop().await;
|
||||||
}
|
}
|
||||||
info!("Audio stream task ended");
|
info!("Audio stream task ended");
|
||||||
}
|
}
|
||||||
@@ -379,6 +394,7 @@ impl Default for AudioStreamer {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use bytes::Bytes;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_streamer_config_default() {
|
fn test_streamer_config_default() {
|
||||||
@@ -398,4 +414,42 @@ mod tests {
|
|||||||
let streamer = AudioStreamer::new();
|
let streamer = AudioStreamer::new();
|
||||||
assert_eq!(streamer.state(), AudioStreamState::Stopped);
|
assert_eq!(streamer.state(), AudioStreamState::Stopped);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slow_subscriber_does_not_block_or_grow_unbounded() {
|
||||||
|
let streamer = AudioStreamer::new();
|
||||||
|
let mut receiver = streamer.subscribe_opus();
|
||||||
|
for sequence in 0..20 {
|
||||||
|
AudioStreamer::fanout_opus(
|
||||||
|
&streamer.opus_subscribers,
|
||||||
|
Arc::new(OpusFrame {
|
||||||
|
data: Bytes::from_static(&[1]),
|
||||||
|
duration_ms: 20,
|
||||||
|
sequence,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut received = 0;
|
||||||
|
while receiver.try_recv().is_ok() {
|
||||||
|
received += 1;
|
||||||
|
}
|
||||||
|
assert_eq!(received, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closed_subscriber_is_pruned() {
|
||||||
|
let streamer = AudioStreamer::new();
|
||||||
|
let receiver = streamer.subscribe_opus();
|
||||||
|
drop(receiver);
|
||||||
|
AudioStreamer::fanout_opus(
|
||||||
|
&streamer.opus_subscribers,
|
||||||
|
Arc::new(OpusFrame {
|
||||||
|
data: Bytes::from_static(&[1]),
|
||||||
|
duration_ms: 20,
|
||||||
|
sequence: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(streamer.subscriber_count(), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/audio/uac/decoder.rs
Normal file
59
src/audio/uac/decoder.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use audiopus::coder::Decoder;
|
||||||
|
use audiopus::{Channels, SampleRate};
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
const CHANNELS: usize = 2;
|
||||||
|
const MAX_PACKET_BYTES: usize = 1275;
|
||||||
|
const MAX_SAMPLES_PER_CHANNEL: usize = 5760;
|
||||||
|
|
||||||
|
pub struct UacOpusDecoder {
|
||||||
|
decoder: Decoder,
|
||||||
|
buffer: Vec<i16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacOpusDecoder {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let decoder = Decoder::new(SampleRate::Hz48000, Channels::Stereo)
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Opus decoder init failed: {error}")))?;
|
||||||
|
Ok(Self {
|
||||||
|
decoder,
|
||||||
|
buffer: vec![0; MAX_SAMPLES_PER_CHANNEL * CHANNELS],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(&mut self, packet: &[u8]) -> Result<&[i16]> {
|
||||||
|
if packet.is_empty() || packet.len() > MAX_PACKET_BYTES {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"invalid Opus packet length {}",
|
||||||
|
packet.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let frames = self
|
||||||
|
.decoder
|
||||||
|
.decode(Some(packet), &mut self.buffer, false)
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Opus decode failed: {error}")))?;
|
||||||
|
Ok(&self.buffer[..frames * CHANNELS])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use audiopus::coder::Encoder;
|
||||||
|
use audiopus::Application;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_preserves_all_stereo_samples() {
|
||||||
|
let encoder =
|
||||||
|
Encoder::new(SampleRate::Hz48000, Channels::Stereo, Application::Audio).unwrap();
|
||||||
|
let pcm = vec![0i16; 960 * CHANNELS];
|
||||||
|
let mut packet = vec![0u8; MAX_PACKET_BYTES];
|
||||||
|
let packet_len = encoder.encode(&pcm, &mut packet).unwrap();
|
||||||
|
|
||||||
|
let mut decoder = UacOpusDecoder::new().unwrap();
|
||||||
|
let decoded = decoder.decode(&packet[..packet_len]).unwrap();
|
||||||
|
assert_eq!(decoded.len(), pcm.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/audio/uac/mod.rs
Normal file
9
src/audio/uac/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//! Browser-to-USB microphone audio pipeline.
|
||||||
|
|
||||||
|
mod decoder;
|
||||||
|
mod playback;
|
||||||
|
mod protocol;
|
||||||
|
|
||||||
|
pub use decoder::UacOpusDecoder;
|
||||||
|
pub use playback::{UacPlayback, UacPlaybackConfig, UacPlaybackState, UacSession};
|
||||||
|
pub use protocol::{parse_audio_packet, UacAudioPacket};
|
||||||
655
src/audio/uac/playback.rs
Normal file
655
src/audio/uac/playback.rs
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use alsa::pcm::{Access, Format, Frames, HwParams, State};
|
||||||
|
use alsa::{Direction, ValueOr, PCM};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
const RETRY_BACKOFF: Duration = Duration::from_secs(1);
|
||||||
|
const PERIOD_FRAMES: Frames = 1_024;
|
||||||
|
// Request the same compatibility buffer as the known-working ALSA player.
|
||||||
|
// The gadget driver may negotiate a smaller buffer; always use its result.
|
||||||
|
const BUFFER_FRAMES: Frames = 32_768;
|
||||||
|
const IDLE_REOPEN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const SINK_STALL_TIMEOUT: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum UacPlaybackState {
|
||||||
|
Idle,
|
||||||
|
Waiting,
|
||||||
|
Active,
|
||||||
|
Stalled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacPlaybackState {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Idle => "idle",
|
||||||
|
Self::Waiting => "waiting",
|
||||||
|
Self::Active => "active",
|
||||||
|
Self::Stalled => "stalled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UacPlaybackConfig {
|
||||||
|
pub device_name: String,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UacPlaybackConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
device_name: crate::otg::uac::find_uac_pcm_device()
|
||||||
|
.unwrap_or_else(crate::otg::uac::uac_pcm_device),
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaybackInner {
|
||||||
|
config: UacPlaybackConfig,
|
||||||
|
stopped: AtomicBool,
|
||||||
|
active_session: Mutex<Option<Arc<Mutex<SessionRuntime>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SessionSink {
|
||||||
|
Closed {
|
||||||
|
retry_at: Option<Instant>,
|
||||||
|
},
|
||||||
|
Probing {
|
||||||
|
pcm: PlaybackPcm,
|
||||||
|
stalled: bool,
|
||||||
|
},
|
||||||
|
Active {
|
||||||
|
pcm: PlaybackPcm,
|
||||||
|
last_progress: Instant,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionSink {
|
||||||
|
fn state(&self) -> UacPlaybackState {
|
||||||
|
match self {
|
||||||
|
Self::Closed { retry_at: None } => UacPlaybackState::Waiting,
|
||||||
|
Self::Closed { retry_at: Some(_) } => UacPlaybackState::Stalled,
|
||||||
|
Self::Probing { stalled: false, .. } => UacPlaybackState::Waiting,
|
||||||
|
Self::Probing { stalled: true, .. } => UacPlaybackState::Stalled,
|
||||||
|
Self::Active { .. } => UacPlaybackState::Active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionRuntime {
|
||||||
|
sink: SessionSink,
|
||||||
|
last_frame: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionRuntime {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sink: SessionSink::Closed { retry_at: None },
|
||||||
|
last_frame: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self) -> UacPlaybackState {
|
||||||
|
self.sink.state()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self) {
|
||||||
|
self.sink = SessionSink::Closed { retry_at: None };
|
||||||
|
self.last_frame = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance playback only when a WebSocket frame arrives. All ALSA handles
|
||||||
|
/// are non-blocking, so a slow or absent USB host drops the current frame
|
||||||
|
/// instead of occupying a worker thread or accumulating stale speech.
|
||||||
|
fn write(&mut self, config: &UacPlaybackConfig, samples: &[i16]) -> bool {
|
||||||
|
// Reopen on resume without an idle timer thread. stop()/session drop
|
||||||
|
// still close the PCM synchronously, even when no frames arrive.
|
||||||
|
if self
|
||||||
|
.last_frame
|
||||||
|
.is_some_and(|last| last.elapsed() >= IDLE_REOPEN_TIMEOUT)
|
||||||
|
{
|
||||||
|
self.close();
|
||||||
|
}
|
||||||
|
self.last_frame = Some(Instant::now());
|
||||||
|
let sink = std::mem::replace(&mut self.sink, SessionSink::Closed { retry_at: None });
|
||||||
|
let (next_sink, accepted) = drive_sink(sink, config, samples);
|
||||||
|
self.sink = next_sink;
|
||||||
|
accepted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UacPlayback {
|
||||||
|
inner: Arc<PlaybackInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UacSession {
|
||||||
|
playback: UacPlayback,
|
||||||
|
runtime: Arc<Mutex<SessionRuntime>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacPlayback {
|
||||||
|
pub fn start(config: UacPlaybackConfig) -> Result<Self> {
|
||||||
|
if config.sample_rate != 48_000 || config.channels != 2 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"UAC playback supports only 48000 Hz stereo".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
inner: Arc::new(PlaybackInner {
|
||||||
|
config,
|
||||||
|
stopped: AtomicBool::new(false),
|
||||||
|
active_session: Mutex::new(None),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn acquire_session(&self) -> Result<UacSession> {
|
||||||
|
let mut active = self.inner.active_session.lock().unwrap();
|
||||||
|
if self.inner.stopped.load(Ordering::Acquire) {
|
||||||
|
return Err(AppError::ServiceUnavailable(
|
||||||
|
"UAC playback is stopping".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if active.is_some() {
|
||||||
|
return Err(AppError::ServiceUnavailable(
|
||||||
|
"another UAC microphone session is already active".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime = Arc::new(Mutex::new(SessionRuntime::new()));
|
||||||
|
*active = Some(Arc::clone(&runtime));
|
||||||
|
Ok(UacSession {
|
||||||
|
playback: self.clone(),
|
||||||
|
runtime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting frames and synchronously close an active ALSA handle.
|
||||||
|
/// This guarantees configfs may rebuild the UAC function after this call.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
if self.inner.stopped.swap(true, Ordering::AcqRel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let runtime = self.inner.active_session.lock().unwrap().take();
|
||||||
|
if let Some(runtime) = runtime {
|
||||||
|
runtime.lock().unwrap().close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacSession {
|
||||||
|
pub fn state(&self) -> UacPlaybackState {
|
||||||
|
self.runtime.lock().unwrap().state()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the frame was accepted and the resulting target state.
|
||||||
|
pub fn try_write(&self, pcm: &[i16]) -> Result<(bool, UacPlaybackState)> {
|
||||||
|
let channels = self.playback.inner.config.channels as usize;
|
||||||
|
if pcm.is_empty() || !pcm.len().is_multiple_of(channels) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"UAC PCM must contain complete stereo frames".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.playback.inner.stopped.load(Ordering::Acquire) {
|
||||||
|
return Err(AppError::ServiceUnavailable(
|
||||||
|
"UAC playback has stopped".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut runtime = self.runtime.lock().unwrap();
|
||||||
|
if self.playback.inner.stopped.load(Ordering::Acquire) {
|
||||||
|
runtime.close();
|
||||||
|
return Err(AppError::ServiceUnavailable(
|
||||||
|
"UAC playback has stopped".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let accepted = runtime.write(&self.playback.inner.config, pcm);
|
||||||
|
Ok((accepted, runtime.state()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for UacSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut active = self.playback.inner.active_session.lock().unwrap();
|
||||||
|
if active
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|session| Arc::ptr_eq(session, &self.runtime))
|
||||||
|
{
|
||||||
|
*active = None;
|
||||||
|
}
|
||||||
|
drop(active);
|
||||||
|
self.runtime.lock().unwrap().close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_sink(
|
||||||
|
sink: SessionSink,
|
||||||
|
config: &UacPlaybackConfig,
|
||||||
|
samples: &[i16],
|
||||||
|
) -> (SessionSink, bool) {
|
||||||
|
match sink {
|
||||||
|
SessionSink::Closed { retry_at } => {
|
||||||
|
if retry_at.is_some_and(|deadline| Instant::now() < deadline) {
|
||||||
|
return (SessionSink::Closed { retry_at }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
match open_pcm(config).and_then(|mut pcm| {
|
||||||
|
pcm.prime_with_silence(config.channels as usize)?;
|
||||||
|
Ok(pcm)
|
||||||
|
}) {
|
||||||
|
Ok(pcm) => drive_probe(pcm, false, config, samples),
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Failed to open UAC playback device; retrying later: {error}");
|
||||||
|
(
|
||||||
|
SessionSink::Closed {
|
||||||
|
retry_at: Some(Instant::now() + RETRY_BACKOFF),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SessionSink::Probing { pcm, stalled } => drive_probe(pcm, stalled, config, samples),
|
||||||
|
SessionSink::Active { pcm, last_progress } => {
|
||||||
|
drive_active(pcm, last_progress, config, samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_probe(
|
||||||
|
mut pcm: PlaybackPcm,
|
||||||
|
stalled: bool,
|
||||||
|
config: &UacPlaybackConfig,
|
||||||
|
samples: &[i16],
|
||||||
|
) -> (SessionSink, bool) {
|
||||||
|
match pcm.consumption_progress() {
|
||||||
|
Ok(false) => (SessionSink::Probing { pcm, stalled }, false),
|
||||||
|
Ok(true) => {
|
||||||
|
// Keep the stream that has just started consuming. Dropping and
|
||||||
|
// preparing it here creates another startup/underrun window.
|
||||||
|
info!("UAC target started consuming microphone audio");
|
||||||
|
drive_active(pcm, Instant::now(), config, samples)
|
||||||
|
}
|
||||||
|
Err(error) => recover_sink(pcm, config, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_active(
|
||||||
|
mut pcm: PlaybackPcm,
|
||||||
|
last_progress: Instant,
|
||||||
|
config: &UacPlaybackConfig,
|
||||||
|
samples: &[i16],
|
||||||
|
) -> (SessionSink, bool) {
|
||||||
|
let last_progress = match pcm.consumption_progress() {
|
||||||
|
Ok(true) => Instant::now(),
|
||||||
|
Ok(false) => last_progress,
|
||||||
|
Err(error) => return recover_sink(pcm, config, error),
|
||||||
|
};
|
||||||
|
if last_progress.elapsed() >= SINK_STALL_TIMEOUT {
|
||||||
|
// Discard queued speech before probing an unavailable host again.
|
||||||
|
if let Err(error) = pcm.reset_and_prime(config.channels as usize) {
|
||||||
|
warn!("Failed to reset stalled UAC playback: {error}");
|
||||||
|
return retry_later();
|
||||||
|
}
|
||||||
|
info!("UAC target stopped consuming audio; waiting for playback activity");
|
||||||
|
return (SessionSink::Probing { pcm, stalled: true }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
match pcm.write_samples(samples, config.channels as usize) {
|
||||||
|
Ok(accepted) => (SessionSink::Active { pcm, last_progress }, accepted),
|
||||||
|
Err(error) => recover_sink(pcm, config, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover_sink(
|
||||||
|
mut pcm: PlaybackPcm,
|
||||||
|
config: &UacPlaybackConfig,
|
||||||
|
error: alsa::Error,
|
||||||
|
) -> (SessionSink, bool) {
|
||||||
|
match error.errno() {
|
||||||
|
libc::EAGAIN | libc::EINTR => (SessionSink::Probing { pcm, stalled: true }, false),
|
||||||
|
libc::EPIPE | libc::ESTRPIPE => {
|
||||||
|
// prepare restarts after XRUN/suspend without snd_pcm_recover's
|
||||||
|
// potentially unbounded resume loop. Start again with silence,
|
||||||
|
// and require fresh consumption before reporting Active.
|
||||||
|
match pcm.reset_and_prime(config.channels as usize) {
|
||||||
|
Ok(()) => (SessionSink::Probing { pcm, stalled: true }, false),
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Failed to recover UAC playback: {error}");
|
||||||
|
retry_later()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("UAC playback failed; reopening later: {error}");
|
||||||
|
retry_later()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_later() -> (SessionSink, bool) {
|
||||||
|
(
|
||||||
|
SessionSink::Closed {
|
||||||
|
retry_at: Some(Instant::now() + RETRY_BACKOFF),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_pcm(config: &UacPlaybackConfig) -> Result<PlaybackPcm> {
|
||||||
|
let pcm = PCM::new(&config.device_name, Direction::Playback, true).map_err(|error| {
|
||||||
|
AppError::AudioError(format!(
|
||||||
|
"Failed to open UAC device {}: {error}",
|
||||||
|
config.device_name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
{
|
||||||
|
let params = HwParams::any(&pcm)
|
||||||
|
.map_err(|error| AppError::AudioError(format!("UAC HwParams failed: {error}")))?;
|
||||||
|
params
|
||||||
|
.set_channels(config.channels as u32)
|
||||||
|
.and_then(|_| params.set_rate(config.sample_rate, ValueOr::Nearest))
|
||||||
|
.and_then(|_| params.set_format(Format::s16()))
|
||||||
|
.and_then(|_| params.set_access(Access::RWInterleaved))
|
||||||
|
.and_then(|_| params.set_period_size_near(PERIOD_FRAMES, ValueOr::Nearest))
|
||||||
|
.and_then(|_| params.set_buffer_size_near(BUFFER_FRAMES))
|
||||||
|
.and_then(|_| pcm.hw_params(¶ms))
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to configure UAC playback: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (buffer_frames, period_frames) = pcm.get_params().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to read UAC PCM parameters: {error}"))
|
||||||
|
})?;
|
||||||
|
{
|
||||||
|
let params = pcm.sw_params_current().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to read UAC SwParams: {error}"))
|
||||||
|
})?;
|
||||||
|
params
|
||||||
|
.set_start_threshold(buffer_frames as Frames)
|
||||||
|
.and_then(|_| params.set_stop_threshold(buffer_frames as Frames))
|
||||||
|
.and_then(|_| params.set_avail_min(period_frames as Frames))
|
||||||
|
.and_then(|_| pcm.sw_params(¶ms))
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to configure UAC SwParams: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
pcm.prepare().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to prepare UAC playback: {error}"))
|
||||||
|
})?;
|
||||||
|
info!(
|
||||||
|
"UAC playback opened on {} (buffer={} frames, period={} frames)",
|
||||||
|
config.device_name, buffer_frames, period_frames
|
||||||
|
);
|
||||||
|
Ok(PlaybackPcm {
|
||||||
|
pcm,
|
||||||
|
buffer_frames: buffer_frames as Frames,
|
||||||
|
period_frames: period_frames as Frames,
|
||||||
|
submitted_frames: 0,
|
||||||
|
consumed_frames: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaybackPcm {
|
||||||
|
pcm: PCM,
|
||||||
|
buffer_frames: Frames,
|
||||||
|
period_frames: Frames,
|
||||||
|
submitted_frames: u64,
|
||||||
|
consumed_frames: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackPcm {
|
||||||
|
fn consumption_progress(&mut self) -> std::result::Result<bool, alsa::Error> {
|
||||||
|
// avail synchronizes the hardware pointer. Successful writes alone
|
||||||
|
// only show that the ring buffer has room, not that USB is consuming.
|
||||||
|
let available = self.pcm.avail()?;
|
||||||
|
match self.pcm.state() {
|
||||||
|
State::XRun => return Err(alsa::Error::new("UAC PCM state", libc::EPIPE)),
|
||||||
|
State::Suspended => return Err(alsa::Error::new("UAC PCM state", libc::ESTRPIPE)),
|
||||||
|
State::Disconnected => return Err(alsa::Error::new("UAC PCM state", libc::ENODEV)),
|
||||||
|
State::Running => {}
|
||||||
|
_ => return Ok(false),
|
||||||
|
}
|
||||||
|
let consumed = consumed_frames(self.submitted_frames, self.buffer_frames, available);
|
||||||
|
let progressed = consumed > self.consumed_frames;
|
||||||
|
self.consumed_frames = consumed;
|
||||||
|
Ok(progressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_samples(
|
||||||
|
&mut self,
|
||||||
|
samples: &[i16],
|
||||||
|
channels: usize,
|
||||||
|
) -> std::result::Result<bool, alsa::Error> {
|
||||||
|
let io = self.pcm.io_i16()?;
|
||||||
|
let written = write_frames(samples, channels, self.period_frames as usize, |chunk| {
|
||||||
|
let written = io.writei(chunk)?;
|
||||||
|
self.submitted_frames += written as u64;
|
||||||
|
Ok(written)
|
||||||
|
})?;
|
||||||
|
Ok(written == samples.len() / channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prime_with_silence(&mut self, channels: usize) -> Result<()> {
|
||||||
|
// Use the negotiated capacity, not BUFFER_FRAMES. A near request is
|
||||||
|
// often clamped by u_audio's DMA buffer limit.
|
||||||
|
let silence = vec![0i16; self.buffer_frames as usize * channels];
|
||||||
|
let complete = self
|
||||||
|
.write_samples(&silence, channels)
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to prime UAC PCM: {error}")))?;
|
||||||
|
if !complete {
|
||||||
|
return Err(AppError::AudioError(
|
||||||
|
"UAC PCM priming was interrupted".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Most hardware starts automatically at the threshold. Some PCM
|
||||||
|
// plugins remain Prepared despite accepting the complete prefill.
|
||||||
|
// Start explicitly only after priming, and never restart a running PCM.
|
||||||
|
if self.pcm.state() == State::Prepared {
|
||||||
|
self.pcm.start().map_err(|error| {
|
||||||
|
AppError::AudioError(format!("Failed to start primed UAC PCM: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_and_prime(&mut self, channels: usize) -> Result<()> {
|
||||||
|
self.pcm
|
||||||
|
.drop()
|
||||||
|
.and_then(|_| self.pcm.prepare())
|
||||||
|
.map_err(|error| AppError::AudioError(format!("Failed to reset UAC PCM: {error}")))?;
|
||||||
|
self.submitted_frames = 0;
|
||||||
|
self.consumed_frames = 0;
|
||||||
|
self.prime_with_silence(channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn consumed_frames(submitted: u64, buffer: Frames, available: Frames) -> u64 {
|
||||||
|
let queued = (buffer - available.clamp(0, buffer)) as u64;
|
||||||
|
submitted.saturating_sub(queued)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bound each write to one negotiated period and advance by actual frames,
|
||||||
|
/// including short writes. Never wait for space or retain stale audio.
|
||||||
|
fn write_frames(
|
||||||
|
samples: &[i16],
|
||||||
|
channels: usize,
|
||||||
|
period_frames: usize,
|
||||||
|
mut write: impl FnMut(&[i16]) -> std::result::Result<usize, alsa::Error>,
|
||||||
|
) -> std::result::Result<usize, alsa::Error> {
|
||||||
|
let total_frames = samples.len() / channels;
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset < total_frames {
|
||||||
|
let end = (offset + period_frames).min(total_frames);
|
||||||
|
match write(&samples[offset * channels..end * channels]) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(written) => offset += written,
|
||||||
|
Err(error) if matches!(error.errno(), libc::EAGAIN | libc::EINTR) => break,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_short_frames_without_skipping_stereo_samples() {
|
||||||
|
let samples: Vec<i16> = (0..24).collect();
|
||||||
|
let mut received = Vec::new();
|
||||||
|
let written = write_frames(&samples, 2, 4, |chunk| {
|
||||||
|
assert!(chunk.len() <= 8);
|
||||||
|
// Simulate a device accepting only one frame per write.
|
||||||
|
received.extend_from_slice(&chunk[..2]);
|
||||||
|
Ok(1)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(written, 12);
|
||||||
|
assert_eq!(received, samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_device_stops_writing_without_waiting_or_claiming_whole_packet() {
|
||||||
|
let mut calls = 0;
|
||||||
|
let written = write_frames(&[0; 24], 2, 4, |_| {
|
||||||
|
calls += 1;
|
||||||
|
if calls == 1 {
|
||||||
|
Ok(2)
|
||||||
|
} else {
|
||||||
|
Err(alsa::Error::new("test write", libc::EAGAIN))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(written, 2);
|
||||||
|
assert_eq!(calls, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writing_into_free_space_is_not_host_consumption() {
|
||||||
|
// A partially filled or full buffer can exist without any USB I/O.
|
||||||
|
assert_eq!(consumed_frames(1024, 4096, 3072), 0);
|
||||||
|
assert_eq!(consumed_frames(4096, 4096, 0), 0);
|
||||||
|
// Consuming a period, followed by filling it again, preserves progress.
|
||||||
|
assert_eq!(consumed_frames(4096, 4096, 1024), 1024);
|
||||||
|
assert_eq!(consumed_frames(5120, 4096, 0), 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn null_config() -> UacPlaybackConfig {
|
||||||
|
UacPlaybackConfig {
|
||||||
|
device_name: "null".into(),
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_pcm_uses_negotiated_start_threshold_and_recovers_to_probing() {
|
||||||
|
// ALSA's null plugin exercises real libasound configuration and I/O
|
||||||
|
// without requiring a USB controller. It cannot verify DWC3 behavior.
|
||||||
|
let config = null_config();
|
||||||
|
let mut pcm = open_pcm(&config).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pcm.pcm
|
||||||
|
.sw_params_current()
|
||||||
|
.unwrap()
|
||||||
|
.get_start_threshold()
|
||||||
|
.unwrap(),
|
||||||
|
pcm.buffer_frames
|
||||||
|
);
|
||||||
|
pcm.prime_with_silence(2).unwrap();
|
||||||
|
assert!(pcm.consumption_progress().unwrap());
|
||||||
|
let (sink, accepted) =
|
||||||
|
recover_sink(pcm, &config, alsa::Error::new("test xrun", libc::EPIPE));
|
||||||
|
assert!(!accepted);
|
||||||
|
assert!(matches!(sink, SessionSink::Probing { stalled: true, .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_closes_an_open_native_pcm_before_returning() {
|
||||||
|
let playback = UacPlayback::start(null_config()).unwrap();
|
||||||
|
let session = playback.acquire_session().unwrap();
|
||||||
|
session.try_write(&[0; 2048]).unwrap();
|
||||||
|
assert!(!matches!(
|
||||||
|
session.runtime.lock().unwrap().sink,
|
||||||
|
SessionSink::Closed { .. }
|
||||||
|
));
|
||||||
|
playback.stop();
|
||||||
|
assert!(matches!(
|
||||||
|
session.runtime.lock().unwrap().sink,
|
||||||
|
SessionSink::Closed { retry_at: None }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idle_resume_reopens_instead_of_reusing_previous_sink() {
|
||||||
|
let config = null_config();
|
||||||
|
let mut runtime = SessionRuntime::new();
|
||||||
|
runtime.sink = SessionSink::Closed {
|
||||||
|
retry_at: Some(Instant::now() + Duration::from_secs(60)),
|
||||||
|
};
|
||||||
|
runtime.last_frame = Some(Instant::now() - IDLE_REOPEN_TIMEOUT);
|
||||||
|
runtime.write(&config, &[0; 2048]);
|
||||||
|
assert!(!matches!(runtime.sink, SessionSink::Closed { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permits_only_one_microphone_session() {
|
||||||
|
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
|
||||||
|
let first = playback.acquire_session().unwrap();
|
||||||
|
assert_eq!(first.state(), UacPlaybackState::Waiting);
|
||||||
|
assert!(playback.acquire_session().is_err());
|
||||||
|
|
||||||
|
drop(first);
|
||||||
|
assert!(playback.acquire_session().is_ok());
|
||||||
|
playback.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_rejects_new_and_existing_session_writes() {
|
||||||
|
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
|
||||||
|
let session = playback.acquire_session().unwrap();
|
||||||
|
|
||||||
|
playback.stop();
|
||||||
|
|
||||||
|
assert!(session.try_write(&[0, 0]).is_err());
|
||||||
|
assert!(playback.acquire_session().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_incomplete_stereo_frames_before_opening_alsa() {
|
||||||
|
let playback = UacPlayback::start(UacPlaybackConfig::default()).unwrap();
|
||||||
|
let session = playback.acquire_session().unwrap();
|
||||||
|
|
||||||
|
assert!(session.try_write(&[0]).is_err());
|
||||||
|
assert_eq!(session.state(), UacPlaybackState::Waiting);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closed_sink_state_reflects_retry_backoff() {
|
||||||
|
assert_eq!(
|
||||||
|
SessionSink::Closed { retry_at: None }.state(),
|
||||||
|
UacPlaybackState::Waiting
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SessionSink::Closed {
|
||||||
|
retry_at: Some(Instant::now())
|
||||||
|
}
|
||||||
|
.state(),
|
||||||
|
UacPlaybackState::Stalled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/audio/uac/protocol.rs
Normal file
98
src/audio/uac/protocol.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
const HEADER_SIZE: usize = 15;
|
||||||
|
const OPUS_MESSAGE: u8 = 0x03;
|
||||||
|
const PCM_MESSAGE: u8 = 0x04;
|
||||||
|
const CHANNELS: usize = 2;
|
||||||
|
const MAX_PCM_SAMPLES: usize = 5760 * CHANNELS;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum UacAudioPacket<'a> {
|
||||||
|
Opus(&'a [u8]),
|
||||||
|
Pcm(&'a [u8]),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacAudioPacket<'_> {
|
||||||
|
pub fn pcm_samples(&self) -> Result<Vec<i16>> {
|
||||||
|
let Self::Pcm(bytes) = self else {
|
||||||
|
return Err(AppError::BadRequest("packet is not raw PCM".to_string()));
|
||||||
|
};
|
||||||
|
if bytes.is_empty() || bytes.len() % (CHANNELS * 2) != 0 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"invalid stereo PCM byte length {}",
|
||||||
|
bytes.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if bytes.len() / 2 > MAX_PCM_SAMPLES {
|
||||||
|
return Err(AppError::BadRequest("PCM frame exceeds 120 ms".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(bytes
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|sample| i16::from_le_bytes([sample[0], sample[1]]))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_audio_packet(data: &[u8]) -> Result<UacAudioPacket<'_>> {
|
||||||
|
if data.len() < HEADER_SIZE {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"UAC frame is shorter than its header".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize;
|
||||||
|
let expected_len = HEADER_SIZE
|
||||||
|
.checked_add(payload_len)
|
||||||
|
.ok_or_else(|| AppError::BadRequest("UAC payload length overflow".to_string()))?;
|
||||||
|
if data.len() != expected_len {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"UAC payload length does not match its header".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = &data[HEADER_SIZE..];
|
||||||
|
match data[0] {
|
||||||
|
OPUS_MESSAGE => Ok(UacAudioPacket::Opus(payload)),
|
||||||
|
PCM_MESSAGE => Ok(UacAudioPacket::Pcm(payload)),
|
||||||
|
message_type => Err(AppError::BadRequest(format!(
|
||||||
|
"unsupported UAC message type 0x{message_type:02x}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn message(message_type: u8, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut data = vec![0; HEADER_SIZE + payload.len()];
|
||||||
|
data[0] = message_type;
|
||||||
|
data[11..15].copy_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||||
|
data[HEADER_SIZE..].copy_from_slice(payload);
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_exact_payload_length() {
|
||||||
|
let valid = message(OPUS_MESSAGE, &[1, 2, 3]);
|
||||||
|
assert_eq!(
|
||||||
|
parse_audio_packet(&valid).unwrap(),
|
||||||
|
UacAudioPacket::Opus(&[1, 2, 3])
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut trailing = valid.clone();
|
||||||
|
trailing.push(4);
|
||||||
|
assert!(parse_audio_packet(&trailing).is_err());
|
||||||
|
assert!(parse_audio_packet(&valid[..valid.len() - 1]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_little_endian_stereo_pcm() {
|
||||||
|
let data = message(PCM_MESSAGE, &[1, 0, 255, 255]);
|
||||||
|
assert_eq!(
|
||||||
|
parse_audio_packet(&data).unwrap().pcm_samples().unwrap(),
|
||||||
|
vec![1, -1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ pub async fn auth_middleware(
|
|||||||
fn unauthorized_response(message: &str) -> Response {
|
fn unauthorized_response(message: &str) -> Response {
|
||||||
let body = ErrorResponse {
|
let body = ErrorResponse {
|
||||||
success: false,
|
success: false,
|
||||||
|
code: None,
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
};
|
};
|
||||||
(StatusCode::UNAUTHORIZED, Json(body)).into_response()
|
(StatusCode::UNAUTHORIZED, Json(body)).into_response()
|
||||||
@@ -92,6 +93,11 @@ fn is_public_endpoint(path: &str) -> bool {
|
|||||||
fn is_setup_public_endpoint(path: &str) -> bool {
|
fn is_setup_public_endpoint(path: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
path,
|
path,
|
||||||
"/setup" | "/setup/init" | "/devices" | "/stream/codecs"
|
"/setup"
|
||||||
|
| "/setup/init"
|
||||||
|
| "/devices"
|
||||||
|
| "/video/input-status"
|
||||||
|
| "/stream/codecs"
|
||||||
|
| "/video/codecs"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod computer_use;
|
|||||||
mod hid;
|
mod hid;
|
||||||
mod otg_network;
|
mod otg_network;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
mod uac;
|
||||||
mod watchdog;
|
mod watchdog;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ pub use computer_use::*;
|
|||||||
pub use hid::*;
|
pub use hid::*;
|
||||||
pub use otg_network::*;
|
pub use otg_network::*;
|
||||||
pub use stream::*;
|
pub use stream::*;
|
||||||
|
pub use uac::*;
|
||||||
pub use watchdog::*;
|
pub use watchdog::*;
|
||||||
pub use web::*;
|
pub use web::*;
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ pub struct AppConfig {
|
|||||||
pub rtsp: RtspConfig,
|
pub rtsp: RtspConfig,
|
||||||
pub redfish: RedfishConfig,
|
pub redfish: RedfishConfig,
|
||||||
pub watchdog: WatchdogConfig,
|
pub watchdog: WatchdogConfig,
|
||||||
|
pub uac: UacConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -51,6 +54,7 @@ impl AppConfig {
|
|||||||
if self.hid.backend != HidBackend::Otg {
|
if self.hid.backend != HidBackend::Otg {
|
||||||
self.msd.enabled = false;
|
self.msd.enabled = false;
|
||||||
self.otg_network.enabled = false;
|
self.otg_network.enabled = false;
|
||||||
|
self.uac.enabled = false;
|
||||||
}
|
}
|
||||||
self.atx.normalize();
|
self.atx.normalize();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ pub enum EncoderType {
|
|||||||
Amf,
|
Amf,
|
||||||
Rkmpp,
|
Rkmpp,
|
||||||
V4l2m2m,
|
V4l2m2m,
|
||||||
|
Amlogic,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EncoderType {
|
impl EncoderType {
|
||||||
@@ -116,6 +117,7 @@ impl EncoderType {
|
|||||||
EncoderType::Amf => "AMD AMF",
|
EncoderType::Amf => "AMD AMF",
|
||||||
EncoderType::Rkmpp => "Rockchip MPP",
|
EncoderType::Rkmpp => "Rockchip MPP",
|
||||||
EncoderType::V4l2m2m => "V4L2 M2M",
|
EncoderType::V4l2m2m => "V4L2 M2M",
|
||||||
|
EncoderType::Amlogic => "AMLENC",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/config/schema/uac.rs
Normal file
78
src/config/schema/uac.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use typeshare::typeshare;
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
/// Configuration for the USB Audio Class microphone gadget.
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct UacConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UacConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
sample_rate: 48_000,
|
||||||
|
channels: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacConfig {
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
// Older configurations stored zero-valued placeholders while UAC was
|
||||||
|
// disabled. Accept them until the feature is enabled and normalized.
|
||||||
|
if !self.enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.sample_rate != 48_000 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"unsupported UAC sample rate {} (expected 48000)",
|
||||||
|
self.sample_rate
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if self.channels != 2 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"unsupported UAC channel count {} (expected 2)",
|
||||||
|
self.channels
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_match_the_audio_transport() {
|
||||||
|
let config = UacConfig::default();
|
||||||
|
assert_eq!(config.sample_rate, 48_000);
|
||||||
|
assert_eq!(config.channels, 2);
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_formats_the_transport_cannot_convert() {
|
||||||
|
assert!(UacConfig {
|
||||||
|
enabled: true,
|
||||||
|
sample_rate: 44_100,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_err());
|
||||||
|
assert!(UacConfig {
|
||||||
|
enabled: true,
|
||||||
|
channels: 1,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.validate()
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,23 +44,56 @@ impl Default for VideoConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct MsdConfig {
|
pub struct MsdConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub msd_dir: String,
|
pub msd_dir: String,
|
||||||
|
pub flash_inquiry_string: String,
|
||||||
|
pub cdrom_inquiry_string: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const DEFAULT_FLASH_INQUIRY_STRING: &str = "One-KVM Virtual Flash";
|
||||||
|
pub const DEFAULT_CDROM_INQUIRY_STRING: &str = "One-KVM Virtual CD-ROM";
|
||||||
|
pub const MAX_INQUIRY_STRING_BYTES: usize = 28;
|
||||||
|
|
||||||
impl Default for MsdConfig {
|
impl Default for MsdConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
msd_dir: String::new(),
|
msd_dir: String::new(),
|
||||||
|
flash_inquiry_string: DEFAULT_FLASH_INQUIRY_STRING.to_string(),
|
||||||
|
cdrom_inquiry_string: DEFAULT_CDROM_INQUIRY_STRING.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MsdConfig {
|
impl MsdConfig {
|
||||||
|
pub fn validate(&self) -> crate::error::Result<()> {
|
||||||
|
Self::validate_inquiry_string("Flash", &self.flash_inquiry_string)?;
|
||||||
|
Self::validate_inquiry_string("CD-ROM", &self.cdrom_inquiry_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_inquiry_string(kind: &str, value: &str) -> crate::error::Result<()> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(crate::error::AppError::BadRequest(format!(
|
||||||
|
"MSD {kind} inquiry string cannot be empty"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if value.len() > MAX_INQUIRY_STRING_BYTES {
|
||||||
|
return Err(crate::error::AppError::BadRequest(format!(
|
||||||
|
"MSD {kind} inquiry string must be at most {MAX_INQUIRY_STRING_BYTES} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !value.bytes().all(|byte| (0x20..=0x7e).contains(&byte)) {
|
||||||
|
return Err(crate::error::AppError::BadRequest(format!(
|
||||||
|
"MSD {kind} inquiry string must contain printable ASCII characters only"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn msd_dir_path(&self) -> std::path::PathBuf {
|
pub fn msd_dir_path(&self) -> std::path::PathBuf {
|
||||||
std::path::PathBuf::from(&self.msd_dir)
|
std::path::PathBuf::from(&self.msd_dir)
|
||||||
}
|
}
|
||||||
@@ -123,3 +156,18 @@ impl Default for WebConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn msd_inquiry_strings_default_and_validate() {
|
||||||
|
assert!(MsdConfig::default().validate().is_ok());
|
||||||
|
assert!(MsdConfig::validate_inquiry_string("Flash", " Custom Drive ").is_ok());
|
||||||
|
assert!(MsdConfig::validate_inquiry_string("Flash", "").is_err());
|
||||||
|
assert!(MsdConfig::validate_inquiry_string("Flash", &"x".repeat(29)).is_err());
|
||||||
|
assert!(MsdConfig::validate_inquiry_string("CD-ROM", "虚拟光驱").is_err());
|
||||||
|
assert!(MsdConfig::validate_inquiry_string("CD-ROM", "bad\tname").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
366
src/error.rs
366
src/error.rs
@@ -1,5 +1,236 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::fmt;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum MsdErrorCode {
|
||||||
|
MsdUnavailable,
|
||||||
|
MsdOperationInProgress,
|
||||||
|
MsdOperationFailed,
|
||||||
|
MsdInvalidRequest,
|
||||||
|
MsdResourceNotFound,
|
||||||
|
MsdResourceAlreadyExists,
|
||||||
|
MsdMediaSlotsFull,
|
||||||
|
MsdMediaAlreadyMounted,
|
||||||
|
MsdMediaInUse,
|
||||||
|
MsdImageTooLarge,
|
||||||
|
MsdInvalidUrl,
|
||||||
|
MsdRemoteDownloadFailed,
|
||||||
|
MsdDownloadIncomplete,
|
||||||
|
MsdDriveNotInitialized,
|
||||||
|
MsdDriveConnected,
|
||||||
|
MsdDriveFilesystemUnsupported,
|
||||||
|
MsdDriveSizeInvalid,
|
||||||
|
MsdStorageSpaceUnavailable,
|
||||||
|
MsdStorageFull,
|
||||||
|
MsdStorageReadOnly,
|
||||||
|
MsdStoragePermissionDenied,
|
||||||
|
MsdMediumRemovalPrevented,
|
||||||
|
MsdDisconnectFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MsdErrorCode {
|
||||||
|
pub const ALL: [Self; 23] = [
|
||||||
|
Self::MsdUnavailable,
|
||||||
|
Self::MsdOperationInProgress,
|
||||||
|
Self::MsdOperationFailed,
|
||||||
|
Self::MsdInvalidRequest,
|
||||||
|
Self::MsdResourceNotFound,
|
||||||
|
Self::MsdResourceAlreadyExists,
|
||||||
|
Self::MsdMediaSlotsFull,
|
||||||
|
Self::MsdMediaAlreadyMounted,
|
||||||
|
Self::MsdMediaInUse,
|
||||||
|
Self::MsdImageTooLarge,
|
||||||
|
Self::MsdInvalidUrl,
|
||||||
|
Self::MsdRemoteDownloadFailed,
|
||||||
|
Self::MsdDownloadIncomplete,
|
||||||
|
Self::MsdDriveNotInitialized,
|
||||||
|
Self::MsdDriveConnected,
|
||||||
|
Self::MsdDriveFilesystemUnsupported,
|
||||||
|
Self::MsdDriveSizeInvalid,
|
||||||
|
Self::MsdStorageSpaceUnavailable,
|
||||||
|
Self::MsdStorageFull,
|
||||||
|
Self::MsdStorageReadOnly,
|
||||||
|
Self::MsdStoragePermissionDenied,
|
||||||
|
Self::MsdMediumRemovalPrevented,
|
||||||
|
Self::MsdDisconnectFailed,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::MsdUnavailable => "MSD_UNAVAILABLE",
|
||||||
|
Self::MsdOperationInProgress => "MSD_OPERATION_IN_PROGRESS",
|
||||||
|
Self::MsdOperationFailed => "MSD_OPERATION_FAILED",
|
||||||
|
Self::MsdInvalidRequest => "MSD_INVALID_REQUEST",
|
||||||
|
Self::MsdResourceNotFound => "MSD_RESOURCE_NOT_FOUND",
|
||||||
|
Self::MsdResourceAlreadyExists => "MSD_RESOURCE_ALREADY_EXISTS",
|
||||||
|
Self::MsdMediaSlotsFull => "MSD_MEDIA_SLOTS_FULL",
|
||||||
|
Self::MsdMediaAlreadyMounted => "MSD_MEDIA_ALREADY_MOUNTED",
|
||||||
|
Self::MsdMediaInUse => "MSD_MEDIA_IN_USE",
|
||||||
|
Self::MsdImageTooLarge => "MSD_IMAGE_TOO_LARGE",
|
||||||
|
Self::MsdInvalidUrl => "MSD_INVALID_URL",
|
||||||
|
Self::MsdRemoteDownloadFailed => "MSD_REMOTE_DOWNLOAD_FAILED",
|
||||||
|
Self::MsdDownloadIncomplete => "MSD_DOWNLOAD_INCOMPLETE",
|
||||||
|
Self::MsdDriveNotInitialized => "MSD_DRIVE_NOT_INITIALIZED",
|
||||||
|
Self::MsdDriveConnected => "MSD_DRIVE_CONNECTED",
|
||||||
|
Self::MsdDriveFilesystemUnsupported => "MSD_DRIVE_FILESYSTEM_UNSUPPORTED",
|
||||||
|
Self::MsdDriveSizeInvalid => "MSD_DRIVE_SIZE_INVALID",
|
||||||
|
Self::MsdStorageSpaceUnavailable => "MSD_STORAGE_SPACE_UNAVAILABLE",
|
||||||
|
Self::MsdStorageFull => "MSD_STORAGE_FULL",
|
||||||
|
Self::MsdStorageReadOnly => "MSD_STORAGE_READ_ONLY",
|
||||||
|
Self::MsdStoragePermissionDenied => "MSD_STORAGE_PERMISSION_DENIED",
|
||||||
|
Self::MsdMediumRemovalPrevented => "MSD_MEDIUM_REMOVAL_PREVENTED",
|
||||||
|
Self::MsdDisconnectFailed => "MSD_DISCONNECT_FAILED",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn message(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::MsdUnavailable => "Virtual media service is unavailable.",
|
||||||
|
Self::MsdOperationInProgress => "Another virtual media operation is in progress.",
|
||||||
|
Self::MsdOperationFailed => "The virtual media operation failed.",
|
||||||
|
Self::MsdInvalidRequest => "The virtual media request is invalid.",
|
||||||
|
Self::MsdResourceNotFound => "The requested virtual media resource was not found.",
|
||||||
|
Self::MsdResourceAlreadyExists => "The virtual media resource already exists.",
|
||||||
|
Self::MsdMediaSlotsFull => "All virtual media slots are in use.",
|
||||||
|
Self::MsdMediaAlreadyMounted => "The virtual medium is already mounted.",
|
||||||
|
Self::MsdMediaInUse => "The virtual medium is currently in use.",
|
||||||
|
Self::MsdImageTooLarge => "The virtual media image is too large.",
|
||||||
|
Self::MsdInvalidUrl => "The download URL is invalid.",
|
||||||
|
Self::MsdRemoteDownloadFailed => "The remote image download failed.",
|
||||||
|
Self::MsdDownloadIncomplete => "The remote image download was incomplete.",
|
||||||
|
Self::MsdDriveNotInitialized => "The virtual drive is not initialized.",
|
||||||
|
Self::MsdDriveConnected => "The virtual drive is connected to the controlled computer.",
|
||||||
|
Self::MsdDriveFilesystemUnsupported => "The virtual drive filesystem is unsupported.",
|
||||||
|
Self::MsdDriveSizeInvalid => "The virtual drive size is invalid.",
|
||||||
|
Self::MsdStorageSpaceUnavailable => {
|
||||||
|
"Available virtual media storage space could not be determined."
|
||||||
|
}
|
||||||
|
Self::MsdStorageFull => "Virtual media storage does not have enough free space.",
|
||||||
|
Self::MsdStorageReadOnly => "Virtual media storage is read-only.",
|
||||||
|
Self::MsdStoragePermissionDenied => {
|
||||||
|
"Permission to access virtual media storage was denied."
|
||||||
|
}
|
||||||
|
Self::MsdMediumRemovalPrevented => {
|
||||||
|
"The controlled computer prevented removal of the virtual medium."
|
||||||
|
}
|
||||||
|
Self::MsdDisconnectFailed => "The virtual medium could not be disconnected.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn redfish_key(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::MsdUnavailable => "MsdUnavailable",
|
||||||
|
Self::MsdOperationInProgress => "MsdOperationInProgress",
|
||||||
|
Self::MsdOperationFailed => "MsdOperationFailed",
|
||||||
|
Self::MsdInvalidRequest => "MsdInvalidRequest",
|
||||||
|
Self::MsdResourceNotFound => "MsdResourceNotFound",
|
||||||
|
Self::MsdResourceAlreadyExists => "MsdResourceAlreadyExists",
|
||||||
|
Self::MsdMediaSlotsFull => "MsdMediaSlotsFull",
|
||||||
|
Self::MsdMediaAlreadyMounted => "MsdMediaAlreadyMounted",
|
||||||
|
Self::MsdMediaInUse => "MsdMediaInUse",
|
||||||
|
Self::MsdImageTooLarge => "MsdImageTooLarge",
|
||||||
|
Self::MsdInvalidUrl => "MsdInvalidUrl",
|
||||||
|
Self::MsdRemoteDownloadFailed => "MsdRemoteDownloadFailed",
|
||||||
|
Self::MsdDownloadIncomplete => "MsdDownloadIncomplete",
|
||||||
|
Self::MsdDriveNotInitialized => "MsdDriveNotInitialized",
|
||||||
|
Self::MsdDriveConnected => "MsdDriveConnected",
|
||||||
|
Self::MsdDriveFilesystemUnsupported => "MsdDriveFilesystemUnsupported",
|
||||||
|
Self::MsdDriveSizeInvalid => "MsdDriveSizeInvalid",
|
||||||
|
Self::MsdStorageSpaceUnavailable => "MsdStorageSpaceUnavailable",
|
||||||
|
Self::MsdStorageFull => "MsdStorageFull",
|
||||||
|
Self::MsdStorageReadOnly => "MsdStorageReadOnly",
|
||||||
|
Self::MsdStoragePermissionDenied => "MsdStoragePermissionDenied",
|
||||||
|
Self::MsdMediumRemovalPrevented => "MsdMediumRemovalPrevented",
|
||||||
|
Self::MsdDisconnectFailed => "MsdDisconnectFailed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn severity(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::MsdUnavailable | Self::MsdOperationFailed | Self::MsdDisconnectFailed => {
|
||||||
|
"Critical"
|
||||||
|
}
|
||||||
|
_ => "Warning",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn resolution(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::MsdUnavailable => "Enable or restore the virtual media service, then retry.",
|
||||||
|
Self::MsdOperationInProgress => {
|
||||||
|
"Wait for the current virtual media operation to finish, then retry."
|
||||||
|
}
|
||||||
|
Self::MsdResourceNotFound | Self::MsdDriveNotInitialized => {
|
||||||
|
"Verify that the requested virtual media resource exists, then retry."
|
||||||
|
}
|
||||||
|
Self::MsdResourceAlreadyExists => {
|
||||||
|
"Use a different resource name or remove the existing resource, then retry."
|
||||||
|
}
|
||||||
|
Self::MsdMediaSlotsFull => "Eject an inserted virtual medium, then retry.",
|
||||||
|
Self::MsdMediaAlreadyMounted => {
|
||||||
|
"Eject the existing virtual medium before mounting it again."
|
||||||
|
}
|
||||||
|
Self::MsdMediaInUse | Self::MsdDriveConnected | Self::MsdMediumRemovalPrevented => {
|
||||||
|
"Eject or unmount the virtual medium on the controlled computer, then retry."
|
||||||
|
}
|
||||||
|
Self::MsdImageTooLarge | Self::MsdDriveSizeInvalid => {
|
||||||
|
"Use a supported image or virtual drive size, then retry."
|
||||||
|
}
|
||||||
|
Self::MsdInvalidUrl | Self::MsdInvalidRequest => "Correct the request and retry.",
|
||||||
|
Self::MsdRemoteDownloadFailed | Self::MsdDownloadIncomplete => {
|
||||||
|
"Verify the remote server and network connection, then retry."
|
||||||
|
}
|
||||||
|
Self::MsdDriveFilesystemUnsupported => {
|
||||||
|
"Reinitialize the virtual drive with a supported filesystem, then retry."
|
||||||
|
}
|
||||||
|
Self::MsdStorageSpaceUnavailable => {
|
||||||
|
"Verify that virtual media storage is available, then retry."
|
||||||
|
}
|
||||||
|
Self::MsdStorageFull => {
|
||||||
|
"Free space in virtual media storage or select a smaller image, then retry."
|
||||||
|
}
|
||||||
|
Self::MsdStorageReadOnly => "Make virtual media storage writable, then retry.",
|
||||||
|
Self::MsdStoragePermissionDenied => {
|
||||||
|
"Correct virtual media storage permissions, then retry."
|
||||||
|
}
|
||||||
|
Self::MsdOperationFailed | Self::MsdDisconnectFailed => {
|
||||||
|
"Retry the operation. If the problem persists, check the One-KVM system logs."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct MsdError {
|
||||||
|
code: MsdErrorCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MsdError {
|
||||||
|
pub const fn new(code: MsdErrorCode) -> Self {
|
||||||
|
Self { code }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn code(self) -> MsdErrorCode {
|
||||||
|
self.code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MsdError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(self.code.message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for MsdError {}
|
||||||
|
|
||||||
|
impl From<MsdErrorCode> for AppError {
|
||||||
|
fn from(code: MsdErrorCode) -> Self {
|
||||||
|
Self::Msd(MsdError::new(code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
#[error("Authentication failed: {0}")]
|
#[error("Authentication failed: {0}")]
|
||||||
@@ -26,6 +257,9 @@ pub enum AppError {
|
|||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Msd(#[from] MsdError),
|
||||||
|
|
||||||
#[error("Configuration error: {0}")]
|
#[error("Configuration error: {0}")]
|
||||||
Config(String),
|
Config(String),
|
||||||
|
|
||||||
@@ -66,3 +300,135 @@ impl From<sqlx::Error> for AppError {
|
|||||||
AppError::Persistence(err.to_string())
|
AppError::Persistence(err.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::MsdErrorCode::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn msd_codes_and_messages_are_stable() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
MsdUnavailable,
|
||||||
|
"MSD_UNAVAILABLE",
|
||||||
|
"Virtual media service is unavailable.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdOperationInProgress,
|
||||||
|
"MSD_OPERATION_IN_PROGRESS",
|
||||||
|
"Another virtual media operation is in progress.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdOperationFailed,
|
||||||
|
"MSD_OPERATION_FAILED",
|
||||||
|
"The virtual media operation failed.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdInvalidRequest,
|
||||||
|
"MSD_INVALID_REQUEST",
|
||||||
|
"The virtual media request is invalid.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdResourceNotFound,
|
||||||
|
"MSD_RESOURCE_NOT_FOUND",
|
||||||
|
"The requested virtual media resource was not found.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdResourceAlreadyExists,
|
||||||
|
"MSD_RESOURCE_ALREADY_EXISTS",
|
||||||
|
"The virtual media resource already exists.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdMediaSlotsFull,
|
||||||
|
"MSD_MEDIA_SLOTS_FULL",
|
||||||
|
"All virtual media slots are in use.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdMediaAlreadyMounted,
|
||||||
|
"MSD_MEDIA_ALREADY_MOUNTED",
|
||||||
|
"The virtual medium is already mounted.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdMediaInUse,
|
||||||
|
"MSD_MEDIA_IN_USE",
|
||||||
|
"The virtual medium is currently in use.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdImageTooLarge,
|
||||||
|
"MSD_IMAGE_TOO_LARGE",
|
||||||
|
"The virtual media image is too large.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdInvalidUrl,
|
||||||
|
"MSD_INVALID_URL",
|
||||||
|
"The download URL is invalid.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdRemoteDownloadFailed,
|
||||||
|
"MSD_REMOTE_DOWNLOAD_FAILED",
|
||||||
|
"The remote image download failed.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdDownloadIncomplete,
|
||||||
|
"MSD_DOWNLOAD_INCOMPLETE",
|
||||||
|
"The remote image download was incomplete.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdDriveNotInitialized,
|
||||||
|
"MSD_DRIVE_NOT_INITIALIZED",
|
||||||
|
"The virtual drive is not initialized.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdDriveConnected,
|
||||||
|
"MSD_DRIVE_CONNECTED",
|
||||||
|
"The virtual drive is connected to the controlled computer.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdDriveFilesystemUnsupported,
|
||||||
|
"MSD_DRIVE_FILESYSTEM_UNSUPPORTED",
|
||||||
|
"The virtual drive filesystem is unsupported.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdDriveSizeInvalid,
|
||||||
|
"MSD_DRIVE_SIZE_INVALID",
|
||||||
|
"The virtual drive size is invalid.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdStorageSpaceUnavailable,
|
||||||
|
"MSD_STORAGE_SPACE_UNAVAILABLE",
|
||||||
|
"Available virtual media storage space could not be determined.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdStorageFull,
|
||||||
|
"MSD_STORAGE_FULL",
|
||||||
|
"Virtual media storage does not have enough free space.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdStorageReadOnly,
|
||||||
|
"MSD_STORAGE_READ_ONLY",
|
||||||
|
"Virtual media storage is read-only.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdStoragePermissionDenied,
|
||||||
|
"MSD_STORAGE_PERMISSION_DENIED",
|
||||||
|
"Permission to access virtual media storage was denied.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdMediumRemovalPrevented,
|
||||||
|
"MSD_MEDIUM_REMOVAL_PREVENTED",
|
||||||
|
"The controlled computer prevented removal of the virtual medium.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MsdDisconnectFailed,
|
||||||
|
"MSD_DISCONNECT_FAILED",
|
||||||
|
"The virtual medium could not be disconnected.",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(cases.len(), super::MsdErrorCode::ALL.len());
|
||||||
|
for (code, expected_code, expected_message) in cases {
|
||||||
|
assert_eq!(code.as_str(), expected_code);
|
||||||
|
assert_eq!(code.message(), expected_message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ use self::types::EXACT_EVENT_TOPICS;
|
|||||||
|
|
||||||
pub use types::{
|
pub use types::{
|
||||||
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo,
|
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo,
|
||||||
MsdDeviceMediaInfo, StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
|
MsdDeviceMediaInfo, StreamKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use std::sync::RwLock;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||||
@@ -40,6 +41,7 @@ pub struct EventBus {
|
|||||||
exact_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
|
exact_topics: std::collections::HashMap<&'static str, broadcast::Sender<SystemEvent>>,
|
||||||
prefix_topics: std::collections::HashMap<String, broadcast::Sender<SystemEvent>>,
|
prefix_topics: std::collections::HashMap<String, broadcast::Sender<SystemEvent>>,
|
||||||
device_info_dirty_tx: broadcast::Sender<()>,
|
device_info_dirty_tx: broadcast::Sender<()>,
|
||||||
|
latest_video_stream_state: RwLock<Option<SystemEvent>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventBus {
|
impl EventBus {
|
||||||
@@ -60,12 +62,26 @@ impl EventBus {
|
|||||||
exact_topics,
|
exact_topics,
|
||||||
prefix_topics,
|
prefix_topics,
|
||||||
device_info_dirty_tx,
|
device_info_dirty_tx,
|
||||||
|
latest_video_stream_state: RwLock::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn publish(&self, event: SystemEvent) {
|
pub fn publish(&self, event: SystemEvent) {
|
||||||
let event_name = event.event_name();
|
let event_name = event.event_name();
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
event,
|
||||||
|
SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Video,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
*self
|
||||||
|
.latest_video_stream_state
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(event.clone());
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(tx) = self.exact_topics.get(event_name) {
|
if let Some(tx) = self.exact_topics.get(event_name) {
|
||||||
let _ = tx.send(event.clone());
|
let _ = tx.send(event.clone());
|
||||||
}
|
}
|
||||||
@@ -103,6 +119,15 @@ impl EventBus {
|
|||||||
self.device_info_dirty_tx.subscribe()
|
self.device_info_dirty_tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stateful video status topics replay this value to new WebSocket
|
||||||
|
/// subscribers so a page refresh cannot miss an earlier signal-loss edge.
|
||||||
|
pub fn latest_video_stream_state(&self) -> Option<SystemEvent> {
|
||||||
|
self.latest_video_stream_state
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn subscriber_count(&self) -> usize {
|
pub fn subscriber_count(&self) -> usize {
|
||||||
self.tx.receiver_count()
|
self.tx.receiver_count()
|
||||||
}
|
}
|
||||||
@@ -124,6 +149,7 @@ mod tests {
|
|||||||
let mut rx = bus.subscribe();
|
let mut rx = bus.subscribe();
|
||||||
|
|
||||||
bus.publish(SystemEvent::StreamStateChanged {
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Video,
|
||||||
state: "streaming".to_string(),
|
state: "streaming".to_string(),
|
||||||
device: Some("/dev/video0".to_string()),
|
device: Some("/dev/video0".to_string()),
|
||||||
reason: None,
|
reason: None,
|
||||||
@@ -132,6 +158,10 @@ mod tests {
|
|||||||
|
|
||||||
let event = rx.recv().await.unwrap();
|
let event = rx.recv().await.unwrap();
|
||||||
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
|
assert!(matches!(event, SystemEvent::StreamStateChanged { .. }));
|
||||||
|
assert!(matches!(
|
||||||
|
bus.latest_video_stream_state(),
|
||||||
|
Some(SystemEvent::StreamStateChanged { state, .. }) if state == "streaming"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -143,6 +173,7 @@ mod tests {
|
|||||||
assert_eq!(bus.subscriber_count(), 2);
|
assert_eq!(bus.subscriber_count(), 2);
|
||||||
|
|
||||||
bus.publish(SystemEvent::StreamStateChanged {
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Video,
|
||||||
state: "ready".to_string(),
|
state: "ready".to_string(),
|
||||||
device: Some("/dev/video0".to_string()),
|
device: Some("/dev/video0".to_string()),
|
||||||
reason: None,
|
reason: None,
|
||||||
@@ -162,6 +193,7 @@ mod tests {
|
|||||||
let mut rx = bus.subscribe_topic("stream.state_changed").unwrap();
|
let mut rx = bus.subscribe_topic("stream.state_changed").unwrap();
|
||||||
|
|
||||||
bus.publish(SystemEvent::StreamStateChanged {
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Video,
|
||||||
state: "ready".to_string(),
|
state: "ready".to_string(),
|
||||||
device: None,
|
device: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
@@ -178,6 +210,7 @@ mod tests {
|
|||||||
let mut rx = bus.subscribe_topic("stream.*").unwrap();
|
let mut rx = bus.subscribe_topic("stream.*").unwrap();
|
||||||
|
|
||||||
bus.publish(SystemEvent::StreamStateChanged {
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Video,
|
||||||
state: "ready".to_string(),
|
state: "ready".to_string(),
|
||||||
device: None,
|
device: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
@@ -200,10 +233,36 @@ mod tests {
|
|||||||
assert_eq!(bus.subscriber_count(), 0);
|
assert_eq!(bus.subscriber_count(), 0);
|
||||||
|
|
||||||
bus.publish(SystemEvent::StreamStateChanged {
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Video,
|
||||||
state: "ready".to_string(),
|
state: "ready".to_string(),
|
||||||
device: None,
|
device: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
next_retry_ms: None,
|
next_retry_ms: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_state_does_not_replace_latest_video_state() {
|
||||||
|
let bus = EventBus::new();
|
||||||
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Video,
|
||||||
|
state: "no_signal".to_string(),
|
||||||
|
device: Some("/dev/video0".to_string()),
|
||||||
|
reason: Some("no_sync".to_string()),
|
||||||
|
next_retry_ms: Some(500),
|
||||||
|
});
|
||||||
|
bus.publish(SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Audio,
|
||||||
|
state: "streaming".to_string(),
|
||||||
|
device: Some("hw:0,0".to_string()),
|
||||||
|
reason: None,
|
||||||
|
next_retry_ms: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
bus.latest_video_stream_state(),
|
||||||
|
Some(SystemEvent::StreamStateChanged { state, reason, .. })
|
||||||
|
if state == "no_signal" && reason.as_deref() == Some("no_sync")
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ pub struct ClientStats {
|
|||||||
pub connected_secs: u64,
|
pub connected_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Video vs audio source for [`SystemEvent::StreamDeviceLost`] (WebSocket `stream.device_lost`).
|
/// Media subsystem that owns a stream state or device event.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum StreamDeviceLostKind {
|
pub enum StreamKind {
|
||||||
Video,
|
Video,
|
||||||
Audio,
|
Audio,
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,7 @@ pub enum SystemEvent {
|
|||||||
|
|
||||||
#[serde(rename = "stream.state_changed")]
|
#[serde(rename = "stream.state_changed")]
|
||||||
StreamStateChanged {
|
StreamStateChanged {
|
||||||
|
kind: StreamKind,
|
||||||
state: String,
|
state: String,
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -141,7 +142,7 @@ pub enum SystemEvent {
|
|||||||
|
|
||||||
#[serde(rename = "stream.device_lost")]
|
#[serde(rename = "stream.device_lost")]
|
||||||
StreamDeviceLost {
|
StreamDeviceLost {
|
||||||
kind: StreamDeviceLostKind,
|
kind: StreamKind,
|
||||||
device: String,
|
device: String,
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
@@ -204,6 +205,8 @@ pub enum SystemEvent {
|
|||||||
total_bytes: Option<u64>,
|
total_bytes: Option<u64>,
|
||||||
progress_pct: Option<f32>,
|
progress_pct: Option<f32>,
|
||||||
status: String,
|
status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error_code: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
#[serde(rename = "system.device_info")]
|
#[serde(rename = "system.device_info")]
|
||||||
@@ -272,6 +275,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_event_name() {
|
fn test_event_name() {
|
||||||
let event = SystemEvent::StreamStateChanged {
|
let event = SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Video,
|
||||||
state: "streaming".to_string(),
|
state: "streaming".to_string(),
|
||||||
device: Some("/dev/video0".to_string()),
|
device: Some("/dev/video0".to_string()),
|
||||||
reason: None,
|
reason: None,
|
||||||
@@ -283,7 +287,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn stream_device_lost_json_snake_case_kind() {
|
fn stream_device_lost_json_snake_case_kind() {
|
||||||
let event = SystemEvent::StreamDeviceLost {
|
let event = SystemEvent::StreamDeviceLost {
|
||||||
kind: StreamDeviceLostKind::Audio,
|
kind: StreamKind::Audio,
|
||||||
device: "hw:0,0".to_string(),
|
device: "hw:0,0".to_string(),
|
||||||
reason: "test".to_string(),
|
reason: "test".to_string(),
|
||||||
};
|
};
|
||||||
@@ -304,6 +308,7 @@ mod tests {
|
|||||||
from_mode: String::new(),
|
from_mode: String::new(),
|
||||||
},
|
},
|
||||||
SystemEvent::StreamStateChanged {
|
SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Video,
|
||||||
state: String::new(),
|
state: String::new(),
|
||||||
device: None,
|
device: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
@@ -321,7 +326,7 @@ mod tests {
|
|||||||
fps: 0,
|
fps: 0,
|
||||||
},
|
},
|
||||||
SystemEvent::StreamDeviceLost {
|
SystemEvent::StreamDeviceLost {
|
||||||
kind: StreamDeviceLostKind::Video,
|
kind: StreamKind::Video,
|
||||||
device: String::new(),
|
device: String::new(),
|
||||||
reason: String::new(),
|
reason: String::new(),
|
||||||
},
|
},
|
||||||
@@ -372,6 +377,7 @@ mod tests {
|
|||||||
total_bytes: None,
|
total_bytes: None,
|
||||||
progress_pct: None,
|
progress_pct: None,
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
|
error_code: None,
|
||||||
},
|
},
|
||||||
SystemEvent::DeviceInfo {
|
SystemEvent::DeviceInfo {
|
||||||
video: VideoDeviceInfo {
|
video: VideoDeviceInfo {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::path::PathBuf;
|
use std::path::Path;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tempfile::TempDir;
|
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use toml_edit::DocumentMut;
|
|
||||||
|
|
||||||
|
use super::protected_config::ProtectedConfigFile;
|
||||||
use super::types::*;
|
use super::types::*;
|
||||||
|
use super::validation::{validate_easytier_config, validate_frpc_config, validate_gostc_config};
|
||||||
use crate::events::EventBus;
|
use crate::events::EventBus;
|
||||||
|
|
||||||
const LOG_BUFFER_SIZE: usize = 200;
|
const LOG_BUFFER_SIZE: usize = 200;
|
||||||
@@ -27,12 +27,12 @@ const TTYD_TCP_PORT: &str = "7681";
|
|||||||
struct ExtensionProcess {
|
struct ExtensionProcess {
|
||||||
child: Child,
|
child: Child,
|
||||||
logs: Arc<RwLock<VecDeque<String>>>,
|
logs: Arc<RwLock<VecDeque<String>>>,
|
||||||
_temp_dir: Option<TempDir>,
|
_config_file: Option<ProtectedConfigFile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExtensionLaunch {
|
struct ExtensionLaunch {
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
temp_dir: Option<TempDir>,
|
config_file: Option<ProtectedConfigFile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ExtensionManager {
|
pub struct ExtensionManager {
|
||||||
@@ -83,24 +83,12 @@ impl ExtensionManager {
|
|||||||
match id {
|
match id {
|
||||||
ExtensionId::Ttyd => config.ttyd.enabled,
|
ExtensionId::Ttyd => config.ttyd.enabled,
|
||||||
ExtensionId::Gostc => {
|
ExtensionId::Gostc => {
|
||||||
config.gostc.enabled
|
config.gostc.enabled && validate_gostc_config(&config.gostc).is_ok()
|
||||||
&& !config.gostc.key.is_empty()
|
|
||||||
&& !config.gostc.addr.trim().is_empty()
|
|
||||||
}
|
}
|
||||||
ExtensionId::Easytier => {
|
ExtensionId::Easytier => {
|
||||||
config.easytier.enabled && !config.easytier.network_name.is_empty()
|
config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok()
|
||||||
}
|
|
||||||
ExtensionId::Frpc => {
|
|
||||||
config.frpc.enabled
|
|
||||||
&& match config.frpc.config_mode {
|
|
||||||
FrpcConfigMode::Quick => {
|
|
||||||
!config.frpc.proxy_name.trim().is_empty()
|
|
||||||
&& !config.frpc.server_addr.trim().is_empty()
|
|
||||||
&& !config.frpc.token.is_empty()
|
|
||||||
}
|
|
||||||
FrpcConfigMode::Full => !config.frpc.custom_toml.trim().is_empty(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ExtensionId::Frpc => config.frpc.enabled && validate_frpc_config(&config.frpc).is_ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +191,7 @@ impl ExtensionManager {
|
|||||||
ExtensionProcess {
|
ExtensionProcess {
|
||||||
child,
|
child,
|
||||||
logs,
|
logs,
|
||||||
_temp_dir: launch.temp_dir,
|
_config_file: launch.config_file,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
drop(processes);
|
drop(processes);
|
||||||
@@ -286,12 +274,7 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
ExtensionId::Gostc => {
|
ExtensionId::Gostc => {
|
||||||
let c = &config.gostc;
|
let c = &config.gostc;
|
||||||
if c.addr.trim().is_empty() {
|
validate_gostc_config(c)?;
|
||||||
return Err("GOSTC server address is required".into());
|
|
||||||
}
|
|
||||||
if c.key.is_empty() {
|
|
||||||
return Err("GOSTC client key is required".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
|
|
||||||
@@ -307,25 +290,59 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ExtensionId::Easytier => {
|
ExtensionId::Easytier => {
|
||||||
let c = &config.easytier;
|
return Self::build_easytier_launch(&config.easytier).await;
|
||||||
if c.network_name.is_empty() {
|
|
||||||
return Err("EasyTier network name is required".into());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ExtensionId::Frpc => {
|
||||||
|
return Self::build_frpc_launch(&config.frpc).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ExtensionLaunch {
|
||||||
|
args,
|
||||||
|
config_file: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_easytier_launch(config: &EasytierConfig) -> Result<ExtensionLaunch, String> {
|
||||||
|
validate_easytier_config(config)?;
|
||||||
|
|
||||||
|
match config.config_mode {
|
||||||
|
EasytierConfigMode::Quick => Ok(ExtensionLaunch {
|
||||||
|
args: Self::build_easytier_quick_args(config),
|
||||||
|
config_file: None,
|
||||||
|
}),
|
||||||
|
EasytierConfigMode::Full => {
|
||||||
|
let config_file = ProtectedConfigFile::create(
|
||||||
|
"EasyTier",
|
||||||
|
"easytier.toml",
|
||||||
|
config.custom_toml.as_str(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ExtensionLaunch {
|
||||||
|
args: vec!["-c".to_string(), Self::path_to_arg(config_file.path())],
|
||||||
|
config_file: Some(config_file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_easytier_quick_args(config: &EasytierConfig) -> Vec<String> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"--network-name".to_string(),
|
"--network-name".to_string(),
|
||||||
c.network_name.clone(),
|
config.network_name.clone(),
|
||||||
"--network-secret".to_string(),
|
"--network-secret".to_string(),
|
||||||
c.network_secret.clone(),
|
config.network_secret.clone(),
|
||||||
];
|
];
|
||||||
|
|
||||||
for peer in &c.peer_urls {
|
for peer in &config.peer_urls {
|
||||||
if !peer.is_empty() {
|
if !peer.is_empty() {
|
||||||
args.extend(["--peers".to_string(), peer.clone()]);
|
args.extend(["--peers".to_string(), peer.clone()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref ip) = c.virtual_ip {
|
if let Some(ref ip) = config.virtual_ip {
|
||||||
if !ip.is_empty() {
|
if !ip.is_empty() {
|
||||||
args.extend(["-i".to_string(), ip.clone()]);
|
args.extend(["-i".to_string(), ip.clone()]);
|
||||||
} else {
|
} else {
|
||||||
@@ -338,65 +355,23 @@ impl ExtensionManager {
|
|||||||
args
|
args
|
||||||
}
|
}
|
||||||
|
|
||||||
ExtensionId::Frpc => {
|
|
||||||
return Self::build_frpc_launch(&config.frpc).await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ExtensionLaunch {
|
|
||||||
args,
|
|
||||||
temp_dir: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn build_frpc_launch(config: &FrpcConfig) -> Result<ExtensionLaunch, String> {
|
async fn build_frpc_launch(config: &FrpcConfig) -> Result<ExtensionLaunch, String> {
|
||||||
|
validate_frpc_config(config)?;
|
||||||
|
|
||||||
let config_text = match config.config_mode {
|
let config_text = match config.config_mode {
|
||||||
FrpcConfigMode::Quick => Self::build_frpc_quick_toml(config)?,
|
FrpcConfigMode::Quick => Self::build_frpc_quick_toml(config)?,
|
||||||
FrpcConfigMode::Full => Self::validate_frpc_full_toml(config)?.to_string(),
|
FrpcConfigMode::Full => config.custom_toml.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let temp_dir =
|
let config_file =
|
||||||
tempfile::tempdir().map_err(|e| format!("Failed to create FRPC config dir: {}", e))?;
|
ProtectedConfigFile::create("FRPC", "frpc.toml", config_text.as_str()).await?;
|
||||||
let config_path = temp_dir.path().join("frpc.toml");
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o700))
|
|
||||||
.map_err(|e| format!("Failed to protect FRPC config dir: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::fs::write(&config_path, config_text)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to write FRPC config: {}", e))?;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
tokio::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to protect FRPC config: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ExtensionLaunch {
|
Ok(ExtensionLaunch {
|
||||||
args: vec!["-c".to_string(), Self::path_to_arg(&config_path)],
|
args: vec!["-c".to_string(), Self::path_to_arg(config_file.path())],
|
||||||
temp_dir: Some(temp_dir),
|
config_file: Some(config_file),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_frpc_full_toml(config: &FrpcConfig) -> Result<&str, String> {
|
|
||||||
let trimmed = config.custom_toml.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Err("FRPC full configuration is required".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
trimmed
|
|
||||||
.parse::<DocumentMut>()
|
|
||||||
.map_err(|e| format!("FRPC full configuration is not valid TOML: {}", e))?;
|
|
||||||
|
|
||||||
Ok(config.custom_toml.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_frpc_quick_toml(config: &FrpcConfig) -> Result<String, String> {
|
fn build_frpc_quick_toml(config: &FrpcConfig) -> Result<String, String> {
|
||||||
if config.proxy_name.trim().is_empty() {
|
if config.proxy_name.trim().is_empty() {
|
||||||
return Err("FRPC proxy name is required".into());
|
return Err("FRPC proxy name is required".into());
|
||||||
@@ -480,7 +455,7 @@ impl ExtensionManager {
|
|||||||
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
|
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn path_to_arg(path: &PathBuf) -> String {
|
fn path_to_arg(path: &Path) -> String {
|
||||||
path.to_string_lossy().to_string()
|
path.to_string_lossy().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,3 +578,107 @@ impl ExtensionManager {
|
|||||||
futures::future::join_all(stop_futures).await;
|
futures::future::join_all(stop_futures).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn easytier_launch_revalidates_full_configuration() {
|
||||||
|
let config = EasytierConfig {
|
||||||
|
config_mode: EasytierConfigMode::Full,
|
||||||
|
custom_toml: "instance_name = [".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = ExtensionManager::build_easytier_launch(&config)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.expect("invalid full configuration should fail launch validation");
|
||||||
|
assert!(error.starts_with("EasyTier full configuration is not valid TOML:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn easytier_quick_mode_keeps_command_line_arguments() {
|
||||||
|
let config = EasytierConfig {
|
||||||
|
network_name: "one-kvm".to_string(),
|
||||||
|
network_secret: "secret".to_string(),
|
||||||
|
peer_urls: vec![
|
||||||
|
"tcp://peer-one:11010".to_string(),
|
||||||
|
String::new(),
|
||||||
|
"udp://peer-two:11010".to_string(),
|
||||||
|
],
|
||||||
|
virtual_ip: Some("10.20.30.40/24".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ExtensionManager::build_easytier_quick_args(&config),
|
||||||
|
vec![
|
||||||
|
"--network-name",
|
||||||
|
"one-kvm",
|
||||||
|
"--network-secret",
|
||||||
|
"secret",
|
||||||
|
"--peers",
|
||||||
|
"tcp://peer-one:11010",
|
||||||
|
"--peers",
|
||||||
|
"udp://peer-two:11010",
|
||||||
|
"-i",
|
||||||
|
"10.20.30.40/24",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn easytier_full_mode_uses_protected_temporary_config() {
|
||||||
|
let config_text = "instance_name = \"one-kvm\"\n";
|
||||||
|
let config = EasytierConfig {
|
||||||
|
config_mode: EasytierConfigMode::Full,
|
||||||
|
network_name: "ignored-quick-network".to_string(),
|
||||||
|
custom_toml: config_text.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let launch = ExtensionManager::build_easytier_launch(&config)
|
||||||
|
.await
|
||||||
|
.expect("full EasyTier launch should build");
|
||||||
|
assert_eq!(launch.args[0], "-c");
|
||||||
|
|
||||||
|
let config_path = std::path::PathBuf::from(&launch.args[1]);
|
||||||
|
assert_eq!(
|
||||||
|
config_path.file_name().and_then(|name| name.to_str()),
|
||||||
|
Some("easytier.toml")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read_to_string(&config_path).await.unwrap(),
|
||||||
|
config_text
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(launch);
|
||||||
|
assert!(!config_path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn easytier_auto_start_uses_fields_for_selected_mode() {
|
||||||
|
let mut config = ExtensionsConfig::default();
|
||||||
|
config.easytier.enabled = true;
|
||||||
|
config.easytier.network_name = "quick-network".to_string();
|
||||||
|
assert!(ExtensionManager::is_enabled_for_config(
|
||||||
|
ExtensionId::Easytier,
|
||||||
|
&config
|
||||||
|
));
|
||||||
|
|
||||||
|
config.easytier.config_mode = EasytierConfigMode::Full;
|
||||||
|
assert!(!ExtensionManager::is_enabled_for_config(
|
||||||
|
ExtensionId::Easytier,
|
||||||
|
&config
|
||||||
|
));
|
||||||
|
|
||||||
|
config.easytier.network_name.clear();
|
||||||
|
config.easytier.custom_toml = "instance_name = \"one-kvm\"".to_string();
|
||||||
|
assert!(ExtensionManager::is_enabled_for_config(
|
||||||
|
ExtensionId::Easytier,
|
||||||
|
&config
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
mod manager;
|
mod manager;
|
||||||
|
mod protected_config;
|
||||||
mod software;
|
mod software;
|
||||||
mod types;
|
mod types;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
pub use manager::ExtensionManager;
|
pub use manager::ExtensionManager;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -8,3 +10,7 @@ pub use manager::TTYD_SOCKET_PATH;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub use manager::TTYD_TCP_ADDR;
|
pub use manager::TTYD_TCP_ADDR;
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
pub(crate) use validation::{
|
||||||
|
validate_easytier_config, validate_extension_config, validate_frpc_config,
|
||||||
|
validate_gostc_config,
|
||||||
|
};
|
||||||
|
|||||||
95
src/extensions/protected_config.rs
Normal file
95
src/extensions/protected_config.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
pub(crate) struct ProtectedConfigFile {
|
||||||
|
_temp_dir: TempDir,
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtectedConfigFile {
|
||||||
|
pub(crate) async fn create(
|
||||||
|
extension_name: &str,
|
||||||
|
file_name: &str,
|
||||||
|
contents: &str,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let temp_dir = tempfile::tempdir().map_err(|error| {
|
||||||
|
format!("Failed to create {} config dir: {}", extension_name, error)
|
||||||
|
})?;
|
||||||
|
let path = temp_dir.path().join(file_name);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o700))
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("Failed to protect {} config dir: {}", extension_name, error)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::fs::write(&path, contents)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Failed to write {} config: {}", extension_name, error))?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("Failed to protect {} config: {}", extension_name, error)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
_temp_dir: temp_dir,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn protects_and_cleans_up_config_file() {
|
||||||
|
let config =
|
||||||
|
ProtectedConfigFile::create("Test extension", "extension.toml", "enabled = true\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let path = config.path().to_path_buf();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read_to_string(&path).await.unwrap(),
|
||||||
|
"enabled = true\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(path.parent().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777,
|
||||||
|
0o700
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(config);
|
||||||
|
assert!(!path.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,11 +103,25 @@ impl Default for GostcConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum EasytierConfigMode {
|
||||||
|
Quick,
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EasytierConfigMode {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Quick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[derive(Default)]
|
|
||||||
pub struct EasytierConfig {
|
pub struct EasytierConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub config_mode: EasytierConfigMode,
|
||||||
pub network_name: String,
|
pub network_name: String,
|
||||||
#[serde(skip_serializing_if = "String::is_empty")]
|
#[serde(skip_serializing_if = "String::is_empty")]
|
||||||
pub network_secret: String,
|
pub network_secret: String,
|
||||||
@@ -115,6 +129,8 @@ pub struct EasytierConfig {
|
|||||||
pub peer_urls: Vec<String>,
|
pub peer_urls: Vec<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub virtual_ip: Option<String>,
|
pub virtual_ip: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "String::is_empty")]
|
||||||
|
pub custom_toml: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
@@ -260,3 +276,26 @@ pub struct ExtensionLogs {
|
|||||||
pub id: ExtensionId,
|
pub id: ExtensionId,
|
||||||
pub logs: Vec<String>,
|
pub logs: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{EasytierConfig, EasytierConfigMode};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_easytier_config_defaults_to_quick_mode() {
|
||||||
|
let config: EasytierConfig = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"enabled": true,
|
||||||
|
"network_name": "legacy-network",
|
||||||
|
"network_secret": "secret",
|
||||||
|
"peer_urls": ["tcp://example.com:11010"],
|
||||||
|
"virtual_ip": "10.10.10.2/24"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("legacy EasyTier config should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(config.config_mode, EasytierConfigMode::Quick);
|
||||||
|
assert!(config.custom_toml.is_empty());
|
||||||
|
assert_eq!(config.network_name, "legacy-network");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
119
src/extensions/validation.rs
Normal file
119
src/extensions/validation.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use toml_edit::DocumentMut;
|
||||||
|
|
||||||
|
use super::types::{
|
||||||
|
EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType, FrpcConfig,
|
||||||
|
FrpcConfigMode, GostcConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn validate_extension_config(
|
||||||
|
id: ExtensionId,
|
||||||
|
config: &ExtensionsConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match id {
|
||||||
|
ExtensionId::Ttyd => Ok(()),
|
||||||
|
ExtensionId::Gostc => validate_gostc_config(&config.gostc),
|
||||||
|
ExtensionId::Easytier => validate_easytier_config(&config.easytier),
|
||||||
|
ExtensionId::Frpc => validate_frpc_config(&config.frpc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_gostc_config(config: &GostcConfig) -> Result<(), String> {
|
||||||
|
require_non_empty(config.addr.trim(), "GOSTC server address is required")?;
|
||||||
|
require_non_empty(config.key.as_str(), "GOSTC client key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_easytier_config(config: &EasytierConfig) -> Result<(), String> {
|
||||||
|
match config.config_mode {
|
||||||
|
EasytierConfigMode::Quick => require_non_empty(
|
||||||
|
config.network_name.trim(),
|
||||||
|
"EasyTier network name is required",
|
||||||
|
),
|
||||||
|
EasytierConfigMode::Full => validate_full_toml("EasyTier", config.custom_toml.as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_frpc_config(config: &FrpcConfig) -> Result<(), String> {
|
||||||
|
match config.config_mode {
|
||||||
|
FrpcConfigMode::Quick => {
|
||||||
|
require_non_empty(config.proxy_name.trim(), "FRPC proxy name is required")?;
|
||||||
|
require_non_empty(config.server_addr.trim(), "FRPC server address is required")?;
|
||||||
|
require_non_empty(config.token.as_str(), "FRPC token is required")?;
|
||||||
|
require_non_empty(config.local_ip.trim(), "FRPC local IP is required")?;
|
||||||
|
|
||||||
|
if matches!(config.proxy_type, FrpProxyType::Tcp | FrpProxyType::Udp)
|
||||||
|
&& config.remote_port.is_none()
|
||||||
|
{
|
||||||
|
return Err("FRPC remote port is required for TCP/UDP proxies".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
FrpcConfigMode::Full => validate_full_toml("FRPC", config.custom_toml.as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_non_empty(value: &str, message: &str) -> Result<(), String> {
|
||||||
|
if value.is_empty() {
|
||||||
|
Err(message.to_string())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_full_toml(extension_name: &str, config: &str) -> Result<(), String> {
|
||||||
|
let trimmed = config.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(format!("{} full configuration is required", extension_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed.parse::<DocumentMut>().map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"{} full configuration is not valid TOML: {}",
|
||||||
|
extension_name, error
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_easytier_full_configuration() {
|
||||||
|
let mut config = EasytierConfig {
|
||||||
|
config_mode: EasytierConfigMode::Full,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_easytier_config(&config).unwrap_err(),
|
||||||
|
"EasyTier full configuration is required"
|
||||||
|
);
|
||||||
|
|
||||||
|
config.custom_toml = "instance_name = [".to_string();
|
||||||
|
assert!(validate_easytier_config(&config)
|
||||||
|
.unwrap_err()
|
||||||
|
.starts_with("EasyTier full configuration is not valid TOML:"));
|
||||||
|
|
||||||
|
config.custom_toml = "instance_name = \"one-kvm\"".to_string();
|
||||||
|
assert!(validate_easytier_config(&config).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_frpc_through_the_same_entry_point() {
|
||||||
|
let mut config = FrpcConfig {
|
||||||
|
config_mode: FrpcConfigMode::Full,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validate_frpc_config(&config).unwrap_err(),
|
||||||
|
"FRPC full configuration is required"
|
||||||
|
);
|
||||||
|
|
||||||
|
config.custom_toml = "serverAddr = \"frps.example.com\"".to_string();
|
||||||
|
assert!(validate_frpc_config(&config).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,9 +167,9 @@ impl OtgBackend {
|
|||||||
if now.duration_since(*last_log).as_secs() >= 1 {
|
if now.duration_since(*last_log).as_secs() >= 1 {
|
||||||
let count = self.error_count.swap(0, Ordering::Relaxed);
|
let count = self.error_count.swap(0, Ordering::Relaxed);
|
||||||
if count > 1 {
|
if count > 1 {
|
||||||
warn!("{} (repeated {} times)", msg, count);
|
debug!("{} (repeated {} times)", msg, count);
|
||||||
} else {
|
} else {
|
||||||
warn!("{}", msg);
|
debug!("{}", msg);
|
||||||
}
|
}
|
||||||
*last_log = now;
|
*last_log = now;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
45
src/main.rs
45
src/main.rs
@@ -308,7 +308,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
if let Err(e) = otg_service
|
if let Err(e) = otg_service
|
||||||
.apply_config(&config.hid, &config.msd, &config.otg_network)
|
.apply_config(&config.hid, &config.msd, &config.otg_network, &config.uac)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("Failed to apply OTG config: {}", e);
|
tracing::warn!("Failed to apply OTG config: {}", e);
|
||||||
@@ -426,25 +426,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.update_video_config(actual_resolution, actual_format, actual_fps)
|
.update_video_config(actual_resolution, actual_format, actual_fps)
|
||||||
.await;
|
.await;
|
||||||
if let Some(device_path) = device_path {
|
if let Some(device_path) = device_path {
|
||||||
let (subdev_path, bridge_kind, v4l2_driver) = streamer
|
let device_info = streamer.current_device().await;
|
||||||
.current_device()
|
|
||||||
.await
|
|
||||||
.map(|d| {
|
|
||||||
(
|
|
||||||
d.subdev_path.clone(),
|
|
||||||
d.bridge_kind.clone(),
|
|
||||||
Some(d.driver.clone()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or((None, None, None));
|
|
||||||
webrtc_streamer
|
webrtc_streamer
|
||||||
.set_capture_device(
|
.set_capture_device(device_path, jpeg_quality, device_info)
|
||||||
device_path,
|
|
||||||
jpeg_quality,
|
|
||||||
subdev_path,
|
|
||||||
bridge_kind,
|
|
||||||
v4l2_driver,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
tracing::debug!("WebRTC streamer configured for direct capture");
|
tracing::debug!("WebRTC streamer configured for direct capture");
|
||||||
} else {
|
} else {
|
||||||
@@ -550,7 +534,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
|
let update_service = Arc::new(UpdateService::new());
|
||||||
let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone());
|
let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone());
|
||||||
|
|
||||||
let state = AppState::new(
|
let state = AppState::new(
|
||||||
@@ -579,6 +563,27 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
data_dir.clone(),
|
data_dir.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
// Initialize UAC playback writer if UAC is enabled.
|
||||||
|
if config.uac.enabled {
|
||||||
|
let uac_cfg = one_kvm::audio::uac::UacPlaybackConfig {
|
||||||
|
sample_rate: config.uac.sample_rate,
|
||||||
|
channels: config.uac.channels as u16,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match one_kvm::audio::uac::UacPlayback::start(uac_cfg) {
|
||||||
|
Ok(writer) => {
|
||||||
|
*state.uac_playback.write().await = Some(writer);
|
||||||
|
tracing::info!("UAC playback writer started");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to start UAC playback writer: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.watchdog.enabled {
|
if config.watchdog.enabled {
|
||||||
if let Err(error) = state.watchdog.enable().await {
|
if let Err(error) = state.watchdog.enable().await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use super::types::{
|
|||||||
DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia,
|
DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia,
|
||||||
MountedMediaKind, MsdState,
|
MountedMediaKind, MsdState,
|
||||||
};
|
};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, MsdErrorCode, Result};
|
||||||
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
||||||
|
|
||||||
pub struct MsdController {
|
pub struct MsdController {
|
||||||
@@ -70,9 +70,11 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
info!("Fetching MSD function from OtgService");
|
info!("Fetching MSD function from OtgService");
|
||||||
let msd_func = self.otg_service.msd_function().await.ok_or_else(|| {
|
let msd_func = self
|
||||||
AppError::Internal("MSD function is not active in OtgService".to_string())
|
.otg_service
|
||||||
})?;
|
.msd_function()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
|
||||||
|
|
||||||
*self.msd_function.write().await = Some(msd_func);
|
*self.msd_function.write().await = Some(msd_func);
|
||||||
|
|
||||||
@@ -148,7 +150,7 @@ impl MsdController {
|
|||||||
read_only: bool,
|
read_only: bool,
|
||||||
requested_lun: Option<u8>,
|
requested_lun: Option<u8>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let _op_guard = self.operation_lock.write().await;
|
let _op_guard = self.try_operation()?;
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
let previous_state = state.clone();
|
let previous_state = state.clone();
|
||||||
|
|
||||||
@@ -159,7 +161,7 @@ impl MsdController {
|
|||||||
self.monitor
|
self.monitor
|
||||||
.report_error(&error_msg, "image_not_found")
|
.report_error(&error_msg, "image_not_found")
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Internal(error_msg));
|
return Err(MsdErrorCode::MsdResourceNotFound.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if state
|
if state
|
||||||
@@ -167,7 +169,7 @@ impl MsdController {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|media| media.kind == MountedMediaKind::Image && media.id == image.id)
|
.any(|media| media.kind == MountedMediaKind::Image && media.id == image.id)
|
||||||
{
|
{
|
||||||
return Err(AppError::BadRequest("Image is already mounted".to_string()));
|
return Err(MsdErrorCode::MsdMediaAlreadyMounted.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let lun = Self::select_lun(&state, requested_lun)?;
|
let lun = Self::select_lun(&state, requested_lun)?;
|
||||||
@@ -195,19 +197,17 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mount_drive(&self) -> Result<()> {
|
pub async fn mount_drive(&self) -> Result<()> {
|
||||||
let _op_guard = self.operation_lock.write().await;
|
let _op_guard = self.try_operation()?;
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
let previous_state = state.clone();
|
let previous_state = state.clone();
|
||||||
|
|
||||||
self.assert_available(&state).await?;
|
self.assert_available(&state).await?;
|
||||||
|
|
||||||
if !self.drive_path.exists() {
|
if !self.drive_path.exists() {
|
||||||
let err =
|
|
||||||
AppError::Internal("Virtual drive not initialized. Call init first.".to_string());
|
|
||||||
self.monitor
|
self.monitor
|
||||||
.report_error("Virtual drive not initialized", "drive_not_found")
|
.report_error("Virtual drive not initialized", "drive_not_found")
|
||||||
.await;
|
.await;
|
||||||
return Err(err);
|
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let drive_info = state.drive_info.clone().or_else(|| {
|
let drive_info = state.drive_info.clone().or_else(|| {
|
||||||
@@ -230,15 +230,13 @@ impl MsdController {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|media| media.kind == MountedMediaKind::Drive)
|
.any(|media| media.kind == MountedMediaKind::Drive)
|
||||||
{
|
{
|
||||||
return Err(AppError::BadRequest(
|
return Err(MsdErrorCode::MsdMediaAlreadyMounted.into());
|
||||||
"Virtual drive is already mounted".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let drive_info = drive_info
|
let drive_info =
|
||||||
.ok_or_else(|| AppError::Internal("Virtual drive info is unavailable".to_string()))?;
|
drive_info.ok_or_else(|| AppError::from(MsdErrorCode::MsdDriveNotInitialized))?;
|
||||||
let lun = Self::lowest_free_lun(&state)
|
let lun = Self::lowest_free_lun(&state)
|
||||||
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()))?;
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull))?;
|
||||||
|
|
||||||
let media = MountedMedia::drive(lun, &drive_info);
|
let media = MountedMedia::drive(lun, &drive_info);
|
||||||
if let Err(e) = self.configure_media(&media).await {
|
if let Err(e) = self.configure_media(&media).await {
|
||||||
@@ -265,7 +263,7 @@ impl MsdController {
|
|||||||
self.monitor
|
self.monitor
|
||||||
.report_error("MSD not available", "not_available")
|
.report_error("MSD not available", "not_available")
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Internal("MSD not available".to_string()));
|
return Err(MsdErrorCode::MsdUnavailable.into());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -286,20 +284,14 @@ impl MsdController {
|
|||||||
fn select_lun(state: &MsdState, requested_lun: Option<u8>) -> Result<u8> {
|
fn select_lun(state: &MsdState, requested_lun: Option<u8>) -> Result<u8> {
|
||||||
let Some(lun) = requested_lun else {
|
let Some(lun) = requested_lun else {
|
||||||
return Self::lowest_free_lun(state)
|
return Self::lowest_free_lun(state)
|
||||||
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()));
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull));
|
||||||
};
|
};
|
||||||
|
|
||||||
if lun >= state.disk_mode.capacity() {
|
if lun >= state.disk_mode.capacity() {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(MsdErrorCode::MsdInvalidRequest.into());
|
||||||
"Media slot {} is outside the current disk mode capacity",
|
|
||||||
lun + 1
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
if state.mounted_media.iter().any(|media| media.lun == lun) {
|
if state.mounted_media.iter().any(|media| media.lun == lun) {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(MsdErrorCode::MsdMediaSlotsFull.into());
|
||||||
"Media slot {} is already occupied",
|
|
||||||
lun + 1
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
Ok(lun)
|
Ok(lun)
|
||||||
}
|
}
|
||||||
@@ -310,7 +302,7 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_disk_mode(&self, disk_mode: DiskMode) -> Result<bool> {
|
pub async fn set_disk_mode(&self, disk_mode: DiskMode) -> Result<bool> {
|
||||||
let _op_guard = self.operation_lock.write().await;
|
let _op_guard = self.try_operation()?;
|
||||||
let previous_state = {
|
let previous_state = {
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
self.assert_available(&state).await?;
|
self.assert_available(&state).await?;
|
||||||
@@ -327,9 +319,10 @@ impl MsdController {
|
|||||||
self.otg_service
|
self.otg_service
|
||||||
.set_msd_lun_capacity(disk_mode.capacity())
|
.set_msd_lun_capacity(disk_mode.capacity())
|
||||||
.await?;
|
.await?;
|
||||||
self.otg_service.msd_function().await.ok_or_else(|| {
|
self.otg_service
|
||||||
AppError::Internal("MSD function missing after OTG rebuild".to_string())
|
.msd_function()
|
||||||
})
|
.await
|
||||||
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdOperationFailed))
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -349,7 +342,7 @@ impl MsdController {
|
|||||||
.report_error(&error_msg, "disk_mode_rollback_failed")
|
.report_error(&error_msg, "disk_mode_rollback_failed")
|
||||||
.await;
|
.await;
|
||||||
self.mark_device_info_dirty().await;
|
self.mark_device_info_dirty().await;
|
||||||
return Err(AppError::Internal(error_msg));
|
return Err(MsdErrorCode::MsdOperationFailed.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
@@ -360,7 +353,7 @@ impl MsdController {
|
|||||||
.report_error(&error_msg, "disk_mode_switch_failed")
|
.report_error(&error_msg, "disk_mode_switch_failed")
|
||||||
.await;
|
.await;
|
||||||
self.mark_device_info_dirty().await;
|
self.mark_device_info_dirty().await;
|
||||||
return Err(AppError::Internal(error_msg));
|
return Err(MsdErrorCode::MsdOperationFailed.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
*self.msd_function.write().await = Some(msd_function);
|
*self.msd_function.write().await = Some(msd_function);
|
||||||
@@ -397,7 +390,7 @@ impl MsdController {
|
|||||||
where
|
where
|
||||||
F: Fn(&MountedMedia) -> bool,
|
F: Fn(&MountedMedia) -> bool,
|
||||||
{
|
{
|
||||||
let _op_guard = self.operation_lock.write().await;
|
let _op_guard = self.try_operation()?;
|
||||||
|
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
let Some(index) = state.mounted_media.iter().position(predicate) else {
|
let Some(index) = state.mounted_media.iter().position(predicate) else {
|
||||||
@@ -419,25 +412,22 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn configure_media(&self, media: &MountedMedia) -> Result<()> {
|
async fn configure_media(&self, media: &MountedMedia) -> Result<()> {
|
||||||
let gadget_path = self.active_gadget_path().await?;
|
if let Err(e) = self
|
||||||
let msd_hold = self.msd_function.read().await;
|
.otg_service
|
||||||
let Some(ref msd) = *msd_hold else {
|
.configure_msd_lun(media.lun, &Self::media_config(media))
|
||||||
self.monitor
|
|
||||||
.report_error("MSD function not initialized", "not_initialized")
|
|
||||||
.await;
|
|
||||||
return Err(AppError::Internal(
|
|
||||||
"MSD function not initialized".to_string(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
if let Err(e) = msd
|
|
||||||
.configure_lun_async(&gadget_path, media.lun, &Self::media_config(media))
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let error_msg = format!("Failed to configure LUN {}: {}", media.lun, e);
|
let error_msg = format!("Failed to configure LUN {}: {}", media.lun, e);
|
||||||
self.monitor
|
self.monitor
|
||||||
.report_error(&error_msg, "configfs_error")
|
.report_error(&error_msg, "configfs_error")
|
||||||
.await;
|
.await;
|
||||||
return Err(e);
|
return Err(match e {
|
||||||
|
AppError::Msd(error) => AppError::Msd(error),
|
||||||
|
error => {
|
||||||
|
warn!(%error, "Unclassified MSD media configuration failure");
|
||||||
|
MsdErrorCode::MsdOperationFailed.into()
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -447,7 +437,7 @@ impl MsdController {
|
|||||||
let msd_hold = self.msd_function.read().await;
|
let msd_hold = self.msd_function.read().await;
|
||||||
let msd = msd_hold
|
let msd = msd_hold
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::Internal("MSD function not initialized".to_string()))?;
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
|
||||||
msd.disconnect_lun_async(&gadget_path, lun).await
|
msd.disconnect_lun_async(&gadget_path, lun).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,9 +445,11 @@ impl MsdController {
|
|||||||
self.otg_service
|
self.otg_service
|
||||||
.set_msd_lun_capacity(previous_state.disk_mode.capacity())
|
.set_msd_lun_capacity(previous_state.disk_mode.capacity())
|
||||||
.await?;
|
.await?;
|
||||||
let msd_function = self.otg_service.msd_function().await.ok_or_else(|| {
|
let msd_function = self
|
||||||
AppError::Internal("MSD function missing after OTG rollback".to_string())
|
.otg_service
|
||||||
})?;
|
.msd_function()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdOperationFailed))?;
|
||||||
*self.msd_function.write().await = Some(msd_function);
|
*self.msd_function.write().await = Some(msd_function);
|
||||||
for media in &previous_state.mounted_media {
|
for media in &previous_state.mounted_media {
|
||||||
self.configure_media(media).await?;
|
self.configure_media(media).await?;
|
||||||
@@ -473,7 +465,7 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn disconnect(&self) -> Result<()> {
|
pub async fn disconnect(&self) -> Result<()> {
|
||||||
let _op_guard = self.operation_lock.write().await;
|
let _op_guard = self.try_operation()?;
|
||||||
|
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
if state.mounted_media.is_empty() {
|
if state.mounted_media.is_empty() {
|
||||||
@@ -488,10 +480,13 @@ impl MsdController {
|
|||||||
for prior in &disconnected {
|
for prior in &disconnected {
|
||||||
if let Err(restore_error) = self.configure_media(prior).await {
|
if let Err(restore_error) = self.configure_media(prior).await {
|
||||||
state.available = false;
|
state.available = false;
|
||||||
return Err(AppError::Internal(format!(
|
warn!(
|
||||||
"Failed to disconnect LUN {}: {error}; restore failed: {restore_error}",
|
lun = media.lun,
|
||||||
media.lun
|
disconnect_error = %error,
|
||||||
)));
|
%restore_error,
|
||||||
|
"Failed to restore MSD media after disconnect failure"
|
||||||
|
);
|
||||||
|
return Err(MsdErrorCode::MsdDisconnectFailed.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Err(error);
|
return Err(error);
|
||||||
@@ -520,16 +515,14 @@ impl MsdController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_image(&self, image_id: &str) -> Result<()> {
|
pub async fn delete_image(&self, image_id: &str) -> Result<()> {
|
||||||
let _op_guard = self.operation_lock.write().await;
|
let _op_guard = self.try_operation()?;
|
||||||
let state = self.state.read().await;
|
let state = self.state.read().await;
|
||||||
if state
|
if state
|
||||||
.mounted_media
|
.mounted_media
|
||||||
.iter()
|
.iter()
|
||||||
.any(|media| media.kind == MountedMediaKind::Image && media.id == image_id)
|
.any(|media| media.kind == MountedMediaKind::Image && media.id == image_id)
|
||||||
{
|
{
|
||||||
return Err(AppError::BadRequest(
|
return Err(MsdErrorCode::MsdMediaInUse.into());
|
||||||
"Cannot delete image while it is mounted".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageManager::new(self.images_path.clone()).delete(image_id)
|
ImageManager::new(self.images_path.clone()).delete(image_id)
|
||||||
@@ -540,6 +533,12 @@ impl MsdController {
|
|||||||
url: String,
|
url: String,
|
||||||
filename: Option<String>,
|
filename: Option<String>,
|
||||||
) -> Result<DownloadProgress> {
|
) -> Result<DownloadProgress> {
|
||||||
|
let parsed_url =
|
||||||
|
reqwest::Url::parse(&url).map_err(|_| AppError::from(MsdErrorCode::MsdInvalidUrl))?;
|
||||||
|
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||||
|
return Err(MsdErrorCode::MsdInvalidUrl.into());
|
||||||
|
}
|
||||||
|
|
||||||
let download_id = uuid::Uuid::new_v4().to_string();
|
let download_id = uuid::Uuid::new_v4().to_string();
|
||||||
let cancel_token = CancellationToken::new();
|
let cancel_token = CancellationToken::new();
|
||||||
|
|
||||||
@@ -560,7 +559,7 @@ impl MsdController {
|
|||||||
total_bytes: None,
|
total_bytes: None,
|
||||||
progress_pct: None,
|
progress_pct: None,
|
||||||
status: DownloadStatus::Started,
|
status: DownloadStatus::Started,
|
||||||
error: None,
|
error_code: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.publish_event(crate::events::SystemEvent::MsdDownloadProgress {
|
self.publish_event(crate::events::SystemEvent::MsdDownloadProgress {
|
||||||
@@ -571,6 +570,7 @@ impl MsdController {
|
|||||||
total_bytes: None,
|
total_bytes: None,
|
||||||
progress_pct: None,
|
progress_pct: None,
|
||||||
status: "started".to_string(),
|
status: "started".to_string(),
|
||||||
|
error_code: None,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -600,6 +600,7 @@ impl MsdController {
|
|||||||
total_bytes: total,
|
total_bytes: total,
|
||||||
progress_pct,
|
progress_pct,
|
||||||
status: "in_progress".to_string(),
|
status: "in_progress".to_string(),
|
||||||
|
error_code: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -624,11 +625,16 @@ impl MsdController {
|
|||||||
total_bytes: Some(image_info.size),
|
total_bytes: Some(image_info.size),
|
||||||
progress_pct: Some(100.0),
|
progress_pct: Some(100.0),
|
||||||
status: "completed".to_string(),
|
status: "completed".to_string(),
|
||||||
|
error_code: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Download failed: {}", e);
|
warn!(error = %e, "MSD image download failed");
|
||||||
|
let code = match e {
|
||||||
|
AppError::Msd(error) => error.code(),
|
||||||
|
_ => MsdErrorCode::MsdOperationFailed,
|
||||||
|
};
|
||||||
if let Some(ref bus) = events {
|
if let Some(ref bus) = events {
|
||||||
bus.publish(crate::events::SystemEvent::MsdDownloadProgress {
|
bus.publish(crate::events::SystemEvent::MsdDownloadProgress {
|
||||||
download_id: download_id_clone,
|
download_id: download_id_clone,
|
||||||
@@ -637,7 +643,8 @@ impl MsdController {
|
|||||||
bytes_downloaded: 0,
|
bytes_downloaded: 0,
|
||||||
total_bytes: None,
|
total_bytes: None,
|
||||||
progress_pct: None,
|
progress_pct: None,
|
||||||
status: format!("failed: {}", e),
|
status: "failed".to_string(),
|
||||||
|
error_code: Some(code.as_str().to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,10 +662,7 @@ impl MsdController {
|
|||||||
info!("Download cancelled: {}", download_id);
|
info!("Download cancelled: {}", download_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::NotFound(format!(
|
Err(MsdErrorCode::MsdResourceNotFound.into())
|
||||||
"Download not found: {}",
|
|
||||||
download_id
|
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,7 +670,13 @@ impl MsdController {
|
|||||||
self.otg_service
|
self.otg_service
|
||||||
.gadget_path()
|
.gadget_path()
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| AppError::Internal("OTG gadget path is not available".to_string()))
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_operation(&self) -> Result<tokio::sync::RwLockWriteGuard<'_, ()>> {
|
||||||
|
self.operation_lock
|
||||||
|
.try_write()
|
||||||
|
.map_err(|_| MsdErrorCode::MsdOperationInProgress.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn shutdown(&self) -> Result<()> {
|
pub async fn shutdown(&self) -> Result<()> {
|
||||||
@@ -712,6 +722,18 @@ mod tests {
|
|||||||
assert!(controller.drive_path.ends_with("ventoy.img"));
|
assert!(controller.drive_path.ends_with("ventoy.img"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_operations_have_a_stable_error_code() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let controller = MsdController::new(Arc::new(OtgService::new()), temp_dir.path());
|
||||||
|
let _guard = controller.operation_lock.write().await;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
controller.try_operation().unwrap_err(),
|
||||||
|
AppError::Msd(error) if error.code() == MsdErrorCode::MsdOperationInProgress
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_state_default() {
|
async fn test_state_default() {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
@@ -783,14 +805,14 @@ mod tests {
|
|||||||
.push(MountedMedia::image(3, &image, false, true));
|
.push(MountedMedia::image(3, &image, false, true));
|
||||||
|
|
||||||
assert_eq!(MsdController::select_lun(&state, Some(5)).unwrap(), 5);
|
assert_eq!(MsdController::select_lun(&state, Some(5)).unwrap(), 5);
|
||||||
assert!(MsdController::select_lun(&state, Some(3))
|
assert!(matches!(
|
||||||
.unwrap_err()
|
MsdController::select_lun(&state, Some(3)).unwrap_err(),
|
||||||
.to_string()
|
AppError::Msd(error) if error.code() == MsdErrorCode::MsdMediaSlotsFull
|
||||||
.contains("already occupied"));
|
));
|
||||||
assert!(MsdController::select_lun(&state, Some(8))
|
assert!(matches!(
|
||||||
.unwrap_err()
|
MsdController::select_lun(&state, Some(8)).unwrap_err(),
|
||||||
.to_string()
|
AppError::Msd(error) if error.code() == MsdErrorCode::MsdInvalidRequest
|
||||||
.contains("outside"));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
170
src/msd/image.rs
170
src/msd/image.rs
@@ -6,10 +6,10 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tracing::info;
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use super::types::ImageInfo;
|
use super::types::ImageInfo;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, MsdErrorCode, Result};
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE: u64 = 32 * 1024 * 1024 * 1024;
|
const MAX_IMAGE_SIZE: u64 = 32 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ impl ImageManager {
|
|||||||
|
|
||||||
pub fn ensure_dir(&self) -> Result<()> {
|
pub fn ensure_dir(&self) -> Result<()> {
|
||||||
fs::create_dir_all(&self.images_path)
|
fs::create_dir_all(&self.images_path)
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to create images directory: {}", e)))?;
|
.map_err(|error| storage_io_error("create images directory", error))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +38,9 @@ impl ImageManager {
|
|||||||
let mut images = Vec::new();
|
let mut images = Vec::new();
|
||||||
|
|
||||||
for entry in fs::read_dir(&self.images_path)
|
for entry in fs::read_dir(&self.images_path)
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to read images directory: {}", e)))?
|
.map_err(|error| storage_io_error("read images directory", error))?
|
||||||
{
|
{
|
||||||
let entry = entry.map_err(|e| {
|
let entry = entry.map_err(|error| storage_io_error("read image entry", error))?;
|
||||||
AppError::Internal(format!("Failed to read directory entry: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_file() {
|
if path.is_file() {
|
||||||
@@ -88,13 +86,13 @@ impl ImageManager {
|
|||||||
return Ok(image);
|
return Ok(image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(AppError::NotFound(format!("Image not found: {}", id)))
|
Err(MsdErrorCode::MsdResourceNotFound.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_by_name(&self, name: &str) -> Result<ImageInfo> {
|
pub fn get_by_name(&self, name: &str) -> Result<ImageInfo> {
|
||||||
let path = self.images_path.join(name);
|
let path = self.images_path.join(name);
|
||||||
self.get_image_info(&path)
|
self.get_image_info(&path)
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Image not found: {}", name)))
|
.ok_or_else(|| MsdErrorCode::MsdResourceNotFound.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -103,30 +101,24 @@ impl ImageManager {
|
|||||||
|
|
||||||
let name = sanitize_filename(name);
|
let name = sanitize_filename(name);
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err(AppError::Internal("Invalid filename".to_string()));
|
return Err(MsdErrorCode::MsdInvalidRequest.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if data.len() as u64 > MAX_IMAGE_SIZE {
|
if data.len() as u64 > MAX_IMAGE_SIZE {
|
||||||
return Err(AppError::Internal(format!(
|
return Err(MsdErrorCode::MsdImageTooLarge.into());
|
||||||
"Image too large. Maximum size: {} GB",
|
|
||||||
MAX_IMAGE_SIZE / 1024 / 1024 / 1024
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = self.images_path.join(&name);
|
let path = self.images_path.join(&name);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
return Err(AppError::Internal(format!(
|
return Err(MsdErrorCode::MsdResourceAlreadyExists.into());
|
||||||
"Image already exists: {}",
|
|
||||||
name
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut file = fs::File::create(&path)
|
let mut file =
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to create image file: {}", e)))?;
|
fs::File::create(&path).map_err(|error| storage_io_error("create image", error))?;
|
||||||
|
|
||||||
file.write_all(data).map_err(|e| {
|
file.write_all(data).map_err(|error| {
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
AppError::Internal(format!("Failed to write image data: {}", e))
|
storage_io_error("write image", error)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
info!("Created image: {} ({} bytes)", name, data.len());
|
info!("Created image: {} ({} bytes)", name, data.len());
|
||||||
@@ -143,7 +135,7 @@ impl ImageManager {
|
|||||||
|
|
||||||
let name = sanitize_filename(name);
|
let name = sanitize_filename(name);
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err(AppError::Internal("Invalid filename".to_string()));
|
return Err(MsdErrorCode::MsdInvalidRequest.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let temp_name = format!(".upload_{}", uuid::Uuid::new_v4());
|
let temp_name = format!(".upload_{}", uuid::Uuid::new_v4());
|
||||||
@@ -151,48 +143,41 @@ impl ImageManager {
|
|||||||
let final_path = self.images_path.join(&name);
|
let final_path = self.images_path.join(&name);
|
||||||
|
|
||||||
if final_path.exists() {
|
if final_path.exists() {
|
||||||
return Err(AppError::Internal(format!(
|
return Err(MsdErrorCode::MsdResourceAlreadyExists.into());
|
||||||
"Image already exists: {}",
|
|
||||||
name
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut file = tokio::fs::File::create(&temp_path)
|
let mut file = tokio::fs::File::create(&temp_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?;
|
.map_err(|error| storage_io_error("create image upload", error))?;
|
||||||
|
|
||||||
let mut bytes_written: u64 = 0;
|
let mut bytes_written: u64 = 0;
|
||||||
|
|
||||||
while let Some(chunk) = field
|
while let Some(chunk) = field.chunk().await.map_err(|error| {
|
||||||
.chunk()
|
warn!(%error, "Failed to read MSD image upload chunk");
|
||||||
.await
|
AppError::from(MsdErrorCode::MsdOperationFailed)
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to read upload chunk: {}", e)))?
|
})? {
|
||||||
{
|
|
||||||
bytes_written += chunk.len() as u64;
|
bytes_written += chunk.len() as u64;
|
||||||
if bytes_written > MAX_IMAGE_SIZE {
|
if bytes_written > MAX_IMAGE_SIZE {
|
||||||
drop(file);
|
drop(file);
|
||||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
return Err(AppError::Internal(format!(
|
return Err(MsdErrorCode::MsdImageTooLarge.into());
|
||||||
"Image too large. Maximum size: {} GB",
|
|
||||||
MAX_IMAGE_SIZE / 1024 / 1024 / 1024
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
file.write_all(&chunk)
|
file.write_all(&chunk)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to write chunk: {}", e)))?;
|
.map_err(|error| storage_io_error("write image upload", error))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
file.flush()
|
file.flush()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to flush file: {}", e)))?;
|
.map_err(|error| storage_io_error("flush image upload", error))?;
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|
||||||
tokio::fs::rename(&temp_path, &final_path)
|
tokio::fs::rename(&temp_path, &final_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|error| {
|
||||||
let _ = std::fs::remove_file(&temp_path);
|
let _ = std::fs::remove_file(&temp_path);
|
||||||
AppError::Internal(format!("Failed to rename temp file: {}", e))
|
storage_io_error("commit image upload", error)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -206,8 +191,7 @@ impl ImageManager {
|
|||||||
pub fn delete(&self, id: &str) -> Result<()> {
|
pub fn delete(&self, id: &str) -> Result<()> {
|
||||||
let image = self.get(id)?;
|
let image = self.get(id)?;
|
||||||
|
|
||||||
fs::remove_file(&image.path)
|
fs::remove_file(&image.path).map_err(|error| storage_io_error("delete image", error))?;
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to delete image: {}", e)))?;
|
|
||||||
|
|
||||||
info!("Deleted image: {}", image.name);
|
info!("Deleted image: {}", image.name);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -224,8 +208,11 @@ impl ImageManager {
|
|||||||
{
|
{
|
||||||
self.ensure_dir()?;
|
self.ensure_dir()?;
|
||||||
|
|
||||||
let parsed_url = reqwest::Url::parse(url)
|
let parsed_url =
|
||||||
.map_err(|e| AppError::BadRequest(format!("Invalid URL: {}", e)))?;
|
reqwest::Url::parse(url).map_err(|_| AppError::from(MsdErrorCode::MsdInvalidUrl))?;
|
||||||
|
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||||
|
return Err(MsdErrorCode::MsdInvalidUrl.into());
|
||||||
|
}
|
||||||
|
|
||||||
info!("Starting download from: {}", url);
|
info!("Starting download from: {}", url);
|
||||||
|
|
||||||
@@ -233,19 +220,17 @@ impl ImageManager {
|
|||||||
.timeout(std::time::Duration::from_secs(3600))
|
.timeout(std::time::Duration::from_secs(3600))
|
||||||
.connect_timeout(std::time::Duration::from_secs(30))
|
.connect_timeout(std::time::Duration::from_secs(30))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to create HTTP client: {}", e)))?;
|
.map_err(|error| remote_download_error("create HTTP client", error))?;
|
||||||
|
|
||||||
let head_response = client
|
let head_response = client
|
||||||
.head(url)
|
.head(url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to connect: {}", e)))?;
|
.map_err(|error| remote_download_error("send HEAD request", error))?;
|
||||||
|
|
||||||
if !head_response.status().is_success() {
|
if !head_response.status().is_success() {
|
||||||
return Err(AppError::Internal(format!(
|
warn!(status = %head_response.status(), "MSD image HEAD request failed");
|
||||||
"Server returned error: {}",
|
return Err(MsdErrorCode::MsdRemoteDownloadFailed.into());
|
||||||
head_response.status()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_size = head_response
|
let total_size = head_response
|
||||||
@@ -256,11 +241,7 @@ impl ImageManager {
|
|||||||
|
|
||||||
if let Some(size) = total_size {
|
if let Some(size) = total_size {
|
||||||
if size > MAX_IMAGE_SIZE {
|
if size > MAX_IMAGE_SIZE {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(MsdErrorCode::MsdImageTooLarge.into());
|
||||||
"File too large: {} bytes (max {} GB)",
|
|
||||||
size,
|
|
||||||
MAX_IMAGE_SIZE / 1024 / 1024 / 1024
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,17 +265,12 @@ impl ImageManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if final_filename.is_empty() {
|
if final_filename.is_empty() {
|
||||||
return Err(AppError::BadRequest(
|
return Err(MsdErrorCode::MsdInvalidRequest.into());
|
||||||
"Could not determine filename".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let final_path = self.images_path.join(&final_filename);
|
let final_path = self.images_path.join(&final_filename);
|
||||||
if final_path.exists() {
|
if final_path.exists() {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(MsdErrorCode::MsdResourceAlreadyExists.into());
|
||||||
"Image already exists: {}",
|
|
||||||
final_filename
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let temp_filename = format!(".download_{}", uuid::Uuid::new_v4());
|
let temp_filename = format!(".download_{}", uuid::Uuid::new_v4());
|
||||||
@@ -304,13 +280,11 @@ impl ImageManager {
|
|||||||
.get(url)
|
.get(url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Download failed: {}", e)))?;
|
.map_err(|error| remote_download_error("send GET request", error))?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(AppError::Internal(format!(
|
warn!(status = %response.status(), "MSD image GET request failed");
|
||||||
"Download failed: HTTP {}",
|
return Err(MsdErrorCode::MsdRemoteDownloadFailed.into());
|
||||||
response.status()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let content_length = response
|
let content_length = response
|
||||||
@@ -322,7 +296,7 @@ impl ImageManager {
|
|||||||
|
|
||||||
let mut file = tokio::fs::File::create(&temp_path)
|
let mut file = tokio::fs::File::create(&temp_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?;
|
.map_err(|error| storage_io_error("create image download", error))?;
|
||||||
|
|
||||||
let mut stream = response.bytes_stream();
|
let mut stream = response.bytes_stream();
|
||||||
let mut downloaded: u64 = 0;
|
let mut downloaded: u64 = 0;
|
||||||
@@ -334,11 +308,11 @@ impl ImageManager {
|
|||||||
|
|
||||||
while let Some(chunk_result) = stream.next().await {
|
while let Some(chunk_result) = stream.next().await {
|
||||||
let chunk =
|
let chunk =
|
||||||
chunk_result.map_err(|e| AppError::Internal(format!("Download error: {}", e)))?;
|
chunk_result.map_err(|error| remote_download_error("read response body", error))?;
|
||||||
|
|
||||||
file.write_all(&chunk).await.map_err(|e| {
|
file.write_all(&chunk).await.map_err(|error| {
|
||||||
let _ = std::fs::remove_file(&temp_path);
|
let _ = std::fs::remove_file(&temp_path);
|
||||||
AppError::Internal(format!("Failed to write data: {}", e))
|
storage_io_error("write image download", error)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
downloaded += chunk.len() as u64;
|
downloaded += chunk.len() as u64;
|
||||||
@@ -360,29 +334,29 @@ impl ImageManager {
|
|||||||
|
|
||||||
file.flush()
|
file.flush()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to flush file: {}", e)))?;
|
.map_err(|error| storage_io_error("flush image download", error))?;
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|
||||||
let metadata = tokio::fs::metadata(&temp_path)
|
let metadata = tokio::fs::metadata(&temp_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to read file metadata: {}", e)))?;
|
.map_err(|error| storage_io_error("read downloaded image metadata", error))?;
|
||||||
|
|
||||||
if let Some(expected) = content_length {
|
if let Some(expected) = content_length {
|
||||||
if metadata.len() != expected {
|
if metadata.len() != expected {
|
||||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
return Err(AppError::Internal(format!(
|
warn!(
|
||||||
"Download incomplete: got {} bytes, expected {}",
|
actual = metadata.len(),
|
||||||
metadata.len(),
|
expected, "MSD image download was incomplete"
|
||||||
expected
|
);
|
||||||
)));
|
return Err(MsdErrorCode::MsdDownloadIncomplete.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::fs::rename(&temp_path, &final_path)
|
tokio::fs::rename(&temp_path, &final_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|error| {
|
||||||
let _ = std::fs::remove_file(&temp_path);
|
let _ = std::fs::remove_file(&temp_path);
|
||||||
AppError::Internal(format!("Failed to move file: {}", e))
|
storage_io_error("commit image download", error)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -395,6 +369,26 @@ impl ImageManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn storage_io_error(operation: &'static str, error: std::io::Error) -> AppError {
|
||||||
|
warn!(operation, %error, "MSD storage operation failed");
|
||||||
|
#[cfg(unix)]
|
||||||
|
let code = match error.raw_os_error() {
|
||||||
|
Some(libc::EFBIG) => MsdErrorCode::MsdImageTooLarge,
|
||||||
|
Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull,
|
||||||
|
Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly,
|
||||||
|
Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied,
|
||||||
|
_ => MsdErrorCode::MsdOperationFailed,
|
||||||
|
};
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let code = MsdErrorCode::MsdOperationFailed;
|
||||||
|
code.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_download_error(operation: &'static str, error: reqwest::Error) -> AppError {
|
||||||
|
warn!(operation, %error, "MSD remote download failed");
|
||||||
|
MsdErrorCode::MsdRemoteDownloadFailed.into()
|
||||||
|
}
|
||||||
|
|
||||||
fn stable_image_id_from_filename(name: &str) -> String {
|
fn stable_image_id_from_filename(name: &str) -> String {
|
||||||
let mut hash: u64 = 0;
|
let mut hash: u64 = 0;
|
||||||
for (i, byte) in name.bytes().enumerate() {
|
for (i, byte) in name.bytes().enumerate() {
|
||||||
@@ -490,4 +484,18 @@ mod tests {
|
|||||||
|
|
||||||
assert!(manager.list().unwrap().is_empty());
|
assert!(manager.list().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_storage_io_errors() {
|
||||||
|
for (errno, expected) in [
|
||||||
|
(libc::EFBIG, MsdErrorCode::MsdImageTooLarge),
|
||||||
|
(libc::ENOSPC, MsdErrorCode::MsdStorageFull),
|
||||||
|
(libc::EROFS, MsdErrorCode::MsdStorageReadOnly),
|
||||||
|
(libc::EACCES, MsdErrorCode::MsdStoragePermissionDenied),
|
||||||
|
(libc::EPERM, MsdErrorCode::MsdStoragePermissionDenied),
|
||||||
|
] {
|
||||||
|
let error = storage_io_error("test", std::io::Error::from_raw_os_error(errno));
|
||||||
|
assert!(matches!(error, AppError::Msd(error) if error.code() == expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ pub use types::{
|
|||||||
};
|
};
|
||||||
pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB};
|
pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB};
|
||||||
|
|
||||||
|
pub use crate::error::{MsdError, MsdErrorCode};
|
||||||
pub use crate::otg::{MsdFunction, MsdLunConfig};
|
pub use crate::otg::{MsdFunction, MsdLunConfig};
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ pub struct DownloadProgress {
|
|||||||
pub total_bytes: Option<u64>,
|
pub total_bytes: Option<u64>,
|
||||||
pub progress_pct: Option<f32>,
|
pub progress_pct: Option<f32>,
|
||||||
pub status: DownloadStatus,
|
pub status: DownloadStatus,
|
||||||
pub error: Option<String>,
|
pub error_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::info;
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use ventoy_img::{FileInfo as VentoyFileInfo, VentoyError, VentoyImage};
|
use ventoy_img::{FileInfo as VentoyFileInfo, VentoyError, VentoyImage};
|
||||||
|
|
||||||
use super::types::{DriveFile, DriveInfo};
|
use super::types::{DriveFile, DriveInfo};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, MsdErrorCode, Result};
|
||||||
|
|
||||||
const STREAM_CHUNK_SIZE: usize = 64 * 1024;
|
const STREAM_CHUNK_SIZE: usize = 64 * 1024;
|
||||||
|
|
||||||
@@ -44,10 +44,7 @@ impl VentoyDrive {
|
|||||||
|
|
||||||
pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> {
|
pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> {
|
||||||
if size_mb < MIN_DRIVE_SIZE_MB {
|
if size_mb < MIN_DRIVE_SIZE_MB {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(MsdErrorCode::MsdDriveSizeInvalid.into());
|
||||||
"Drive size must be at least {} MB",
|
|
||||||
MIN_DRIVE_SIZE_MB
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
let size_str = format!("{}M", size_mb);
|
let size_str = format!("{}M", size_mb);
|
||||||
let path = self.path.clone();
|
let path = self.path.clone();
|
||||||
@@ -59,7 +56,7 @@ impl VentoyDrive {
|
|||||||
VentoyImage::create(&path, &size_str, DEFAULT_LABEL).map_err(drive_init_error)?;
|
VentoyImage::create(&path, &size_str, DEFAULT_LABEL).map_err(drive_init_error)?;
|
||||||
|
|
||||||
let metadata = std::fs::metadata(&path)
|
let metadata = std::fs::metadata(&path)
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?;
|
.map_err(|error| drive_io_error("read initialized drive metadata", error))?;
|
||||||
|
|
||||||
Ok::<DriveInfo, AppError>(DriveInfo {
|
Ok::<DriveInfo, AppError>(DriveInfo {
|
||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
@@ -70,7 +67,7 @@ impl VentoyDrive {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))??;
|
.map_err(|error| task_error("initialize virtual drive", error))??;
|
||||||
|
|
||||||
info!("Ventoy drive created successfully");
|
info!("Ventoy drive created successfully");
|
||||||
Ok(info)
|
Ok(info)
|
||||||
@@ -78,7 +75,7 @@ impl VentoyDrive {
|
|||||||
|
|
||||||
pub async fn info(&self) -> Result<DriveInfo> {
|
pub async fn info(&self) -> Result<DriveInfo> {
|
||||||
if !self.exists() {
|
if !self.exists() {
|
||||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = self.path.clone();
|
let path = self.path.clone();
|
||||||
@@ -86,7 +83,7 @@ impl VentoyDrive {
|
|||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let metadata = std::fs::metadata(&path)
|
let metadata = std::fs::metadata(&path)
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?;
|
.map_err(|error| drive_io_error("read drive metadata", error))?;
|
||||||
|
|
||||||
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
||||||
|
|
||||||
@@ -110,12 +107,12 @@ impl VentoyDrive {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
.map_err(|error| task_error("read virtual drive info", error))?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_files(&self, dir_path: &str) -> Result<Vec<DriveFile>> {
|
pub async fn list_files(&self, dir_path: &str) -> Result<Vec<DriveFile>> {
|
||||||
if !self.exists() {
|
if !self.exists() {
|
||||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = self.path.clone();
|
let path = self.path.clone();
|
||||||
@@ -138,7 +135,7 @@ impl VentoyDrive {
|
|||||||
.collect())
|
.collect())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
.map_err(|error| task_error("list virtual drive files", error))?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn write_file_from_multipart_field(
|
pub async fn write_file_from_multipart_field(
|
||||||
@@ -147,7 +144,7 @@ impl VentoyDrive {
|
|||||||
mut field: axum::extract::multipart::Field<'_>,
|
mut field: axum::extract::multipart::Field<'_>,
|
||||||
) -> Result<u64> {
|
) -> Result<u64> {
|
||||||
if !self.exists() {
|
if !self.exists() {
|
||||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let temp_dir = self.path.parent().unwrap_or(Path::new("/tmp"));
|
let temp_dir = self.path.parent().unwrap_or(Path::new("/tmp"));
|
||||||
@@ -156,24 +153,23 @@ impl VentoyDrive {
|
|||||||
|
|
||||||
let mut temp_file = tokio::fs::File::create(&temp_path)
|
let mut temp_file = tokio::fs::File::create(&temp_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?;
|
.map_err(|error| drive_io_error("create virtual drive upload", error))?;
|
||||||
|
|
||||||
let mut bytes_written: u64 = 0;
|
let mut bytes_written: u64 = 0;
|
||||||
|
|
||||||
while let Some(chunk) = field
|
while let Some(chunk) = field.chunk().await.map_err(|error| {
|
||||||
.chunk()
|
warn!(%error, "Failed to read virtual drive upload chunk");
|
||||||
.await
|
AppError::from(MsdErrorCode::MsdOperationFailed)
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to read upload chunk: {}", e)))?
|
})? {
|
||||||
{
|
|
||||||
bytes_written += chunk.len() as u64;
|
bytes_written += chunk.len() as u64;
|
||||||
tokio::io::AsyncWriteExt::write_all(&mut temp_file, &chunk)
|
tokio::io::AsyncWriteExt::write_all(&mut temp_file, &chunk)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to write chunk: {}", e)))?;
|
.map_err(|error| drive_io_error("write virtual drive upload", error))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::io::AsyncWriteExt::flush(&mut temp_file)
|
tokio::io::AsyncWriteExt::flush(&mut temp_file)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to flush temp file: {}", e)))?;
|
.map_err(|error| drive_io_error("flush virtual drive upload", error))?;
|
||||||
drop(temp_file);
|
drop(temp_file);
|
||||||
|
|
||||||
let path = self.path.clone();
|
let path = self.path.clone();
|
||||||
@@ -191,7 +187,7 @@ impl VentoyDrive {
|
|||||||
Ok::<(), AppError>(())
|
Ok::<(), AppError>(())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?;
|
.map_err(|error| task_error("write virtual drive file", error))?;
|
||||||
|
|
||||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
|
|
||||||
@@ -202,7 +198,7 @@ impl VentoyDrive {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub async fn read_file(&self, file_path: &str) -> Result<Vec<u8>> {
|
pub async fn read_file(&self, file_path: &str) -> Result<Vec<u8>> {
|
||||||
if !self.exists() {
|
if !self.exists() {
|
||||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = self.path.clone();
|
let path = self.path.clone();
|
||||||
@@ -215,12 +211,12 @@ impl VentoyDrive {
|
|||||||
image.read_file(&file_path).map_err(ventoy_to_app_error)
|
image.read_file(&file_path).map_err(ventoy_to_app_error)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
.map_err(|error| task_error("read virtual drive file", error))?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_file_info(&self, file_path: &str) -> Result<Option<DriveFile>> {
|
pub async fn get_file_info(&self, file_path: &str) -> Result<Option<DriveFile>> {
|
||||||
if !self.exists() {
|
if !self.exists() {
|
||||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = self.path.clone();
|
let path = self.path.clone();
|
||||||
@@ -234,7 +230,7 @@ impl VentoyDrive {
|
|||||||
.map_err(ventoy_to_app_error)
|
.map_err(ventoy_to_app_error)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))??;
|
.map_err(|error| task_error("read virtual drive file information", error))??;
|
||||||
|
|
||||||
Ok(info.map(|f| DriveFile {
|
Ok(info.map(|f| DriveFile {
|
||||||
name: f.name,
|
name: f.name,
|
||||||
@@ -253,19 +249,16 @@ impl VentoyDrive {
|
|||||||
tokio::sync::mpsc::Receiver<std::result::Result<bytes::Bytes, std::io::Error>>,
|
tokio::sync::mpsc::Receiver<std::result::Result<bytes::Bytes, std::io::Error>>,
|
||||||
)> {
|
)> {
|
||||||
if !self.exists() {
|
if !self.exists() {
|
||||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_info = self
|
let file_info = self
|
||||||
.get_file_info(file_path)
|
.get_file_info(file_path)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("File not found: {}", file_path)))?;
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdResourceNotFound))?;
|
||||||
|
|
||||||
if file_info.is_dir {
|
if file_info.is_dir {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(MsdErrorCode::MsdInvalidRequest.into());
|
||||||
"'{}' is a directory",
|
|
||||||
file_path
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_size = file_info.size;
|
let file_size = file_info.size;
|
||||||
@@ -300,7 +293,7 @@ impl VentoyDrive {
|
|||||||
|
|
||||||
pub async fn mkdir(&self, dir_path: &str) -> Result<()> {
|
pub async fn mkdir(&self, dir_path: &str) -> Result<()> {
|
||||||
if !self.exists() {
|
if !self.exists() {
|
||||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = self.path.clone();
|
let path = self.path.clone();
|
||||||
@@ -315,12 +308,12 @@ impl VentoyDrive {
|
|||||||
.map_err(ventoy_to_app_error)
|
.map_err(ventoy_to_app_error)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
.map_err(|error| task_error("create virtual drive directory", error))?
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, path_to_delete: &str) -> Result<()> {
|
pub async fn delete(&self, path_to_delete: &str) -> Result<()> {
|
||||||
if !self.exists() {
|
if !self.exists() {
|
||||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = self.path.clone();
|
let path = self.path.clone();
|
||||||
@@ -335,22 +328,23 @@ impl VentoyDrive {
|
|||||||
.map_err(ventoy_to_app_error)
|
.map_err(ventoy_to_app_error)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
.map_err(|error| task_error("delete virtual drive resource", error))?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ventoy_to_app_error(err: VentoyError) -> AppError {
|
fn ventoy_to_app_error(err: VentoyError) -> AppError {
|
||||||
|
warn!(%err, "Virtual drive filesystem operation failed");
|
||||||
match err {
|
match err {
|
||||||
VentoyError::Io(e) => AppError::Io(e),
|
VentoyError::Io(error) => drive_io_error("access virtual drive", error),
|
||||||
VentoyError::InvalidSize(s) => AppError::BadRequest(format!("Invalid size: {}", s)),
|
VentoyError::InvalidSize(_) | VentoyError::SizeParseError(_) => {
|
||||||
VentoyError::SizeParseError(s) => AppError::BadRequest(format!("Size parse error: {}", s)),
|
MsdErrorCode::MsdDriveSizeInvalid.into()
|
||||||
VentoyError::FilesystemError(s) => AppError::Internal(format!("Filesystem error: {}", s)),
|
}
|
||||||
VentoyError::ImageError(s) => AppError::Internal(format!("Image error: {}", s)),
|
VentoyError::FilesystemError(_)
|
||||||
VentoyError::FileNotFound(s) => AppError::NotFound(format!("File not found: {}", s)),
|
| VentoyError::ImageError(_)
|
||||||
VentoyError::ResourceNotFound(s) => {
|
| VentoyError::PartitionError(_) => MsdErrorCode::MsdDriveFilesystemUnsupported.into(),
|
||||||
AppError::Internal(format!("Resource not found: {}", s))
|
VentoyError::FileNotFound(_) | VentoyError::ResourceNotFound(_) => {
|
||||||
|
MsdErrorCode::MsdResourceNotFound.into()
|
||||||
}
|
}
|
||||||
VentoyError::PartitionError(s) => AppError::Internal(format!("Partition error: {}", s)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,21 +355,35 @@ fn drive_init_error(err: VentoyError) -> AppError {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
match error.raw_os_error() {
|
match error.raw_os_error() {
|
||||||
Some(libc::EFBIG) => AppError::BadRequest(
|
Some(libc::EFBIG) => MsdErrorCode::MsdDriveSizeInvalid.into(),
|
||||||
"MSD directory filesystem does not support a virtual drive file of this size".into(),
|
Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull.into(),
|
||||||
),
|
Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly.into(),
|
||||||
Some(libc::ENOSPC) => AppError::BadRequest(
|
Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied.into(),
|
||||||
"MSD directory does not have enough free space for the virtual drive".into(),
|
_ => drive_io_error("initialize virtual drive", error),
|
||||||
),
|
|
||||||
Some(libc::EROFS) => AppError::BadRequest("MSD directory filesystem is read-only".into()),
|
|
||||||
Some(libc::EACCES | libc::EPERM) => AppError::BadRequest(
|
|
||||||
"One-KVM does not have permission to write to the MSD directory".into(),
|
|
||||||
),
|
|
||||||
_ => AppError::Io(error),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
AppError::Io(error)
|
drive_io_error("initialize virtual drive", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_io_error(operation: &'static str, error: std::io::Error) -> AppError {
|
||||||
|
warn!(operation, %error, "Virtual drive storage operation failed");
|
||||||
|
#[cfg(unix)]
|
||||||
|
let code = match error.raw_os_error() {
|
||||||
|
Some(libc::EFBIG) => MsdErrorCode::MsdImageTooLarge,
|
||||||
|
Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull,
|
||||||
|
Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly,
|
||||||
|
Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied,
|
||||||
|
_ => MsdErrorCode::MsdOperationFailed,
|
||||||
|
};
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let code = MsdErrorCode::MsdOperationFailed;
|
||||||
|
code.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn task_error(operation: &'static str, error: tokio::task::JoinError) -> AppError {
|
||||||
|
warn!(operation, %error, "Virtual drive task failed");
|
||||||
|
MsdErrorCode::MsdOperationFailed.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ventoy_file_to_drive_file(info: VentoyFileInfo, parent_path: &str) -> DriveFile {
|
fn ventoy_file_to_drive_file(info: VentoyFileInfo, parent_path: &str) -> DriveFile {
|
||||||
@@ -470,16 +478,35 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn classifies_drive_creation_io_errors() {
|
fn classifies_drive_creation_io_errors() {
|
||||||
for (errno, expected) in [
|
for (errno, expected) in [
|
||||||
(libc::EFBIG, "does not support"),
|
(libc::EFBIG, MsdErrorCode::MsdDriveSizeInvalid),
|
||||||
(libc::ENOSPC, "enough free space"),
|
(libc::ENOSPC, MsdErrorCode::MsdStorageFull),
|
||||||
(libc::EROFS, "read-only"),
|
(libc::EROFS, MsdErrorCode::MsdStorageReadOnly),
|
||||||
(libc::EACCES, "permission"),
|
(libc::EACCES, MsdErrorCode::MsdStoragePermissionDenied),
|
||||||
|
(libc::EPERM, MsdErrorCode::MsdStoragePermissionDenied),
|
||||||
] {
|
] {
|
||||||
let error = drive_init_error(VentoyError::Io(std::io::Error::from_raw_os_error(errno)));
|
let error = drive_init_error(VentoyError::Io(std::io::Error::from_raw_os_error(errno)));
|
||||||
assert!(matches!(error, AppError::BadRequest(message) if message.contains(expected)));
|
assert!(matches!(error, AppError::Msd(error) if error.code() == expected));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_ventoy_filesystem_and_resource_errors() {
|
||||||
|
for error in [
|
||||||
|
VentoyError::FilesystemError("details".into()),
|
||||||
|
VentoyError::ImageError("details".into()),
|
||||||
|
VentoyError::PartitionError("details".into()),
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
ventoy_to_app_error(error),
|
||||||
|
AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveFilesystemUnsupported
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
ventoy_to_app_error(VentoyError::FileNotFound("details".into())),
|
||||||
|
AppError::Msd(error) if error.code() == MsdErrorCode::MsdResourceNotFound
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
fn init_ventoy_resources() -> bool {
|
fn init_ventoy_resources() -> bool {
|
||||||
static INIT: OnceLock<bool> = OnceLock::new();
|
static INIT: OnceLock<bool> = OnceLock::new();
|
||||||
*INIT.get_or_init(|| {
|
*INIT.get_or_init(|| {
|
||||||
|
|||||||
@@ -67,8 +67,14 @@ struct BridgeJournal {
|
|||||||
existing_bridge: bool,
|
existing_bridge: bool,
|
||||||
original_connection_uuid: Option<String>,
|
original_connection_uuid: Option<String>,
|
||||||
bridge_profile_uuid: Option<String>,
|
bridge_profile_uuid: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
bridge_profile_name: Option<String>,
|
||||||
uplink_profile_uuid: Option<String>,
|
uplink_profile_uuid: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
uplink_profile_name: Option<String>,
|
||||||
usb_profile_uuid: String,
|
usb_profile_uuid: String,
|
||||||
|
#[serde(default)]
|
||||||
|
usb_profile_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -87,11 +93,11 @@ impl TransactionProfiles {
|
|||||||
let suffix = &transaction[..12];
|
let suffix = &transaction[..12];
|
||||||
Self {
|
Self {
|
||||||
bridge_name: format!("{PROFILE_PREFIX}-bridge-{suffix}"),
|
bridge_name: format!("{PROFILE_PREFIX}-bridge-{suffix}"),
|
||||||
bridge_uuid: Uuid::new_v4().to_string(),
|
bridge_uuid: String::new(),
|
||||||
uplink_name: format!("{PROFILE_PREFIX}-uplink-{suffix}"),
|
uplink_name: format!("{PROFILE_PREFIX}-uplink-{suffix}"),
|
||||||
uplink_uuid: Uuid::new_v4().to_string(),
|
uplink_uuid: String::new(),
|
||||||
usb_name: format!("{PROFILE_PREFIX}-usb-{suffix}"),
|
usb_name: format!("{PROFILE_PREFIX}-usb-{suffix}"),
|
||||||
usb_uuid: Uuid::new_v4().to_string(),
|
usb_uuid: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,15 +157,18 @@ impl NetworkBridgeRuntime {
|
|||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let profiles = TransactionProfiles::new();
|
let mut profiles = TransactionProfiles::new();
|
||||||
let journal = BridgeJournal {
|
let mut journal = BridgeJournal {
|
||||||
version: JOURNAL_VERSION,
|
version: JOURNAL_VERSION,
|
||||||
uplink: uplink.to_string(),
|
uplink: uplink.to_string(),
|
||||||
existing_bridge: false,
|
existing_bridge: false,
|
||||||
original_connection_uuid: Some(original_connection_uuid.clone()),
|
original_connection_uuid: Some(original_connection_uuid.clone()),
|
||||||
bridge_profile_uuid: Some(profiles.bridge_uuid.clone()),
|
bridge_profile_uuid: None,
|
||||||
uplink_profile_uuid: Some(profiles.uplink_uuid.clone()),
|
bridge_profile_name: Some(profiles.bridge_name.clone()),
|
||||||
usb_profile_uuid: profiles.usb_uuid.clone(),
|
uplink_profile_uuid: None,
|
||||||
|
uplink_profile_name: Some(profiles.uplink_name.clone()),
|
||||||
|
usb_profile_uuid: String::new(),
|
||||||
|
usb_profile_name: Some(profiles.usb_name.clone()),
|
||||||
};
|
};
|
||||||
write_journal(&journal)?;
|
write_journal(&journal)?;
|
||||||
|
|
||||||
@@ -174,9 +183,10 @@ impl NetworkBridgeRuntime {
|
|||||||
BRIDGE_IF,
|
BRIDGE_IF,
|
||||||
"con-name",
|
"con-name",
|
||||||
&profiles.bridge_name,
|
&profiles.bridge_name,
|
||||||
"connection.uuid",
|
|
||||||
&profiles.bridge_uuid,
|
|
||||||
])?;
|
])?;
|
||||||
|
profiles.bridge_uuid = connection_value(&profiles.bridge_name, "connection.uuid")?;
|
||||||
|
journal.bridge_profile_uuid = Some(profiles.bridge_uuid.clone());
|
||||||
|
write_journal(&journal)?;
|
||||||
run_nmcli(&[
|
run_nmcli(&[
|
||||||
"connection",
|
"connection",
|
||||||
"modify",
|
"modify",
|
||||||
@@ -220,8 +230,6 @@ impl NetworkBridgeRuntime {
|
|||||||
uplink,
|
uplink,
|
||||||
"con-name",
|
"con-name",
|
||||||
&profiles.uplink_name,
|
&profiles.uplink_name,
|
||||||
"connection.uuid",
|
|
||||||
&profiles.uplink_uuid,
|
|
||||||
"master",
|
"master",
|
||||||
BRIDGE_IF,
|
BRIDGE_IF,
|
||||||
"slave-type",
|
"slave-type",
|
||||||
@@ -229,6 +237,9 @@ impl NetworkBridgeRuntime {
|
|||||||
"connection.autoconnect",
|
"connection.autoconnect",
|
||||||
"no",
|
"no",
|
||||||
])?;
|
])?;
|
||||||
|
profiles.uplink_uuid = connection_value(&profiles.uplink_name, "connection.uuid")?;
|
||||||
|
journal.uplink_profile_uuid = Some(profiles.uplink_uuid.clone());
|
||||||
|
write_journal(&journal)?;
|
||||||
run_nmcli(&[
|
run_nmcli(&[
|
||||||
"connection",
|
"connection",
|
||||||
"add",
|
"add",
|
||||||
@@ -238,8 +249,6 @@ impl NetworkBridgeRuntime {
|
|||||||
usb_interface,
|
usb_interface,
|
||||||
"con-name",
|
"con-name",
|
||||||
&profiles.usb_name,
|
&profiles.usb_name,
|
||||||
"connection.uuid",
|
|
||||||
&profiles.usb_uuid,
|
|
||||||
"master",
|
"master",
|
||||||
BRIDGE_IF,
|
BRIDGE_IF,
|
||||||
"slave-type",
|
"slave-type",
|
||||||
@@ -247,6 +256,9 @@ impl NetworkBridgeRuntime {
|
|||||||
"connection.autoconnect",
|
"connection.autoconnect",
|
||||||
"no",
|
"no",
|
||||||
])?;
|
])?;
|
||||||
|
profiles.usb_uuid = connection_value(&profiles.usb_name, "connection.uuid")?;
|
||||||
|
journal.usb_profile_uuid = profiles.usb_uuid.clone();
|
||||||
|
write_journal(&journal)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
if let Err(error) = prepare_result {
|
if let Err(error) = prepare_result {
|
||||||
@@ -449,17 +461,27 @@ fn select_bridge_candidate<'a>(
|
|||||||
|
|
||||||
fn restore_from_journal(journal: &BridgeJournal) -> Result<()> {
|
fn restore_from_journal(journal: &BridgeJournal) -> Result<()> {
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for (kind, profile_uuid) in [
|
for (kind, profile_uuid, profile_name) in [
|
||||||
("USB", Some(journal.usb_profile_uuid.as_str())),
|
(
|
||||||
("uplink", journal.uplink_profile_uuid.as_deref()),
|
"USB",
|
||||||
("bridge", journal.bridge_profile_uuid.as_deref()),
|
(!journal.usb_profile_uuid.is_empty()).then_some(journal.usb_profile_uuid.as_str()),
|
||||||
|
journal.usb_profile_name.as_deref(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uplink",
|
||||||
|
journal.uplink_profile_uuid.as_deref(),
|
||||||
|
journal.uplink_profile_name.as_deref(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"bridge",
|
||||||
|
journal.bridge_profile_uuid.as_deref(),
|
||||||
|
journal.bridge_profile_name.as_deref(),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let Some(profile_uuid) = profile_uuid else {
|
if let Err(error) = delete_owned_connection(profile_uuid, profile_name) {
|
||||||
continue;
|
let profile = profile_uuid.or(profile_name).unwrap_or("unknown");
|
||||||
};
|
|
||||||
if let Err(error) = delete_connection(profile_uuid) {
|
|
||||||
errors.push(format!(
|
errors.push(format!(
|
||||||
"failed to remove owned {kind} profile {profile_uuid}: {error}"
|
"failed to remove owned {kind} profile {profile}: {error}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,12 +649,23 @@ fn connection_uuids() -> Result<Vec<String>> {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_connection(profile_uuid: &str) -> Result<()> {
|
fn delete_owned_connection(profile_uuid: Option<&str>, profile_name: Option<&str>) -> Result<()> {
|
||||||
if !connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) {
|
if let Some(profile_uuid) = profile_uuid {
|
||||||
|
if connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) {
|
||||||
|
return run_nmcli(&["connection", "delete", "uuid", profile_uuid]).map(|_| ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(profile_name) = profile_name else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let output = run_nmcli(&["-t", "--escape", "no", "-f", "NAME", "connection", "show"])?;
|
||||||
|
if !String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.any(|name| name == profile_name)
|
||||||
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
run_nmcli(&["connection", "delete", "uuid", profile_uuid])?;
|
run_nmcli(&["connection", "delete", "id", profile_name]).map(|_| ())
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn copy_connection_properties(source: &str, target: &str, properties: &[&str]) -> Result<()> {
|
fn copy_connection_properties(source: &str, target: &str, properties: &[&str]) -> Result<()> {
|
||||||
@@ -834,8 +867,11 @@ mod tests {
|
|||||||
existing_bridge: false,
|
existing_bridge: false,
|
||||||
original_connection_uuid: Some("original-uuid".to_string()),
|
original_connection_uuid: Some("original-uuid".to_string()),
|
||||||
bridge_profile_uuid: Some("bridge-uuid".to_string()),
|
bridge_profile_uuid: Some("bridge-uuid".to_string()),
|
||||||
|
bridge_profile_name: Some("bridge-name".to_string()),
|
||||||
uplink_profile_uuid: Some("uplink-uuid".to_string()),
|
uplink_profile_uuid: Some("uplink-uuid".to_string()),
|
||||||
|
uplink_profile_name: Some("uplink-name".to_string()),
|
||||||
usb_profile_uuid: "usb-uuid".to_string(),
|
usb_profile_uuid: "usb-uuid".to_string(),
|
||||||
|
usb_profile_name: Some("usb-name".to_string()),
|
||||||
};
|
};
|
||||||
let value = serde_json::to_string(&journal).unwrap();
|
let value = serde_json::to_string(&journal).unwrap();
|
||||||
let decoded: BridgeJournal = serde_json::from_str(&value).unwrap();
|
let decoded: BridgeJournal = serde_json::from_str(&value).unwrap();
|
||||||
@@ -844,13 +880,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transaction_profiles_use_unique_names_and_uuids() {
|
fn bridge_journal_accepts_legacy_entries_without_profile_names() {
|
||||||
|
let value = r#"{
|
||||||
|
"version": 2,
|
||||||
|
"uplink": "eth0",
|
||||||
|
"existing_bridge": false,
|
||||||
|
"original_connection_uuid": "original-uuid",
|
||||||
|
"bridge_profile_uuid": "bridge-uuid",
|
||||||
|
"uplink_profile_uuid": "uplink-uuid",
|
||||||
|
"usb_profile_uuid": "usb-uuid"
|
||||||
|
}"#;
|
||||||
|
let decoded: BridgeJournal = serde_json::from_str(value).unwrap();
|
||||||
|
assert_eq!(decoded.bridge_profile_name, None);
|
||||||
|
assert_eq!(decoded.uplink_profile_name, None);
|
||||||
|
assert_eq!(decoded.usb_profile_name, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transaction_profiles_use_unique_names() {
|
||||||
let first = TransactionProfiles::new();
|
let first = TransactionProfiles::new();
|
||||||
let second = TransactionProfiles::new();
|
let second = TransactionProfiles::new();
|
||||||
assert_ne!(first.bridge_name, second.bridge_name);
|
assert_ne!(first.bridge_name, second.bridge_name);
|
||||||
assert_ne!(first.bridge_uuid, second.bridge_uuid);
|
|
||||||
assert!(first.usb_name.starts_with(PROFILE_PREFIX));
|
assert!(first.usb_name.starts_with(PROFILE_PREFIX));
|
||||||
assert!(Uuid::parse_str(&first.usb_uuid).is_ok());
|
assert!(first.bridge_uuid.is_empty());
|
||||||
|
assert!(first.uplink_uuid.is_empty());
|
||||||
|
assert!(first.usb_uuid.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ pub fn write_file(path: &Path, content: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write an optional configfs/sysfs attribute when the running kernel exposes it.
|
||||||
|
/// This keeps newer kernel enhancements compatible with older kernels.
|
||||||
|
pub fn write_file_if_exists(path: &Path, content: &str) -> Result<bool> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
write_file(path, content)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn write_bytes(path: &Path, data: &[u8]) -> Result<()> {
|
pub fn write_bytes(path: &Path, data: &[u8]) -> Result<()> {
|
||||||
let mut file = File::create(path)
|
let mut file = File::create(path)
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to create {}: {}", path.display(), e)))?;
|
.map_err(|e| AppError::Internal(format!("Failed to create {}: {}", path.display(), e)))?;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use tracing::debug;
|
|||||||
|
|
||||||
use super::configfs::{
|
use super::configfs::{
|
||||||
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
|
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
|
||||||
|
write_file_if_exists,
|
||||||
};
|
};
|
||||||
use super::function::GadgetFunction;
|
use super::function::GadgetFunction;
|
||||||
use super::report_desc::{
|
use super::report_desc::{
|
||||||
@@ -143,6 +144,10 @@ impl GadgetFunction for HidFunction {
|
|||||||
self.func_type.report_desc(self.keyboard_leds),
|
self.func_type.report_desc(self.keyboard_leds),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Supported by the PiKVM HID kernel patch. Older kernels simply do
|
||||||
|
// not expose this ConfigFS attribute.
|
||||||
|
let _ = write_file_if_exists(&func_path.join("wakeup_on_write"), "1")?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Created HID function: {} at {}",
|
"Created HID function: {} at {}",
|
||||||
self.name(),
|
self.name(),
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ use std::path::PathBuf;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use super::configfs::{
|
use super::configfs::{
|
||||||
configfs_path, create_dir, create_symlink, find_udc, is_configfs_available, remove_dir,
|
configfs_path, create_dir, find_udc, is_configfs_available, remove_dir, write_file,
|
||||||
remove_file, write_file, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
|
write_file_if_exists, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
|
||||||
DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
|
DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
|
||||||
};
|
};
|
||||||
use super::function::GadgetFunction;
|
use super::function::GadgetFunction;
|
||||||
use super::hid::HidFunction;
|
use super::hid::HidFunction;
|
||||||
use super::msd::MsdFunction;
|
use super::msd::{MsdFunction, MsdInquiryStrings};
|
||||||
use super::network::NetworkFunction;
|
use super::network::NetworkFunction;
|
||||||
use crate::config::OtgNetworkConfig;
|
use crate::config::OtgNetworkConfig;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
@@ -47,6 +47,7 @@ pub struct OtgGadgetManager {
|
|||||||
hid_instance: u8,
|
hid_instance: u8,
|
||||||
msd_instance: u8,
|
msd_instance: u8,
|
||||||
network_instance: u8,
|
network_instance: u8,
|
||||||
|
uac_instance: u8,
|
||||||
functions: Vec<Box<dyn GadgetFunction>>,
|
functions: Vec<Box<dyn GadgetFunction>>,
|
||||||
bound_udc: Option<String>,
|
bound_udc: Option<String>,
|
||||||
created_by_us: bool,
|
created_by_us: bool,
|
||||||
@@ -73,6 +74,7 @@ impl OtgGadgetManager {
|
|||||||
hid_instance: 0,
|
hid_instance: 0,
|
||||||
msd_instance: 0,
|
msd_instance: 0,
|
||||||
network_instance: 0,
|
network_instance: 0,
|
||||||
|
uac_instance: 0,
|
||||||
functions: Vec::with_capacity(4),
|
functions: Vec::with_capacity(4),
|
||||||
bound_udc: None,
|
bound_udc: None,
|
||||||
created_by_us: false,
|
created_by_us: false,
|
||||||
@@ -132,8 +134,12 @@ impl OtgGadgetManager {
|
|||||||
Ok(device_path)
|
Ok(device_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_msd(&mut self, lun_capacity: u8) -> Result<MsdFunction> {
|
pub fn add_msd(
|
||||||
let func = MsdFunction::new(self.msd_instance, lun_capacity)?;
|
&mut self,
|
||||||
|
lun_capacity: u8,
|
||||||
|
inquiry_strings: MsdInquiryStrings,
|
||||||
|
) -> Result<MsdFunction> {
|
||||||
|
let func = MsdFunction::new(self.msd_instance, lun_capacity, inquiry_strings)?;
|
||||||
let func_clone = func.clone();
|
let func_clone = func.clone();
|
||||||
self.add_function(Box::new(func))?;
|
self.add_function(Box::new(func))?;
|
||||||
self.msd_instance += 1;
|
self.msd_instance += 1;
|
||||||
@@ -148,6 +154,14 @@ impl OtgGadgetManager {
|
|||||||
Ok(func_clone)
|
Ok(func_clone)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_uac(&mut self, sample_rate: u32, channels: u8) -> Result<super::uac::UacFunction> {
|
||||||
|
let func = super::uac::UacFunction::new(self.uac_instance, sample_rate, channels)?;
|
||||||
|
let func_clone = func.clone();
|
||||||
|
self.add_function(Box::new(func))?;
|
||||||
|
self.uac_instance += 1;
|
||||||
|
Ok(func_clone)
|
||||||
|
}
|
||||||
|
|
||||||
fn add_function(&mut self, func: Box<dyn GadgetFunction>) -> Result<()> {
|
fn add_function(&mut self, func: Box<dyn GadgetFunction>) -> Result<()> {
|
||||||
self.functions.push(func);
|
self.functions.push(func);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -186,14 +200,28 @@ impl OtgGadgetManager {
|
|||||||
func.link(&self.config_path, &self.gadget_path)?;
|
func.link(&self.config_path, &self.gadget_path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A host only enables USB remote wakeup when the configuration
|
||||||
|
// descriptor advertises it. Enable the descriptor bit only when the
|
||||||
|
// running kernel supports the HID wakeup_on_write attribute.
|
||||||
|
let hid_wakeup_supported = self.functions.iter().any(|func| {
|
||||||
|
func.name().starts_with("hid.")
|
||||||
|
&& self
|
||||||
|
.gadget_path
|
||||||
|
.join("functions")
|
||||||
|
.join(func.name())
|
||||||
|
.join("wakeup_on_write")
|
||||||
|
.exists()
|
||||||
|
});
|
||||||
|
if hid_wakeup_supported {
|
||||||
|
let _ = write_file_if_exists(&self.config_path.join("bmAttributes"), "0xA0")?;
|
||||||
|
}
|
||||||
|
|
||||||
debug!("OTG USB Gadget setup complete");
|
debug!("OTG USB Gadget setup complete");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bind(&mut self, udc: &str) -> Result<()> {
|
pub fn bind(&mut self, udc: &str) -> Result<()> {
|
||||||
if let Err(e) = self.recreate_config_links() {
|
self.recreate_config_links()?;
|
||||||
warn!("Failed to recreate gadget config links before bind: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Binding gadget to UDC: {}", udc);
|
debug!("Binding gadget to UDC: {}", udc);
|
||||||
write_file(&self.gadget_path.join("UDC"), &udc)?;
|
write_file(&self.gadget_path.join("UDC"), &udc)?;
|
||||||
@@ -355,39 +383,22 @@ impl OtgGadgetManager {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let entries = std::fs::read_dir(&functions_path).map_err(|e| {
|
// ConfigFS binds functions in link insertion order. Preserve the
|
||||||
|
// setup order (UAC before HID), including on rebind: directory
|
||||||
|
// iteration order is unspecified and can change endpoint allocation.
|
||||||
|
for func in &self.functions {
|
||||||
|
let dest = self.config_path.join(func.name());
|
||||||
|
if dest.symlink_metadata().is_ok() {
|
||||||
|
fs::remove_file(&dest).map_err(|error| {
|
||||||
AppError::Internal(format!(
|
AppError::Internal(format!(
|
||||||
"Failed to read functions directory {}: {}",
|
"Failed to remove config link {}: {error}",
|
||||||
functions_path.display(),
|
dest.display()
|
||||||
e
|
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let name = entry.file_name();
|
|
||||||
let name = match name.to_str() {
|
|
||||||
Some(n) => n,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
if !name.contains(".usb") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let src = functions_path.join(name);
|
|
||||||
let dest = self.config_path.join(name);
|
|
||||||
|
|
||||||
if dest.exists() {
|
|
||||||
if let Err(e) = remove_file(&dest) {
|
|
||||||
warn!(
|
|
||||||
"Failed to remove existing config link {}: {}",
|
|
||||||
dest.display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for func in &self.functions {
|
||||||
create_symlink(&src, &dest)?;
|
func.link(&self.config_path, &self.gadget_path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -440,6 +451,81 @@ pub async fn wait_for_hid_devices(device_paths: &[PathBuf], timeout_ms: u64) ->
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
struct RecordedFunction {
|
||||||
|
name: &'static str,
|
||||||
|
links: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GadgetFunction for RecordedFunction {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.name
|
||||||
|
}
|
||||||
|
fn create(&self, _: &Path) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn link(&self, config: &Path, gadget: &Path) -> Result<()> {
|
||||||
|
super::super::configfs::create_symlink(
|
||||||
|
&gadget.join("functions").join(self.name),
|
||||||
|
&config.join(self.name),
|
||||||
|
)?;
|
||||||
|
self.links.lock().unwrap().push(self.name.into());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn unlink(&self, _: &Path) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn cleanup(&self, _: &Path) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebind_links_uac_before_hid_in_registration_order() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let mut manager = OtgGadgetManager::new();
|
||||||
|
manager.gadget_path = temp.path().to_path_buf();
|
||||||
|
manager.config_path = temp.path().join("configs/c.1");
|
||||||
|
fs::create_dir_all(&manager.config_path).unwrap();
|
||||||
|
let links = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
for name in ["uac1.usb0", "hid.usb0", "mass_storage.usb0"] {
|
||||||
|
fs::create_dir_all(temp.path().join("functions").join(name)).unwrap();
|
||||||
|
// Include a dangling pre-existing link, as well as testing rebind.
|
||||||
|
std::os::unix::fs::symlink("/nonexistent-uac-test", manager.config_path.join(name))
|
||||||
|
.unwrap();
|
||||||
|
manager
|
||||||
|
.add_function(Box::new(RecordedFunction {
|
||||||
|
name,
|
||||||
|
links: Arc::clone(&links),
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
for _ in 0..2 {
|
||||||
|
links.lock().unwrap().clear();
|
||||||
|
manager.recreate_config_links().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
*links.lock().unwrap(),
|
||||||
|
["uac1.usb0", "hid.usb0", "mass_storage.usb0"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_failure_prevents_udc_binding() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let mut manager = OtgGadgetManager::new();
|
||||||
|
manager.gadget_path = temp.path().to_path_buf();
|
||||||
|
manager.config_path = temp.path().join("configs/c.1");
|
||||||
|
fs::create_dir_all(temp.path().join("functions/hid.usb0")).unwrap();
|
||||||
|
// A directory occupying a config link cannot be removed as a file.
|
||||||
|
fs::create_dir_all(manager.config_path.join("hid.usb0")).unwrap();
|
||||||
|
fs::write(temp.path().join("UDC"), "").unwrap();
|
||||||
|
manager.add_keyboard(false).unwrap();
|
||||||
|
assert!(manager.bind("test-udc").is_err());
|
||||||
|
assert_eq!(fs::read_to_string(temp.path().join("UDC")).unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_manager_creation() {
|
fn test_manager_creation() {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub mod report_desc;
|
|||||||
pub mod self_check;
|
pub mod self_check;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub mod uac;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
|
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
|
||||||
@@ -27,6 +29,8 @@ pub use msd::{MsdFunction, MsdLunConfig};
|
|||||||
pub use network::NetworkFunction;
|
pub use network::NetworkFunction;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService};
|
pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService};
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub use uac::UacFunction;
|
||||||
|
|
||||||
/// List USB Device Controller names exposed by sysfs.
|
/// List USB Device Controller names exposed by sysfs.
|
||||||
pub fn list_udc_devices() -> Vec<String> {
|
pub fn list_udc_devices() -> Vec<String> {
|
||||||
|
|||||||
479
src/otg/msd.rs
479
src/otg/msd.rs
@@ -1,10 +1,14 @@
|
|||||||
use std::fs;
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::configfs::{create_dir, create_symlink, remove_dir, remove_file, write_file};
|
use super::configfs::{create_dir, create_symlink, remove_dir, remove_file, write_file};
|
||||||
use super::function::GadgetFunction;
|
use super::function::GadgetFunction;
|
||||||
use crate::error::{AppError, Result};
|
use crate::config::{MsdConfig, DEFAULT_CDROM_INQUIRY_STRING, DEFAULT_FLASH_INQUIRY_STRING};
|
||||||
|
use crate::error::{AppError, MsdErrorCode, Result};
|
||||||
|
|
||||||
|
const MEDIA_TYPE_REBIND_DELAY_MS: u64 = 300;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MsdLunConfig {
|
pub struct MsdLunConfig {
|
||||||
@@ -53,14 +57,39 @@ impl MsdLunConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MsdInquiryStrings {
|
||||||
|
pub flash: String,
|
||||||
|
pub cdrom: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MsdInquiryStrings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
flash: DEFAULT_FLASH_INQUIRY_STRING.to_string(),
|
||||||
|
cdrom: DEFAULT_CDROM_INQUIRY_STRING.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&MsdConfig> for MsdInquiryStrings {
|
||||||
|
fn from(config: &MsdConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
flash: config.flash_inquiry_string.clone(),
|
||||||
|
cdrom: config.cdrom_inquiry_string.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MsdFunction {
|
pub struct MsdFunction {
|
||||||
name: String,
|
name: String,
|
||||||
lun_capacity: u8,
|
lun_capacity: u8,
|
||||||
|
inquiry_strings: MsdInquiryStrings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MsdFunction {
|
impl MsdFunction {
|
||||||
pub fn new(instance: u8, lun_capacity: u8) -> Result<Self> {
|
pub fn new(instance: u8, lun_capacity: u8, inquiry_strings: MsdInquiryStrings) -> Result<Self> {
|
||||||
if lun_capacity != 1 && lun_capacity != 8 {
|
if lun_capacity != 1 && lun_capacity != 8 {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"MSD LUN capacity must be 1 or 8, got {lun_capacity}"
|
"MSD LUN capacity must be 1 or 8, got {lun_capacity}"
|
||||||
@@ -70,6 +99,7 @@ impl MsdFunction {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
name: format!("mass_storage.usb{}", instance),
|
name: format!("mass_storage.usb{}", instance),
|
||||||
lun_capacity,
|
lun_capacity,
|
||||||
|
inquiry_strings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +180,88 @@ impl MsdFunction {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current_cdrom = fs::read_to_string(lun_path.join("cdrom"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let rebind_required = Self::media_type_rebind_required(¤t_cdrom, config);
|
||||||
|
let udc_path = gadget_path.join("UDC");
|
||||||
|
let bound_udc = if rebind_required && udc_path.exists() {
|
||||||
|
fs::read_to_string(&udc_path)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::Internal(format!(
|
||||||
|
"Failed to read bound UDC before changing LUN {lun} media type: {error}"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !bound_udc.is_empty() {
|
||||||
|
info!(
|
||||||
|
"LUN {} media type is changing; temporarily unbinding UDC {}",
|
||||||
|
lun, bound_udc
|
||||||
|
);
|
||||||
|
write_file(&udc_path, "")?;
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(MEDIA_TYPE_REBIND_DELAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
let configure_result = self.configure_lun_attributes(&lun_path, lun, config);
|
||||||
|
let rebind_result = if bound_udc.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let result = write_file(&udc_path, &bound_udc);
|
||||||
|
if result.is_ok() {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(MEDIA_TYPE_REBIND_DELAY_MS));
|
||||||
|
info!(
|
||||||
|
"Rebound UDC {} after changing LUN {} media type",
|
||||||
|
bound_udc, lun
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
};
|
||||||
|
|
||||||
|
match (configure_result, rebind_result) {
|
||||||
|
(Ok(()), Ok(())) => Ok(()),
|
||||||
|
(Err(configure_error), Ok(())) => Err(configure_error),
|
||||||
|
(Ok(()), Err(rebind_error)) => Err(AppError::Internal(format!(
|
||||||
|
"Configured LUN {lun}, but failed to rebind UDC {bound_udc}: {rebind_error}"
|
||||||
|
))),
|
||||||
|
(Err(configure_error), Err(rebind_error)) => Err(AppError::Internal(format!(
|
||||||
|
"Failed to configure LUN {lun}: {configure_error}; also failed to rebind UDC {bound_udc}: {rebind_error}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_type_rebind_required(current_cdrom: &str, config: &MsdLunConfig) -> bool {
|
||||||
|
current_cdrom != if config.cdrom { "1" } else { "0" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inquiry_string_path(lun_path: &Path, cdrom: bool) -> Option<PathBuf> {
|
||||||
|
let cdrom_path = lun_path.join("inquiry_string_cdrom");
|
||||||
|
if cdrom && cdrom_path.exists() {
|
||||||
|
return Some(cdrom_path);
|
||||||
|
}
|
||||||
|
let generic_path = lun_path.join("inquiry_string");
|
||||||
|
generic_path.exists().then_some(generic_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inquiry_string(&self, cdrom: bool) -> &str {
|
||||||
|
if cdrom {
|
||||||
|
&self.inquiry_strings.cdrom
|
||||||
|
} else {
|
||||||
|
&self.inquiry_strings.flash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_lun_attributes(
|
||||||
|
&self,
|
||||||
|
lun_path: &Path,
|
||||||
|
lun: u8,
|
||||||
|
config: &MsdLunConfig,
|
||||||
|
) -> Result<()> {
|
||||||
let read_attr = |attr: &str| -> String {
|
let read_attr = |attr: &str| -> String {
|
||||||
fs::read_to_string(lun_path.join(attr))
|
fs::read_to_string(lun_path.join(attr))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -161,7 +273,6 @@ impl MsdFunction {
|
|||||||
let current_ro = read_attr("ro");
|
let current_ro = read_attr("ro");
|
||||||
let current_removable = read_attr("removable");
|
let current_removable = read_attr("removable");
|
||||||
let current_nofua = read_attr("nofua");
|
let current_nofua = read_attr("nofua");
|
||||||
|
|
||||||
let new_cdrom = if config.cdrom { "1" } else { "0" };
|
let new_cdrom = if config.cdrom { "1" } else { "0" };
|
||||||
let new_ro = if config.ro { "1" } else { "0" };
|
let new_ro = if config.ro { "1" } else { "0" };
|
||||||
let new_removable = if config.removable { "1" } else { "0" };
|
let new_removable = if config.removable { "1" } else { "0" };
|
||||||
@@ -170,20 +281,26 @@ impl MsdFunction {
|
|||||||
let forced_eject_path = lun_path.join("forced_eject");
|
let forced_eject_path = lun_path.join("forced_eject");
|
||||||
if forced_eject_path.exists() {
|
if forced_eject_path.exists() {
|
||||||
debug!("Using forced_eject to clear LUN {}", lun);
|
debug!("Using forced_eject to clear LUN {}", lun);
|
||||||
let _ = write_file(&forced_eject_path, "1");
|
if let Err(error) = write_file(&forced_eject_path, "1") {
|
||||||
|
warn!(
|
||||||
|
"LUN {} forced_eject failed while changing media: {}; clearing file instead",
|
||||||
|
lun, error
|
||||||
|
);
|
||||||
|
write_file(&lun_path.join("file"), "")?;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = write_file(&lun_path.join("file"), "");
|
write_file(&lun_path.join("file"), "")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
|
||||||
let cdrom_changed = current_cdrom != new_cdrom;
|
if current_cdrom != new_cdrom {
|
||||||
if cdrom_changed {
|
|
||||||
debug!(
|
debug!(
|
||||||
"Updating LUN {} cdrom: {} -> {}",
|
"Updating LUN {} cdrom: {} -> {}",
|
||||||
lun, current_cdrom, new_cdrom
|
lun, current_cdrom, new_cdrom
|
||||||
);
|
);
|
||||||
write_file(&lun_path.join("cdrom"), new_cdrom)?;
|
write_file(&lun_path.join("cdrom"), new_cdrom)?;
|
||||||
|
self.write_inquiry_string(lun_path, config.cdrom)?;
|
||||||
}
|
}
|
||||||
if current_ro != new_ro {
|
if current_ro != new_ro {
|
||||||
debug!("Updating LUN {} ro: {} -> {}", lun, current_ro, new_ro);
|
debug!("Updating LUN {} ro: {} -> {}", lun, current_ro, new_ro);
|
||||||
@@ -204,11 +321,6 @@ impl MsdFunction {
|
|||||||
write_file(&lun_path.join("nofua"), new_nofua)?;
|
write_file(&lun_path.join("nofua"), new_nofua)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if cdrom_changed {
|
|
||||||
debug!("CDROM mode changed, brief yield for USB host");
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.file.exists() {
|
if config.file.exists() {
|
||||||
let file_path = config.file.to_string_lossy();
|
let file_path = config.file.to_string_lossy();
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
@@ -225,10 +337,9 @@ impl MsdFunction {
|
|||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(error) => {
|
||||||
let is_busy = e.to_string().contains("Device or resource busy")
|
let is_busy = error.to_string().contains("Device or resource busy")
|
||||||
|| e.to_string().contains("os error 16");
|
|| error.to_string().contains("os error 16");
|
||||||
|
|
||||||
if is_busy && attempt < 4 {
|
if is_busy && attempt < 4 {
|
||||||
warn!(
|
warn!(
|
||||||
"LUN {} file write busy, retrying (attempt {}/5)",
|
"LUN {} file write busy, retrying (attempt {}/5)",
|
||||||
@@ -236,17 +347,16 @@ impl MsdFunction {
|
|||||||
attempt + 1
|
attempt + 1
|
||||||
);
|
);
|
||||||
std::thread::sleep(std::time::Duration::from_millis(50 << attempt));
|
std::thread::sleep(std::time::Duration::from_millis(50 << attempt));
|
||||||
last_error = Some(e);
|
last_error = Some(error);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
return Err(error);
|
||||||
return Err(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(e) = last_error {
|
if let Some(error) = last_error {
|
||||||
return Err(e);
|
return Err(error);
|
||||||
}
|
}
|
||||||
} else if !config.file.as_os_str().is_empty() {
|
} else if !config.file.as_os_str().is_empty() {
|
||||||
warn!("LUN {} file does not exist: {}", lun, config.file.display());
|
warn!("LUN {} file does not exist: {}", lun, config.file.display());
|
||||||
@@ -255,6 +365,26 @@ impl MsdFunction {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_inquiry_string(&self, lun_path: &Path, cdrom: bool) -> Result<()> {
|
||||||
|
if let Some(path) = Self::inquiry_string_path(lun_path, cdrom) {
|
||||||
|
write_file(&path, self.inquiry_string(cdrom))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_inquiry_strings(&self, lun_path: &Path) -> Result<()> {
|
||||||
|
let generic_path = lun_path.join("inquiry_string");
|
||||||
|
if generic_path.exists() {
|
||||||
|
write_file(&generic_path, &self.inquiry_strings.flash)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cdrom_path = lun_path.join("inquiry_string_cdrom");
|
||||||
|
if cdrom_path.exists() {
|
||||||
|
write_file(&cdrom_path, &self.inquiry_strings.cdrom)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn disconnect_lun_async(&self, gadget_path: &Path, lun: u8) -> Result<()> {
|
pub async fn disconnect_lun_async(&self, gadget_path: &Path, lun: u8) -> Result<()> {
|
||||||
let gadget_path = gadget_path.to_path_buf();
|
let gadget_path = gadget_path.to_path_buf();
|
||||||
let this = self.clone();
|
let this = self.clone();
|
||||||
@@ -276,6 +406,52 @@ impl MsdFunction {
|
|||||||
self.disconnect_lun_path(&lun_path, lun as u16)
|
self.disconnect_lun_path(&lun_path, lun as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn medium_removal_was_prevented(error: &std::io::Error) -> bool {
|
||||||
|
error.raw_os_error() == Some(libc::EBUSY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_lun_file(file_path: &Path, lun: u16) -> Result<()> {
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open(file_path)
|
||||||
|
.map_err(|error| {
|
||||||
|
warn!(
|
||||||
|
lun,
|
||||||
|
path = %file_path.display(),
|
||||||
|
%error,
|
||||||
|
"Failed to open MSD LUN backing-file attribute while disconnecting"
|
||||||
|
);
|
||||||
|
AppError::from(MsdErrorCode::MsdDisconnectFailed)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// An empty configfs value is represented by a newline. Keep this as one
|
||||||
|
// write operation so EBUSY can be attributed to fsg_store_file().
|
||||||
|
if let Err(error) = file.write_all(b"\n") {
|
||||||
|
warn!(
|
||||||
|
lun,
|
||||||
|
path = %file_path.display(),
|
||||||
|
errno = error.raw_os_error(),
|
||||||
|
%error,
|
||||||
|
"Kernel rejected MSD LUN disconnect"
|
||||||
|
);
|
||||||
|
return if Self::medium_removal_was_prevented(&error) {
|
||||||
|
Err(MsdErrorCode::MsdMediumRemovalPrevented.into())
|
||||||
|
} else {
|
||||||
|
Err(MsdErrorCode::MsdDisconnectFailed.into())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
file.flush().map_err(|error| {
|
||||||
|
warn!(
|
||||||
|
lun,
|
||||||
|
path = %file_path.display(),
|
||||||
|
%error,
|
||||||
|
"Failed to flush MSD LUN backing-file attribute while disconnecting"
|
||||||
|
);
|
||||||
|
MsdErrorCode::MsdDisconnectFailed.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn disconnect_lun_path(&self, lun_path: &Path, lun: u16) -> Result<()> {
|
fn disconnect_lun_path(&self, lun_path: &Path, lun: u16) -> Result<()> {
|
||||||
if lun_path.exists() {
|
if lun_path.exists() {
|
||||||
let forced_eject_path = lun_path.join("forced_eject");
|
let forced_eject_path = lun_path.join("forced_eject");
|
||||||
@@ -293,14 +469,14 @@ impl MsdFunction {
|
|||||||
);
|
);
|
||||||
let file_path = lun_path.join("file");
|
let file_path = lun_path.join("file");
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
write_file(&file_path, "")?;
|
Self::clear_lun_file(&file_path, lun)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let file_path = lun_path.join("file");
|
let file_path = lun_path.join("file");
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
write_file(&file_path, "")?;
|
Self::clear_lun_file(&file_path, lun)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
info!("LUN {} disconnected", lun);
|
info!("LUN {} disconnected", lun);
|
||||||
@@ -344,6 +520,7 @@ impl GadgetFunction for MsdFunction {
|
|||||||
|
|
||||||
for lun in 0..self.lun_capacity {
|
for lun in 0..self.lun_capacity {
|
||||||
self.clear_lun_unbound(gadget_path, lun)?;
|
self.clear_lun_unbound(gadget_path, lun)?;
|
||||||
|
self.write_inquiry_strings(&self.lun_path(gadget_path, lun))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Created MSD function: {}", self.name());
|
debug!("Created MSD function: {}", self.name());
|
||||||
@@ -415,6 +592,10 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn test_msd(capacity: u8) -> MsdFunction {
|
||||||
|
MsdFunction::new(0, capacity, MsdInquiryStrings::default()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lun_config_cdrom() {
|
fn test_lun_config_cdrom() {
|
||||||
let config = MsdLunConfig::cdrom(PathBuf::from("/tmp/test.iso"));
|
let config = MsdLunConfig::cdrom(PathBuf::from("/tmp/test.iso"));
|
||||||
@@ -431,21 +612,154 @@ mod tests {
|
|||||||
assert!(config.removable);
|
assert!(config.removable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inquiry_string_uses_cdrom_attribute_with_stock_fallback() {
|
||||||
|
let msd = MsdFunction::new(
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
MsdInquiryStrings {
|
||||||
|
flash: "Custom Flash".into(),
|
||||||
|
cdrom: "Custom Optical".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let patched = TempDir::new().unwrap();
|
||||||
|
std::fs::write(patched.path().join("inquiry_string"), b"generic\n").unwrap();
|
||||||
|
std::fs::write(patched.path().join("inquiry_string_cdrom"), b"cdrom\n").unwrap();
|
||||||
|
|
||||||
|
msd.write_inquiry_strings(patched.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(patched.path().join("inquiry_string_cdrom"))
|
||||||
|
.unwrap()
|
||||||
|
.trim(),
|
||||||
|
"Custom Optical"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(patched.path().join("inquiry_string"))
|
||||||
|
.unwrap()
|
||||||
|
.trim(),
|
||||||
|
"Custom Flash"
|
||||||
|
);
|
||||||
|
|
||||||
|
let stock = TempDir::new().unwrap();
|
||||||
|
std::fs::write(stock.path().join("inquiry_string"), b"generic\n").unwrap();
|
||||||
|
msd.write_inquiry_string(stock.path(), true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(stock.path().join("inquiry_string"))
|
||||||
|
.unwrap()
|
||||||
|
.trim(),
|
||||||
|
"Custom Optical"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_msd_function_name() {
|
fn test_msd_function_name() {
|
||||||
let msd = MsdFunction::new(0, 1).unwrap();
|
let msd = test_msd(1);
|
||||||
assert_eq!(msd.name(), "mass_storage.usb0");
|
assert_eq!(msd.name(), "mass_storage.usb0");
|
||||||
assert_eq!(msd.lun_capacity, 1);
|
assert_eq!(msd.lun_capacity, 1);
|
||||||
|
|
||||||
let multi = MsdFunction::new(0, 8).unwrap();
|
let multi = test_msd(8);
|
||||||
assert_eq!(multi.lun_capacity, 8);
|
assert_eq!(multi.lun_capacity, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_msd_function_rejects_invalid_capacity() {
|
fn test_msd_function_rejects_invalid_capacity() {
|
||||||
assert!(MsdFunction::new(0, 0).is_err());
|
assert!(MsdFunction::new(0, 0, MsdInquiryStrings::default()).is_err());
|
||||||
assert!(MsdFunction::new(0, 2).is_err());
|
assert!(MsdFunction::new(0, 2, MsdInquiryStrings::default()).is_err());
|
||||||
assert!(MsdFunction::new(0, 9).is_err());
|
assert!(MsdFunction::new(0, 9, MsdInquiryStrings::default()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_ebusy_means_the_host_prevented_medium_removal() {
|
||||||
|
let busy = std::io::Error::from_raw_os_error(libc::EBUSY);
|
||||||
|
let io = std::io::Error::from_raw_os_error(libc::EIO);
|
||||||
|
|
||||||
|
assert!(MsdFunction::medium_removal_was_prevented(&busy));
|
||||||
|
assert!(!MsdFunction::medium_removal_was_prevented(&io));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_lun_prefers_forced_eject() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
|
||||||
|
std::fs::create_dir_all(&lun_path).unwrap();
|
||||||
|
std::fs::write(lun_path.join("file"), b"backing.img\n").unwrap();
|
||||||
|
std::fs::write(lun_path.join("forced_eject"), b"0\n").unwrap();
|
||||||
|
let msd = test_msd(1);
|
||||||
|
|
||||||
|
msd.disconnect_lun(temp_dir.path(), 0).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(lun_path.join("forced_eject")).unwrap(),
|
||||||
|
b"1\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(lun_path.join("file")).unwrap(),
|
||||||
|
b"backing.img\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_lun_without_forced_eject_clears_file() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
|
||||||
|
std::fs::create_dir_all(&lun_path).unwrap();
|
||||||
|
std::fs::write(lun_path.join("file"), b"backing.img\n").unwrap();
|
||||||
|
let msd = test_msd(1);
|
||||||
|
|
||||||
|
msd.disconnect_lun(temp_dir.path(), 0).unwrap();
|
||||||
|
|
||||||
|
assert!(std::fs::read(lun_path.join("file"))
|
||||||
|
.unwrap()
|
||||||
|
.starts_with(b"\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_lun_falls_back_when_forced_eject_write_fails() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
|
||||||
|
std::fs::create_dir_all(lun_path.join("forced_eject")).unwrap();
|
||||||
|
std::fs::write(lun_path.join("file"), b"backing.img\n").unwrap();
|
||||||
|
let msd = test_msd(1);
|
||||||
|
|
||||||
|
msd.disconnect_lun(temp_dir.path(), 0).unwrap();
|
||||||
|
|
||||||
|
assert!(std::fs::read(lun_path.join("file"))
|
||||||
|
.unwrap()
|
||||||
|
.starts_with(b"\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_lun_only_changes_the_selected_lun() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let function_path = temp_dir.path().join("functions/mass_storage.usb0");
|
||||||
|
for lun in 0..2 {
|
||||||
|
let lun_path = function_path.join(format!("lun.{lun}"));
|
||||||
|
std::fs::create_dir_all(&lun_path).unwrap();
|
||||||
|
std::fs::write(lun_path.join("file"), format!("backing-{lun}.img\n")).unwrap();
|
||||||
|
std::fs::write(lun_path.join("forced_eject"), b"0\n").unwrap();
|
||||||
|
}
|
||||||
|
let msd = test_msd(8);
|
||||||
|
|
||||||
|
msd.disconnect_lun(temp_dir.path(), 1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(function_path.join("lun.0/forced_eject")).unwrap(),
|
||||||
|
b"0\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(function_path.join("lun.1/forced_eject")).unwrap(),
|
||||||
|
b"1\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(function_path.join("lun.0/file")).unwrap(),
|
||||||
|
b"backing-0.img\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(function_path.join("lun.1/file")).unwrap(),
|
||||||
|
b"backing-1.img\n"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -453,7 +767,7 @@ mod tests {
|
|||||||
for capacity in [1, 8] {
|
for capacity in [1, 8] {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
std::fs::create_dir_all(temp_dir.path().join("functions")).unwrap();
|
std::fs::create_dir_all(temp_dir.path().join("functions")).unwrap();
|
||||||
let msd = MsdFunction::new(0, capacity).unwrap();
|
let msd = test_msd(capacity);
|
||||||
|
|
||||||
msd.create(temp_dir.path()).unwrap();
|
msd.create(temp_dir.path()).unwrap();
|
||||||
|
|
||||||
@@ -475,7 +789,7 @@ mod tests {
|
|||||||
std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap();
|
std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap();
|
||||||
let image_path = temp_dir.path().join("test.img");
|
let image_path = temp_dir.path().join("test.img");
|
||||||
std::fs::write(&image_path, b"image").unwrap();
|
std::fs::write(&image_path, b"image").unwrap();
|
||||||
let msd = MsdFunction::new(0, 1).unwrap();
|
let msd = test_msd(1);
|
||||||
|
|
||||||
msd.configure_lun(temp_dir.path(), 0, &MsdLunConfig::disk(image_path, false))
|
msd.configure_lun(temp_dir.path(), 0, &MsdLunConfig::disk(image_path, false))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -486,6 +800,81 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_type_changes_require_udc_rebind() {
|
||||||
|
let iso = MsdLunConfig::cdrom(PathBuf::from("/tmp/test.iso"));
|
||||||
|
let disk = MsdLunConfig::disk(PathBuf::from("/tmp/test.img"), false);
|
||||||
|
|
||||||
|
assert!(MsdFunction::media_type_rebind_required("0", &iso));
|
||||||
|
assert!(!MsdFunction::media_type_rebind_required("1", &iso));
|
||||||
|
assert!(MsdFunction::media_type_rebind_required("1", &disk));
|
||||||
|
assert!(!MsdFunction::media_type_rebind_required("0", &disk));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configure_cdrom_restores_bound_udc() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
|
||||||
|
std::fs::create_dir_all(&lun_path).unwrap();
|
||||||
|
for attr in ["file", "cdrom", "ro", "removable", "nofua"] {
|
||||||
|
std::fs::write(lun_path.join(attr), b"0\n").unwrap();
|
||||||
|
}
|
||||||
|
std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap();
|
||||||
|
let image_path = temp_dir.path().join("test.iso");
|
||||||
|
std::fs::write(&image_path, b"iso").unwrap();
|
||||||
|
let msd = test_msd(1);
|
||||||
|
|
||||||
|
msd.configure_lun(temp_dir.path(), 0, &MsdLunConfig::cdrom(image_path.clone()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(temp_dir.path().join("UDC"))
|
||||||
|
.unwrap()
|
||||||
|
.trim(),
|
||||||
|
"test.udc"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(lun_path.join("cdrom"))
|
||||||
|
.unwrap()
|
||||||
|
.trim(),
|
||||||
|
"1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(lun_path.join("ro")).unwrap().trim(),
|
||||||
|
"1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(lun_path.join("file"))
|
||||||
|
.unwrap()
|
||||||
|
.trim(),
|
||||||
|
image_path.to_string_lossy()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configure_failure_still_restores_bound_udc() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
|
||||||
|
std::fs::create_dir_all(lun_path.join("file")).unwrap();
|
||||||
|
for attr in ["cdrom", "ro", "removable", "nofua"] {
|
||||||
|
std::fs::write(lun_path.join(attr), b"0\n").unwrap();
|
||||||
|
}
|
||||||
|
std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap();
|
||||||
|
let image_path = temp_dir.path().join("test.iso");
|
||||||
|
std::fs::write(&image_path, b"iso").unwrap();
|
||||||
|
let msd = test_msd(1);
|
||||||
|
|
||||||
|
assert!(msd
|
||||||
|
.configure_lun(temp_dir.path(), 0, &MsdLunConfig::cdrom(image_path),)
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(temp_dir.path().join("UDC"))
|
||||||
|
.unwrap()
|
||||||
|
.trim(),
|
||||||
|
"test.udc"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cleanup_removes_all_dynamic_luns_including_stale_capacity() {
|
fn cleanup_removes_all_dynamic_luns_including_stale_capacity() {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
@@ -493,13 +882,37 @@ mod tests {
|
|||||||
for lun in 1..8 {
|
for lun in 1..8 {
|
||||||
std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap();
|
std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap();
|
||||||
}
|
}
|
||||||
let msd = MsdFunction::new(0, 1).unwrap();
|
let msd = test_msd(1);
|
||||||
|
|
||||||
msd.cleanup(temp_dir.path()).unwrap();
|
msd.cleanup(temp_dir.path()).unwrap();
|
||||||
|
|
||||||
assert!(!func_path.exists());
|
assert!(!func_path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_forced_ejects_every_existing_lun() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let func_path = temp_dir.path().join("functions/mass_storage.usb0");
|
||||||
|
for lun in 0..3 {
|
||||||
|
let lun_path = func_path.join(format!("lun.{lun}"));
|
||||||
|
std::fs::create_dir_all(&lun_path).unwrap();
|
||||||
|
std::fs::write(lun_path.join("file"), format!("backing-{lun}.img\n")).unwrap();
|
||||||
|
std::fs::write(lun_path.join("forced_eject"), b"0\n").unwrap();
|
||||||
|
}
|
||||||
|
let msd = test_msd(1);
|
||||||
|
|
||||||
|
// Ordinary files do not disappear with configfs groups, so cleanup is
|
||||||
|
// expected to report directory-removal failures in this test fixture.
|
||||||
|
assert!(msd.cleanup(temp_dir.path()).is_err());
|
||||||
|
|
||||||
|
for lun in 0..3 {
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(func_path.join(format!("lun.{lun}/forced_eject"))).unwrap(),
|
||||||
|
b"1\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cleanup_reports_when_non_configfs_cannot_release_default_lun() {
|
fn cleanup_reports_when_non_configfs_cannot_release_default_lun() {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
@@ -507,7 +920,7 @@ mod tests {
|
|||||||
for lun in 0..2 {
|
for lun in 0..2 {
|
||||||
std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap();
|
std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap();
|
||||||
}
|
}
|
||||||
let msd = MsdFunction::new(0, 1).unwrap();
|
let msd = test_msd(1);
|
||||||
|
|
||||||
let error = msd.cleanup(temp_dir.path()).unwrap_err();
|
let error = msd.cleanup(temp_dir.path()).unwrap_err();
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ use typeshare::typeshare;
|
|||||||
|
|
||||||
use super::bridge::NetworkBridgeRuntime;
|
use super::bridge::NetworkBridgeRuntime;
|
||||||
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
||||||
use super::msd::MsdFunction;
|
use super::msd::{MsdFunction, MsdInquiryStrings, MsdLunConfig};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
|
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
|
||||||
|
UacConfig,
|
||||||
};
|
};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
@@ -61,7 +62,9 @@ pub(crate) struct OtgDesiredState {
|
|||||||
pub keyboard_leds: bool,
|
pub keyboard_leds: bool,
|
||||||
pub msd_enabled: bool,
|
pub msd_enabled: bool,
|
||||||
pub msd_lun_capacity: u8,
|
pub msd_lun_capacity: u8,
|
||||||
|
pub msd_inquiry_strings: MsdInquiryStrings,
|
||||||
pub network: OtgNetworkConfig,
|
pub network: OtgNetworkConfig,
|
||||||
|
pub uac: UacConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OtgDesiredState {
|
impl Default for OtgDesiredState {
|
||||||
@@ -73,7 +76,9 @@ impl Default for OtgDesiredState {
|
|||||||
keyboard_leds: false,
|
keyboard_leds: false,
|
||||||
msd_enabled: false,
|
msd_enabled: false,
|
||||||
msd_lun_capacity: 1,
|
msd_lun_capacity: 1,
|
||||||
|
msd_inquiry_strings: MsdInquiryStrings::default(),
|
||||||
network: OtgNetworkConfig::default(),
|
network: OtgNetworkConfig::default(),
|
||||||
|
uac: UacConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,8 +88,11 @@ impl OtgDesiredState {
|
|||||||
hid: &HidConfig,
|
hid: &HidConfig,
|
||||||
msd: &MsdConfig,
|
msd: &MsdConfig,
|
||||||
network: &OtgNetworkConfig,
|
network: &OtgNetworkConfig,
|
||||||
|
uac: &UacConfig,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
network.validate()?;
|
network.validate()?;
|
||||||
|
uac.validate()?;
|
||||||
|
msd.validate()?;
|
||||||
let hid_functions = if hid.backend == HidBackend::Otg {
|
let hid_functions = if hid.backend == HidBackend::Otg {
|
||||||
let functions = hid.constrained_otg_functions();
|
let functions = hid.constrained_otg_functions();
|
||||||
Some(functions)
|
Some(functions)
|
||||||
@@ -93,7 +101,7 @@ impl OtgDesiredState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
hid.validate_otg_functions()?;
|
hid.validate_otg_functions()?;
|
||||||
let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled;
|
let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled || uac.enabled;
|
||||||
let udc = if needs_udc {
|
let udc = if needs_udc {
|
||||||
hid.otg_udc
|
hid.otg_udc
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -110,7 +118,13 @@ impl OtgDesiredState {
|
|||||||
keyboard_leds: hid.effective_otg_keyboard_leds(),
|
keyboard_leds: hid.effective_otg_keyboard_leds(),
|
||||||
msd_enabled: msd.enabled,
|
msd_enabled: msd.enabled,
|
||||||
msd_lun_capacity: 1,
|
msd_lun_capacity: 1,
|
||||||
|
msd_inquiry_strings: MsdInquiryStrings::from(msd),
|
||||||
network: network.clone(),
|
network: network.clone(),
|
||||||
|
uac: if uac.enabled {
|
||||||
|
uac.clone()
|
||||||
|
} else {
|
||||||
|
UacConfig::default()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +146,9 @@ struct OtgServiceState {
|
|||||||
pub hid_enabled: bool,
|
pub hid_enabled: bool,
|
||||||
pub msd_enabled: bool,
|
pub msd_enabled: bool,
|
||||||
pub msd_lun_capacity: u8,
|
pub msd_lun_capacity: u8,
|
||||||
|
pub msd_inquiry_strings: MsdInquiryStrings,
|
||||||
pub network: OtgNetworkConfig,
|
pub network: OtgNetworkConfig,
|
||||||
|
pub uac: UacConfig,
|
||||||
pub configured_udc: Option<String>,
|
pub configured_udc: Option<String>,
|
||||||
pub hid_paths: Option<HidDevicePaths>,
|
pub hid_paths: Option<HidDevicePaths>,
|
||||||
pub hid_functions: Option<OtgHidFunctions>,
|
pub hid_functions: Option<OtgHidFunctions>,
|
||||||
@@ -149,7 +165,9 @@ impl Default for OtgServiceState {
|
|||||||
hid_enabled: false,
|
hid_enabled: false,
|
||||||
msd_enabled: false,
|
msd_enabled: false,
|
||||||
msd_lun_capacity: 1,
|
msd_lun_capacity: 1,
|
||||||
|
msd_inquiry_strings: MsdInquiryStrings::default(),
|
||||||
network: OtgNetworkConfig::default(),
|
network: OtgNetworkConfig::default(),
|
||||||
|
uac: UacConfig::default(),
|
||||||
configured_udc: None,
|
configured_udc: None,
|
||||||
hid_paths: None,
|
hid_paths: None,
|
||||||
hid_functions: None,
|
hid_functions: None,
|
||||||
@@ -202,6 +220,27 @@ impl OtgService {
|
|||||||
self.desired.read().await.msd_lun_capacity
|
self.desired.read().await.msd_lun_capacity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn configure_msd_lun(&self, lun: u8, config: &MsdLunConfig) -> Result<()> {
|
||||||
|
// Keep the manager locked across a possible UDC rebind so an OTG
|
||||||
|
// reconcile cannot replace the gadget halfway through the media-type
|
||||||
|
// transition.
|
||||||
|
let manager = self.manager.lock().await;
|
||||||
|
let gadget_path = manager
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.gadget_path().clone())
|
||||||
|
.ok_or_else(|| AppError::Internal("OTG gadget is not active".to_string()))?;
|
||||||
|
let function = self
|
||||||
|
.msd_function
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| AppError::Internal("MSD function is not active".to_string()))?;
|
||||||
|
|
||||||
|
function
|
||||||
|
.configure_lun_async(&gadget_path, lun, config)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn network_status(&self) -> OtgNetworkStatus {
|
pub async fn network_status(&self) -> OtgNetworkStatus {
|
||||||
let state = self.state.read().await;
|
let state = self.state.read().await;
|
||||||
OtgNetworkStatus {
|
OtgNetworkStatus {
|
||||||
@@ -215,6 +254,7 @@ impl OtgService {
|
|||||||
hid: &HidConfig,
|
hid: &HidConfig,
|
||||||
msd: &MsdConfig,
|
msd: &MsdConfig,
|
||||||
network: &OtgNetworkConfig,
|
network: &OtgNetworkConfig,
|
||||||
|
uac: &UacConfig,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !self.recovery_checked.load(Ordering::SeqCst) {
|
if !self.recovery_checked.load(Ordering::SeqCst) {
|
||||||
if let Err(error) = NetworkBridgeRuntime::recover_stale_transaction() {
|
if let Err(error) = NetworkBridgeRuntime::recover_stale_transaction() {
|
||||||
@@ -226,7 +266,7 @@ impl OtgService {
|
|||||||
}
|
}
|
||||||
let previous = self.desired.read().await.clone();
|
let previous = self.desired.read().await.clone();
|
||||||
let desired = self
|
let desired = self
|
||||||
.desired_from_config_preserving_runtime(hid, msd, network)
|
.desired_from_config_preserving_runtime(hid, msd, network, uac)
|
||||||
.await?;
|
.await?;
|
||||||
{
|
{
|
||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
@@ -269,8 +309,9 @@ impl OtgService {
|
|||||||
hid: &HidConfig,
|
hid: &HidConfig,
|
||||||
msd: &MsdConfig,
|
msd: &MsdConfig,
|
||||||
network: &OtgNetworkConfig,
|
network: &OtgNetworkConfig,
|
||||||
|
uac: &UacConfig,
|
||||||
) -> Result<OtgDesiredState> {
|
) -> Result<OtgDesiredState> {
|
||||||
let mut desired = OtgDesiredState::from_config(hid, msd, network)?;
|
let mut desired = OtgDesiredState::from_config(hid, msd, network, uac)?;
|
||||||
desired.msd_lun_capacity = self.desired.read().await.msd_lun_capacity;
|
desired.msd_lun_capacity = self.desired.read().await.msd_lun_capacity;
|
||||||
Ok(desired)
|
Ok(desired)
|
||||||
}
|
}
|
||||||
@@ -305,10 +346,11 @@ impl OtgService {
|
|||||||
let desired = self.desired.read().await.clone();
|
let desired = self.desired.read().await.clone();
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Reconciling OTG gadget: HID={}, MSD={}, NET={}, UDC={:?}",
|
"Reconciling OTG gadget: HID={}, MSD={}, NET={}, UAC={}, UDC={:?}",
|
||||||
desired.hid_enabled(),
|
desired.hid_enabled(),
|
||||||
desired.msd_enabled,
|
desired.msd_enabled,
|
||||||
desired.network_enabled(),
|
desired.network_enabled(),
|
||||||
|
desired.uac.enabled,
|
||||||
desired.udc
|
desired.udc
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -319,7 +361,9 @@ impl OtgService {
|
|||||||
&& state.hid_enabled == desired.hid_enabled()
|
&& state.hid_enabled == desired.hid_enabled()
|
||||||
&& state.msd_enabled == desired.msd_enabled
|
&& state.msd_enabled == desired.msd_enabled
|
||||||
&& state.msd_lun_capacity == desired.msd_lun_capacity
|
&& state.msd_lun_capacity == desired.msd_lun_capacity
|
||||||
|
&& state.msd_inquiry_strings == desired.msd_inquiry_strings
|
||||||
&& state.network == desired.network
|
&& state.network == desired.network
|
||||||
|
&& state.uac == desired.uac
|
||||||
&& state.configured_udc == desired.udc
|
&& state.configured_udc == desired.udc
|
||||||
&& state.hid_functions == desired.hid_functions
|
&& state.hid_functions == desired.hid_functions
|
||||||
&& state.keyboard_leds_enabled == desired.keyboard_leds
|
&& state.keyboard_leds_enabled == desired.keyboard_leds
|
||||||
@@ -358,7 +402,9 @@ impl OtgService {
|
|||||||
state.hid_enabled = false;
|
state.hid_enabled = false;
|
||||||
state.msd_enabled = false;
|
state.msd_enabled = false;
|
||||||
state.msd_lun_capacity = 1;
|
state.msd_lun_capacity = 1;
|
||||||
|
state.msd_inquiry_strings = MsdInquiryStrings::default();
|
||||||
state.network = OtgNetworkConfig::default();
|
state.network = OtgNetworkConfig::default();
|
||||||
|
state.uac = UacConfig::default();
|
||||||
state.configured_udc = None;
|
state.configured_udc = None;
|
||||||
state.hid_paths = None;
|
state.hid_paths = None;
|
||||||
state.hid_functions = None;
|
state.hid_functions = None;
|
||||||
@@ -367,7 +413,11 @@ impl OtgService {
|
|||||||
state.error = None;
|
state.error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !desired.hid_enabled() && !desired.msd_enabled && !desired.network_enabled() {
|
if !desired.hid_enabled()
|
||||||
|
&& !desired.msd_enabled
|
||||||
|
&& !desired.network_enabled()
|
||||||
|
&& !desired.uac.enabled
|
||||||
|
{
|
||||||
info!("OTG desired state is empty, gadget removed");
|
info!("OTG desired state is empty, gadget removed");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -393,6 +443,20 @@ impl OtgService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut hid_paths = None;
|
let mut hid_paths = None;
|
||||||
|
// Add UAC BEFORE HID so the isochronous endpoint gets a
|
||||||
|
// lower hardware endpoint number. DWC3 seems to have
|
||||||
|
// trouble with isochronous transfers on higher-numbered
|
||||||
|
// endpoints when they follow interrupt endpoints.
|
||||||
|
let _uac_func = if desired.uac.enabled {
|
||||||
|
Some(
|
||||||
|
manager
|
||||||
|
.add_uac(desired.uac.sample_rate, desired.uac.channels)
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to add UAC function: {e}")))?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(hid_functions) = desired.hid_functions.clone() {
|
if let Some(hid_functions) = desired.hid_functions.clone() {
|
||||||
let mut paths = HidDevicePaths {
|
let mut paths = HidDevicePaths {
|
||||||
udc: Some(udc.clone()),
|
udc: Some(udc.clone()),
|
||||||
@@ -449,7 +513,10 @@ impl OtgService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let msd_func = if desired.msd_enabled {
|
let msd_func = if desired.msd_enabled {
|
||||||
match manager.add_msd(desired.msd_lun_capacity) {
|
match manager.add_msd(
|
||||||
|
desired.msd_lun_capacity,
|
||||||
|
desired.msd_inquiry_strings.clone(),
|
||||||
|
) {
|
||||||
Ok(func) => {
|
Ok(func) => {
|
||||||
debug!("MSD function added to gadget");
|
debug!("MSD function added to gadget");
|
||||||
Some(func)
|
Some(func)
|
||||||
@@ -536,7 +603,9 @@ impl OtgService {
|
|||||||
state.hid_enabled = desired.hid_enabled();
|
state.hid_enabled = desired.hid_enabled();
|
||||||
state.msd_enabled = desired.msd_enabled;
|
state.msd_enabled = desired.msd_enabled;
|
||||||
state.msd_lun_capacity = desired.msd_lun_capacity;
|
state.msd_lun_capacity = desired.msd_lun_capacity;
|
||||||
|
state.msd_inquiry_strings = desired.msd_inquiry_strings.clone();
|
||||||
state.network = desired.network.clone();
|
state.network = desired.network.clone();
|
||||||
|
state.uac = desired.uac.clone();
|
||||||
state.configured_udc = Some(udc);
|
state.configured_udc = Some(udc);
|
||||||
state.hid_paths = hid_paths;
|
state.hid_paths = hid_paths;
|
||||||
state.hid_functions = desired.hid_functions;
|
state.hid_functions = desired.hid_functions;
|
||||||
@@ -642,6 +711,7 @@ mod tests {
|
|||||||
&HidConfig::default(),
|
&HidConfig::default(),
|
||||||
&MsdConfig::default(),
|
&MsdConfig::default(),
|
||||||
&OtgNetworkConfig::default(),
|
&OtgNetworkConfig::default(),
|
||||||
|
&UacConfig::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -657,6 +727,14 @@ mod tests {
|
|||||||
assert_ne!(single, multi);
|
assert_ne!(single, multi);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inquiry_strings_participate_in_desired_state_equality() {
|
||||||
|
let original = OtgDesiredState::default();
|
||||||
|
let mut changed = original.clone();
|
||||||
|
changed.msd_inquiry_strings.flash = "Custom Flash".to_string();
|
||||||
|
assert_ne!(original, changed);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn onecloud_full_composite_is_not_rejected_before_configfs() {
|
fn onecloud_full_composite_is_not_rejected_before_configfs() {
|
||||||
let hid = HidConfig {
|
let hid = HidConfig {
|
||||||
@@ -673,7 +751,8 @@ mod tests {
|
|||||||
..OtgNetworkConfig::default()
|
..OtgNetworkConfig::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let desired = OtgDesiredState::from_config(&hid, &msd, &network).unwrap();
|
let desired =
|
||||||
|
OtgDesiredState::from_config(&hid, &msd, &network, &UacConfig::default()).unwrap();
|
||||||
|
|
||||||
assert_eq!(desired.udc.as_deref(), Some("c9040000.usb"));
|
assert_eq!(desired.udc.as_deref(), Some("c9040000.usb"));
|
||||||
assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full()));
|
assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full()));
|
||||||
|
|||||||
153
src/otg/uac.rs
Normal file
153
src/otg/uac.rs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use super::configfs::{create_dir, create_symlink, remove_dir, write_file};
|
||||||
|
use super::function::GadgetFunction;
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
/// USB Audio Class 1.0 gadget function.
|
||||||
|
///
|
||||||
|
/// Creates a virtual USB microphone that the USB host sees as a standard
|
||||||
|
/// USB audio input device. Audio written to the PCM playback device on the
|
||||||
|
/// gadget side appears as microphone input on the host.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UacFunction {
|
||||||
|
name: String,
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UacFunction {
|
||||||
|
/// Create a new UAC1 function instance.
|
||||||
|
///
|
||||||
|
/// `instance` is a zero-based index to avoid name collisions
|
||||||
|
/// (e.g. `uac2.usb0`).
|
||||||
|
pub fn new(instance: u8, sample_rate: u32, channels: u8) -> Result<Self> {
|
||||||
|
if sample_rate == 0 || sample_rate > 384_000 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"invalid UAC sample rate: {sample_rate}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if channels == 0 || channels > 8 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"invalid UAC channel count: {channels}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
name: format!("uac1.usb{instance}"),
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn function_path(&self, gadget_path: &Path) -> PathBuf {
|
||||||
|
gadget_path.join("functions").join(&self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GadgetFunction for UacFunction {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(&self, gadget_path: &Path) -> Result<()> {
|
||||||
|
let func_path = self.function_path(gadget_path);
|
||||||
|
create_dir(&func_path)?;
|
||||||
|
|
||||||
|
// Playback direction (gadget → host): the controlled machine sees
|
||||||
|
// this as a microphone/line-in input.
|
||||||
|
let chmask: u32 = (1u32 << self.channels) - 1;
|
||||||
|
write_file(&func_path.join("p_chmask"), &chmask.to_string())?;
|
||||||
|
write_file(&func_path.join("p_srate"), &self.sample_rate.to_string())?;
|
||||||
|
// 16-bit S16LE.
|
||||||
|
write_file(&func_path.join("p_ssize"), "2")?;
|
||||||
|
// One decibel per step. The kernel default is 1/256 dB, which creates
|
||||||
|
// 25,600 control values and triggers a UAC volume-range warning.
|
||||||
|
write_file(&func_path.join("p_volume_res"), "256")?;
|
||||||
|
// UAC1 does not need p_hs_bint — Windows has native built-in
|
||||||
|
// UAC1 drivers and handles isochronous streaming automatically.
|
||||||
|
|
||||||
|
// Only enable playback direction (gadget → host = mic).
|
||||||
|
// Disabling capture saves one isochronous endpoint.
|
||||||
|
write_file(&func_path.join("c_chmask"), "0")?;
|
||||||
|
write_file(&func_path.join("c_volume_present"), "0")?;
|
||||||
|
|
||||||
|
// req_number=4: explicitly allocate 4 USB requests for the
|
||||||
|
// isochronous endpoint. Default (0 = auto) may not be enough
|
||||||
|
// for composite gadgets on DWC3.
|
||||||
|
let _ = write_file(&func_path.join("req_number"), "4");
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"UAC1 function {} created: {}ch {}Hz",
|
||||||
|
&self.name, self.channels, self.sample_rate
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link(&self, config_path: &Path, gadget_path: &Path) -> Result<()> {
|
||||||
|
let func_path = self.function_path(gadget_path);
|
||||||
|
let link_path = config_path.join(&self.name);
|
||||||
|
create_symlink(&func_path, &link_path)?;
|
||||||
|
debug!("UAC1 function {} linked into configuration", &self.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlink(&self, config_path: &Path) -> Result<()> {
|
||||||
|
let link_path = config_path.join(&self.name);
|
||||||
|
if link_path.exists() {
|
||||||
|
std::fs::remove_file(&link_path).map_err(|e| {
|
||||||
|
AppError::Internal(format!(
|
||||||
|
"Failed to unlink UAC1 function {}: {}",
|
||||||
|
&self.name, e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
debug!("UAC1 function {} unlinked", &self.name);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup(&self, gadget_path: &Path) -> Result<()> {
|
||||||
|
let func_path = self.function_path(gadget_path);
|
||||||
|
if func_path.exists() {
|
||||||
|
remove_dir(&func_path).map_err(|e| {
|
||||||
|
AppError::Internal(format!(
|
||||||
|
"Failed to remove UAC1 function {}: {}",
|
||||||
|
&self.name, e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
info!("UAC1 function {} removed", &self.name);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the ALSA PCM device name that the kernel assigns to a UAC1
|
||||||
|
/// gadget after binding. The device appears as a playback-only PCM on
|
||||||
|
/// the gadget side.
|
||||||
|
pub fn uac_pcm_device() -> String {
|
||||||
|
// The kernel assigns the card name based on the gadget name.
|
||||||
|
// The PCM name is typically "playback" for UAC1.
|
||||||
|
"hw:UAC1Gadget,0".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the actual PCM device name for a UAC1 playback device
|
||||||
|
/// by scanning /proc/asound/ for the gadget audio card.
|
||||||
|
pub fn find_uac_pcm_device() -> Option<String> {
|
||||||
|
for entry in std::fs::read_dir("/proc/asound").ok()? {
|
||||||
|
let entry = entry.ok()?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_str()?;
|
||||||
|
if !name.starts_with("card") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let card_path = entry.path().join("id");
|
||||||
|
if let Ok(id) = std::fs::read_to_string(&card_path) {
|
||||||
|
if id.trim().starts_with("UAC1Gadget") || id.trim().starts_with("gadget") {
|
||||||
|
let card_num = name.strip_prefix("card")?;
|
||||||
|
return Some(format!("hw:{card_num},0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -88,10 +88,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn only_service_discovery_and_session_creation_are_public() {
|
fn only_service_discovery_and_session_creation_are_public() {
|
||||||
assert!(is_redfish_public_endpoint("/v1/", &Method::GET));
|
assert!(is_redfish_public_endpoint("/v1/", &Method::GET));
|
||||||
assert!(is_redfish_public_endpoint(
|
assert!(is_redfish_public_endpoint("/v1/$metadata", &Method::GET));
|
||||||
"/v1/$metadata",
|
|
||||||
&Method::GET
|
|
||||||
));
|
|
||||||
assert!(is_redfish_public_endpoint(
|
assert!(is_redfish_public_endpoint(
|
||||||
"/v1/SessionService/Sessions",
|
"/v1/SessionService/Sessions",
|
||||||
&Method::POST
|
&Method::POST
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use std::sync::Arc;
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use super::super::schema::*;
|
use super::super::schema::*;
|
||||||
use super::{empty_collection, resource_not_found, service_unavailable, validate_id};
|
use super::{empty_collection, resource_not_found, validate_id};
|
||||||
use crate::error::AppError;
|
use crate::error::{AppError, MsdErrorCode};
|
||||||
use crate::msd::{ImageInfo, ImageManager, MountedMedia, MountedMediaKind};
|
use crate::msd::{ImageInfo, ImageManager, MountedMedia, MountedMediaKind};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ async fn virtual_media_collection(
|
|||||||
let capacity = {
|
let capacity = {
|
||||||
let guard = state.msd.read().await;
|
let guard = state.msd.read().await;
|
||||||
let Some(msd) = guard.as_ref() else {
|
let Some(msd) = guard.as_ref() else {
|
||||||
return service_unavailable("MSD not available");
|
return msd_error_response(MsdErrorCode::MsdUnavailable);
|
||||||
};
|
};
|
||||||
msd.state().await.disk_mode.capacity()
|
msd.state().await.disk_mode.capacity()
|
||||||
};
|
};
|
||||||
@@ -81,7 +81,7 @@ async fn virtual_media_detail(
|
|||||||
let (msd_state, lun) = {
|
let (msd_state, lun) = {
|
||||||
let guard = state.msd.read().await;
|
let guard = state.msd.read().await;
|
||||||
let Some(msd) = guard.as_ref() else {
|
let Some(msd) = guard.as_ref() else {
|
||||||
return service_unavailable("MSD not available");
|
return msd_error_response(MsdErrorCode::MsdUnavailable);
|
||||||
};
|
};
|
||||||
let msd_state = msd.state().await;
|
let msd_state = msd.state().await;
|
||||||
let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else {
|
let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else {
|
||||||
@@ -164,17 +164,14 @@ async fn virtual_media_insert(
|
|||||||
let lun = {
|
let lun = {
|
||||||
let guard = state.msd.read().await;
|
let guard = state.msd.read().await;
|
||||||
let Some(msd) = guard.as_ref() else {
|
let Some(msd) = guard.as_ref() else {
|
||||||
return service_unavailable("MSD not available");
|
return msd_error_response(MsdErrorCode::MsdUnavailable);
|
||||||
};
|
};
|
||||||
let msd_state = msd.state().await;
|
let msd_state = msd.state().await;
|
||||||
let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else {
|
let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else {
|
||||||
return resource_not_found();
|
return resource_not_found();
|
||||||
};
|
};
|
||||||
if msd_state.mounted_media.iter().any(|media| media.lun == lun) {
|
if msd_state.mounted_media.iter().any(|media| media.lun == lun) {
|
||||||
return redfish_error(
|
return msd_error_response(MsdErrorCode::MsdMediaSlotsFull);
|
||||||
StatusCode::CONFLICT,
|
|
||||||
"Virtual media slot is already occupied",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
lun
|
lun
|
||||||
};
|
};
|
||||||
@@ -194,7 +191,7 @@ async fn virtual_media_insert(
|
|||||||
let result = {
|
let result = {
|
||||||
let guard = state.msd.read().await;
|
let guard = state.msd.read().await;
|
||||||
let Some(msd) = guard.as_ref() else {
|
let Some(msd) = guard.as_ref() else {
|
||||||
return service_unavailable("MSD not available");
|
return msd_error_response(MsdErrorCode::MsdUnavailable);
|
||||||
};
|
};
|
||||||
msd.mount_image_at_lun(&image, cdrom, read_only, lun).await
|
msd.mount_image_at_lun(&image, cdrom, read_only, lun).await
|
||||||
};
|
};
|
||||||
@@ -222,7 +219,7 @@ async fn virtual_media_eject(
|
|||||||
let lun = {
|
let lun = {
|
||||||
let guard = state.msd.read().await;
|
let guard = state.msd.read().await;
|
||||||
let Some(msd) = guard.as_ref() else {
|
let Some(msd) = guard.as_ref() else {
|
||||||
return service_unavailable("MSD not available");
|
return msd_error_response(MsdErrorCode::MsdUnavailable);
|
||||||
};
|
};
|
||||||
let capacity = msd.state().await.disk_mode.capacity();
|
let capacity = msd.state().await.disk_mode.capacity();
|
||||||
let Some(lun) = parse_slot_id(&media_id, capacity) else {
|
let Some(lun) = parse_slot_id(&media_id, capacity) else {
|
||||||
@@ -234,7 +231,7 @@ async fn virtual_media_eject(
|
|||||||
let result = {
|
let result = {
|
||||||
let guard = state.msd.read().await;
|
let guard = state.msd.read().await;
|
||||||
let Some(msd) = guard.as_ref() else {
|
let Some(msd) = guard.as_ref() else {
|
||||||
return service_unavailable("MSD not available");
|
return msd_error_response(MsdErrorCode::MsdUnavailable);
|
||||||
};
|
};
|
||||||
msd.unmount_lun(lun).await
|
msd.unmount_lun(lun).await
|
||||||
};
|
};
|
||||||
@@ -352,6 +349,9 @@ async fn resolve_image(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn app_error_response(error: AppError) -> Response {
|
fn app_error_response(error: AppError) -> Response {
|
||||||
|
if let AppError::Msd(error) = error {
|
||||||
|
return msd_error_response(error.code());
|
||||||
|
}
|
||||||
let status = match &error {
|
let status = match &error {
|
||||||
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
@@ -361,6 +361,35 @@ fn app_error_response(error: AppError) -> Response {
|
|||||||
redfish_error(status, &error.to_string())
|
redfish_error(status, &error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn msd_error_response(code: MsdErrorCode) -> Response {
|
||||||
|
use MsdErrorCode::*;
|
||||||
|
let status = match code {
|
||||||
|
MsdUnavailable => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
MsdResourceNotFound | MsdDriveNotInitialized => StatusCode::NOT_FOUND,
|
||||||
|
MsdOperationInProgress
|
||||||
|
| MsdResourceAlreadyExists
|
||||||
|
| MsdMediaSlotsFull
|
||||||
|
| MsdMediaAlreadyMounted
|
||||||
|
| MsdMediaInUse
|
||||||
|
| MsdDriveConnected
|
||||||
|
| MsdMediumRemovalPrevented => StatusCode::CONFLICT,
|
||||||
|
MsdInvalidRequest
|
||||||
|
| MsdImageTooLarge
|
||||||
|
| MsdInvalidUrl
|
||||||
|
| MsdDriveFilesystemUnsupported
|
||||||
|
| MsdDriveSizeInvalid
|
||||||
|
| MsdStorageSpaceUnavailable
|
||||||
|
| MsdStorageFull
|
||||||
|
| MsdStorageReadOnly
|
||||||
|
| MsdStoragePermissionDenied => StatusCode::BAD_REQUEST,
|
||||||
|
MsdOperationFailed
|
||||||
|
| MsdRemoteDownloadFailed
|
||||||
|
| MsdDownloadIncomplete
|
||||||
|
| MsdDisconnectFailed => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
};
|
||||||
|
(status, Json(RedfishError::msd(code))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
fn redfish_error(status: StatusCode, message: &str) -> Response {
|
fn redfish_error(status: StatusCode, message: &str) -> Response {
|
||||||
(status, Json(RedfishError::general_error(message))).into_response()
|
(status, Json(RedfishError::general_error(message))).into_response()
|
||||||
}
|
}
|
||||||
@@ -421,4 +450,45 @@ mod tests {
|
|||||||
not_inserted.inserted = Some(false);
|
not_inserted.inserted = Some(false);
|
||||||
assert!(validate_insert_request(¬_inserted).is_err());
|
assert!(validate_insert_request(¬_inserted).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn msd_redfish_errors_use_the_one_kvm_registry_shape() {
|
||||||
|
for code in MsdErrorCode::ALL {
|
||||||
|
let body = RedfishError::msd(code);
|
||||||
|
let expected = format!("OneKVM.1.0.{}", code.redfish_key());
|
||||||
|
assert_eq!(body.error.code, expected);
|
||||||
|
assert_eq!(body.error.message, code.message());
|
||||||
|
assert_eq!(body.error.extended_info.len(), 1);
|
||||||
|
let info = &body.error.extended_info[0];
|
||||||
|
assert_eq!(info.message_id, expected);
|
||||||
|
assert_eq!(info.message, code.message());
|
||||||
|
assert_eq!(info.severity, code.severity());
|
||||||
|
assert_eq!(info.resolution, code.resolution());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn msd_and_validation_errors_keep_separate_redfish_registries() {
|
||||||
|
let response = msd_error_response(MsdErrorCode::MsdStoragePermissionDenied);
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
json["error"]["code"],
|
||||||
|
"OneKVM.1.0.MsdStoragePermissionDenied"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
json["error"]["@Message.ExtendedInfo"][0]["MessageId"],
|
||||||
|
"OneKVM.1.0.MsdStoragePermissionDenied"
|
||||||
|
);
|
||||||
|
|
||||||
|
let validation = app_error_response(AppError::BadRequest("invalid property".into()));
|
||||||
|
let body = axum::body::to_bytes(validation.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(json["error"]["code"], "Base.1.18.GeneralError");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::error::MsdErrorCode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -546,6 +547,23 @@ pub struct RedfishExtendedInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RedfishError {
|
impl RedfishError {
|
||||||
|
pub fn msd(code: MsdErrorCode) -> Self {
|
||||||
|
let message_id = format!("OneKVM.1.0.{}", code.redfish_key());
|
||||||
|
Self {
|
||||||
|
error: RedfishErrorBody {
|
||||||
|
code: message_id.clone(),
|
||||||
|
message: code.message().to_string(),
|
||||||
|
extended_info: vec![RedfishExtendedInfo {
|
||||||
|
odata_type: "#Message.v1_2_1.Message".to_string(),
|
||||||
|
message_id,
|
||||||
|
message: code.message().to_string(),
|
||||||
|
severity: code.severity().to_string(),
|
||||||
|
resolution: code.resolution().to_string(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn general_error(message: &str) -> Self {
|
pub fn general_error(message: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
error: RedfishErrorBody {
|
error: RedfishErrorBody {
|
||||||
|
|||||||
@@ -276,7 +276,6 @@ impl RustDeskService {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
let status = self.status.clone();
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match mediator.start().await {
|
match mediator.start().await {
|
||||||
@@ -286,9 +285,7 @@ impl RustDeskService {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Rendezvous mediator error: {}", e);
|
error!("Rendezvous mediator error: {}", e);
|
||||||
*status.write() = ServiceStatus::Error(e.to_string());
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
*status.write() = ServiceStatus::Starting;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ use super::protocol::{
|
|||||||
rendezvous_message, NatType, RendezvousMessage,
|
rendezvous_message, NatType, RendezvousMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
const REG_INTERVAL_MS: u64 = 12_000;
|
const REG_INTERVAL: Duration = Duration::from_secs(12);
|
||||||
|
|
||||||
const MIN_REG_TIMEOUT_MS: u64 = 3_000;
|
const MIN_REG_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
const MAX_REG_TIMEOUT_MS: u64 = 30_000;
|
const MAX_REG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
const OFFLINE_AFTER_TIMEOUTS: u32 = 4;
|
||||||
|
|
||||||
const TIMER_INTERVAL_MS: u64 = 300;
|
const TIMER_INTERVAL_MS: u64 = 300;
|
||||||
|
|
||||||
@@ -32,7 +34,6 @@ const TIMER_INTERVAL_MS: u64 = 300;
|
|||||||
pub enum RendezvousStatus {
|
pub enum RendezvousStatus {
|
||||||
Disconnected,
|
Disconnected,
|
||||||
Connecting,
|
Connecting,
|
||||||
Connected,
|
|
||||||
Registered,
|
Registered,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -42,13 +43,86 @@ impl std::fmt::Display for RendezvousStatus {
|
|||||||
match self {
|
match self {
|
||||||
Self::Disconnected => write!(f, "disconnected"),
|
Self::Disconnected => write!(f, "disconnected"),
|
||||||
Self::Connecting => write!(f, "connecting"),
|
Self::Connecting => write!(f, "connecting"),
|
||||||
Self::Connected => write!(f, "connected"),
|
|
||||||
Self::Registered => write!(f, "registered"),
|
Self::Registered => write!(f, "registered"),
|
||||||
Self::Error(e) => write!(f, "error: {}", e),
|
Self::Error(e) => write!(f, "error: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum RegistrationDecision {
|
||||||
|
Wait,
|
||||||
|
Send,
|
||||||
|
Retry { consecutive_timeouts: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks the request/response lifecycle for HBBS registration.
|
||||||
|
///
|
||||||
|
/// UDP `connect` only selects a peer; it does not prove reachability. Registration
|
||||||
|
/// health is therefore derived exclusively from acknowledged registration requests.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct RegistrationTracker {
|
||||||
|
last_sent: Option<Instant>,
|
||||||
|
last_response: Option<Instant>,
|
||||||
|
response_timeout: Duration,
|
||||||
|
consecutive_timeouts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegistrationTracker {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
last_sent: None,
|
||||||
|
last_response: None,
|
||||||
|
response_timeout: MIN_REG_TIMEOUT,
|
||||||
|
consecutive_timeouts: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll(&mut self, now: Instant) -> RegistrationDecision {
|
||||||
|
if let Some(sent_at) = self.last_sent {
|
||||||
|
if now.saturating_duration_since(sent_at) < self.response_timeout {
|
||||||
|
return RegistrationDecision::Wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.consecutive_timeouts = self.consecutive_timeouts.saturating_add(1);
|
||||||
|
self.response_timeout = (self.response_timeout + MIN_REG_TIMEOUT).min(MAX_REG_TIMEOUT);
|
||||||
|
return RegistrationDecision::Retry {
|
||||||
|
consecutive_timeouts: self.consecutive_timeouts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let registration_expired = self
|
||||||
|
.last_response
|
||||||
|
.map(|response_at| now.saturating_duration_since(response_at) >= REG_INTERVAL)
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if registration_expired {
|
||||||
|
RegistrationDecision::Send
|
||||||
|
} else {
|
||||||
|
RegistrationDecision::Wait
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_sent(&mut self, now: Instant) {
|
||||||
|
self.last_sent = Some(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_response(&mut self, now: Instant) {
|
||||||
|
self.last_sent = None;
|
||||||
|
self.last_response = Some(now);
|
||||||
|
self.response_timeout = MIN_REG_TIMEOUT;
|
||||||
|
self.consecutive_timeouts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_after_timeout(consecutive_timeouts: u32) -> RendezvousStatus {
|
||||||
|
if consecutive_timeouts >= OFFLINE_AFTER_TIMEOUTS {
|
||||||
|
RendezvousStatus::Disconnected
|
||||||
|
} else {
|
||||||
|
RendezvousStatus::Connecting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type RelayCallback = Arc<dyn Fn(String, String, String, Vec<u8>, String) + Send + Sync>;
|
pub type RelayCallback = Arc<dyn Fn(String, String, String, Vec<u8>, String) + Send + Sync>;
|
||||||
|
|
||||||
pub type PunchCallback =
|
pub type PunchCallback =
|
||||||
@@ -147,6 +221,14 @@ impl RendezvousMediator {
|
|||||||
self.status.read().clone()
|
self.status.read().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_status(&self, next: RendezvousStatus) {
|
||||||
|
let mut current = self.status.write();
|
||||||
|
if *current != next {
|
||||||
|
info!("Rendezvous status changed: {} -> {}", *current, next);
|
||||||
|
*current = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_config(&self, config: RustDeskConfig) {
|
pub fn update_config(&self, config: RustDeskConfig) {
|
||||||
*self.config.write() = config;
|
*self.config.write() = config;
|
||||||
self.increment_serial();
|
self.increment_serial();
|
||||||
@@ -209,11 +291,21 @@ impl RendezvousMediator {
|
|||||||
"Rendezvous mediator not starting: enabled={}, server='{}'",
|
"Rendezvous mediator not starting: enabled={}, server='{}'",
|
||||||
config.enabled, effective_server
|
config.enabled, effective_server
|
||||||
);
|
);
|
||||||
|
self.set_status(RendezvousStatus::Disconnected);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
*self.status.write() = RendezvousStatus::Connecting;
|
self.set_status(RendezvousStatus::Connecting);
|
||||||
|
|
||||||
|
let result = self.run(config).await;
|
||||||
|
match &result {
|
||||||
|
Ok(()) => self.set_status(RendezvousStatus::Disconnected),
|
||||||
|
Err(err) => self.set_status(RendezvousStatus::Error(err.to_string())),
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, config: RustDeskConfig) -> anyhow::Result<()> {
|
||||||
let addr = config.rendezvous_addr();
|
let addr = config.rendezvous_addr();
|
||||||
info!(
|
info!(
|
||||||
"Starting rendezvous mediator for {} to {}",
|
"Starting rendezvous mediator for {} to {}",
|
||||||
@@ -233,8 +325,7 @@ impl RendezvousMediator {
|
|||||||
let socket = UdpSocket::from_std(std_socket)?;
|
let socket = UdpSocket::from_std(std_socket)?;
|
||||||
socket.connect(server_addr).await?;
|
socket.connect(server_addr).await?;
|
||||||
|
|
||||||
info!("Connected to rendezvous server at {}", server_addr);
|
info!("RustDesk UDP transport ready for {}", server_addr);
|
||||||
*self.status.write() = RendezvousStatus::Connected;
|
|
||||||
|
|
||||||
self.registration_loop(socket).await
|
self.registration_loop(socket).await
|
||||||
}
|
}
|
||||||
@@ -242,10 +333,7 @@ impl RendezvousMediator {
|
|||||||
async fn registration_loop(&self, socket: UdpSocket) -> anyhow::Result<()> {
|
async fn registration_loop(&self, socket: UdpSocket) -> anyhow::Result<()> {
|
||||||
let mut timer = interval(Duration::from_millis(TIMER_INTERVAL_MS));
|
let mut timer = interval(Duration::from_millis(TIMER_INTERVAL_MS));
|
||||||
let mut recv_buf = vec![0u8; 65535];
|
let mut recv_buf = vec![0u8; 65535];
|
||||||
let mut last_register_sent: Option<Instant> = None;
|
let mut registration = RegistrationTracker::new();
|
||||||
let mut last_register_resp: Option<Instant> = None;
|
|
||||||
let mut reg_timeout = MIN_REG_TIMEOUT_MS;
|
|
||||||
let mut fails = 0;
|
|
||||||
let mut shutdown_rx = self.shutdown_tx.subscribe();
|
let mut shutdown_rx = self.shutdown_tx.subscribe();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -254,39 +342,36 @@ impl RendezvousMediator {
|
|||||||
match result {
|
match result {
|
||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
if let Ok(msg) = decode_rendezvous_message(&recv_buf[..len]) {
|
if let Ok(msg) = decode_rendezvous_message(&recv_buf[..len]) {
|
||||||
self.handle_response(&socket, msg, &mut last_register_resp, &mut fails, &mut reg_timeout).await?;
|
self.handle_response(&socket, msg, &mut registration).await?;
|
||||||
} else {
|
} else {
|
||||||
debug!("Failed to decode rendezvous message");
|
debug!("Failed to decode rendezvous message");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to receive from socket: {}", e);
|
return Err(anyhow::anyhow!("Failed to receive from socket: {}", e));
|
||||||
*self.status.write() = RendezvousStatus::Error(e.to_string());
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = timer.tick() => {
|
_ = timer.tick() => {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let expired = last_register_resp
|
match registration.poll(now) {
|
||||||
.map(|x| x.elapsed().as_millis() as u64 >= REG_INTERVAL_MS)
|
RegistrationDecision::Wait => {}
|
||||||
.unwrap_or(true);
|
RegistrationDecision::Send => {
|
||||||
let timeout = last_register_sent
|
|
||||||
.map(|x| x.elapsed().as_millis() as u64 >= reg_timeout)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if timeout && reg_timeout < MAX_REG_TIMEOUT_MS {
|
|
||||||
reg_timeout += MIN_REG_TIMEOUT_MS;
|
|
||||||
fails += 1;
|
|
||||||
if fails >= 4 {
|
|
||||||
warn!("Registration timeout, {} consecutive failures", fails);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if timeout || (last_register_sent.is_none() && expired) {
|
|
||||||
self.send_register(&socket).await?;
|
self.send_register(&socket).await?;
|
||||||
last_register_sent = Some(now);
|
registration.mark_sent(now);
|
||||||
|
}
|
||||||
|
RegistrationDecision::Retry { consecutive_timeouts } => {
|
||||||
|
let next_status =
|
||||||
|
RegistrationTracker::status_after_timeout(consecutive_timeouts);
|
||||||
|
self.set_status(next_status);
|
||||||
|
warn!(
|
||||||
|
"RustDesk registration timed out ({} consecutive timeouts)",
|
||||||
|
consecutive_timeouts
|
||||||
|
);
|
||||||
|
self.send_register(&socket).await?;
|
||||||
|
registration.mark_sent(now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +382,6 @@ impl RendezvousMediator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*self.status.write() = RendezvousStatus::Disconnected;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,48 +468,49 @@ impl RendezvousMediator {
|
|||||||
&self,
|
&self,
|
||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
msg: RendezvousMessage,
|
msg: RendezvousMessage,
|
||||||
last_resp: &mut Option<Instant>,
|
registration: &mut RegistrationTracker,
|
||||||
fails: &mut i32,
|
|
||||||
reg_timeout: &mut u64,
|
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
*last_resp = Some(Instant::now());
|
|
||||||
*fails = 0;
|
|
||||||
*reg_timeout = MIN_REG_TIMEOUT_MS;
|
|
||||||
|
|
||||||
match msg.union {
|
match msg.union {
|
||||||
Some(rendezvous_message::Union::RegisterPeerResponse(rpr)) => {
|
Some(rendezvous_message::Union::RegisterPeerResponse(rpr)) => {
|
||||||
|
registration.mark_response(Instant::now());
|
||||||
if rpr.request_pk {
|
if rpr.request_pk {
|
||||||
info!("Server requested public key registration");
|
info!("Server requested public key registration");
|
||||||
*self.key_confirmed.write() = false;
|
*self.key_confirmed.write() = false;
|
||||||
|
self.set_status(RendezvousStatus::Connecting);
|
||||||
self.send_register_pk(socket).await?;
|
self.send_register_pk(socket).await?;
|
||||||
|
registration.mark_sent(Instant::now());
|
||||||
|
} else {
|
||||||
|
self.set_status(RendezvousStatus::Registered);
|
||||||
}
|
}
|
||||||
*self.status.write() = RendezvousStatus::Registered;
|
|
||||||
}
|
}
|
||||||
Some(rendezvous_message::Union::RegisterPkResponse(rpr)) => {
|
Some(rendezvous_message::Union::RegisterPkResponse(rpr)) => {
|
||||||
|
registration.mark_response(Instant::now());
|
||||||
info!("Received RegisterPkResponse: result={:?}", rpr.result);
|
info!("Received RegisterPkResponse: result={:?}", rpr.result);
|
||||||
match rpr.result.value() {
|
match rpr.result.value() {
|
||||||
0 => {
|
0 => {
|
||||||
info!("✓ Public key registered successfully with server");
|
info!("✓ Public key registered successfully with server");
|
||||||
*self.key_confirmed.write() = true;
|
*self.key_confirmed.write() = true;
|
||||||
self.increment_serial();
|
self.increment_serial();
|
||||||
*self.status.write() = RendezvousStatus::Registered;
|
self.set_status(RendezvousStatus::Registered);
|
||||||
}
|
}
|
||||||
2 => {
|
2 => {
|
||||||
warn!("UUID mismatch, need to re-register");
|
warn!("UUID mismatch, need to re-register");
|
||||||
*self.key_confirmed.write() = false;
|
*self.key_confirmed.write() = false;
|
||||||
|
self.set_status(RendezvousStatus::Connecting);
|
||||||
}
|
}
|
||||||
3 => {
|
3 => {
|
||||||
error!("Device ID already exists on server");
|
error!("Device ID already exists on server");
|
||||||
*self.status.write() =
|
self.set_status(RendezvousStatus::Error(
|
||||||
RendezvousStatus::Error("Device ID already exists".to_string());
|
"Device ID already exists".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
4 => {
|
4 => {
|
||||||
warn!("Registration too frequent");
|
warn!("Registration too frequent");
|
||||||
|
self.set_status(RendezvousStatus::Connecting);
|
||||||
}
|
}
|
||||||
5 => {
|
5 => {
|
||||||
error!("Invalid device ID format");
|
error!("Invalid device ID format");
|
||||||
*self.status.write() =
|
self.set_status(RendezvousStatus::Error("Invalid ID format".to_string()));
|
||||||
RendezvousStatus::Error("Invalid ID format".to_string());
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
error!("Unknown RegisterPkResponse result: {:?}", rpr.result);
|
error!("Unknown RegisterPkResponse result: {:?}", rpr.result);
|
||||||
@@ -797,7 +882,111 @@ fn get_local_addresses() -> Vec<std::net::IpAddr> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{normalize_relay_server, select_relay_server};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
normalize_relay_server, select_relay_server, RegistrationDecision, RegistrationTracker,
|
||||||
|
RendezvousStatus, REG_INTERVAL,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_tracker_requires_an_acknowledged_response() {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let mut tracker = RegistrationTracker::new();
|
||||||
|
|
||||||
|
assert_eq!(tracker.poll(started_at), RegistrationDecision::Send);
|
||||||
|
tracker.mark_sent(started_at);
|
||||||
|
assert_eq!(
|
||||||
|
tracker.poll(started_at + Duration::from_secs(2)),
|
||||||
|
RegistrationDecision::Wait
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tracker.poll(started_at + Duration::from_secs(3)),
|
||||||
|
RegistrationDecision::Retry {
|
||||||
|
consecutive_timeouts: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
RegistrationTracker::status_after_timeout(1),
|
||||||
|
RendezvousStatus::Connecting
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_tracker_marks_four_timeouts_offline() {
|
||||||
|
let mut now = Instant::now();
|
||||||
|
let mut tracker = RegistrationTracker::new();
|
||||||
|
|
||||||
|
assert_eq!(tracker.poll(now), RegistrationDecision::Send);
|
||||||
|
tracker.mark_sent(now);
|
||||||
|
|
||||||
|
for (failure, timeout) in [(1, 3), (2, 6), (3, 9), (4, 12)] {
|
||||||
|
now += Duration::from_secs(timeout);
|
||||||
|
assert_eq!(
|
||||||
|
tracker.poll(now),
|
||||||
|
RegistrationDecision::Retry {
|
||||||
|
consecutive_timeouts: failure
|
||||||
|
}
|
||||||
|
);
|
||||||
|
tracker.mark_sent(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
RegistrationTracker::status_after_timeout(4),
|
||||||
|
RendezvousStatus::Disconnected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_response_clears_in_flight_retry_state() {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let mut tracker = RegistrationTracker::new();
|
||||||
|
|
||||||
|
tracker.mark_sent(started_at);
|
||||||
|
let retry_at = started_at + Duration::from_secs(3);
|
||||||
|
assert_eq!(
|
||||||
|
tracker.poll(retry_at),
|
||||||
|
RegistrationDecision::Retry {
|
||||||
|
consecutive_timeouts: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
tracker.mark_sent(retry_at);
|
||||||
|
|
||||||
|
let response_at = retry_at + Duration::from_millis(100);
|
||||||
|
tracker.mark_response(response_at);
|
||||||
|
assert_eq!(tracker.last_sent, None);
|
||||||
|
assert_eq!(tracker.consecutive_timeouts, 0);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tracker.poll(response_at + REG_INTERVAL - Duration::from_millis(1)),
|
||||||
|
RegistrationDecision::Wait
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tracker.poll(response_at + REG_INTERVAL),
|
||||||
|
RegistrationDecision::Send
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_timeout_count_continues_after_backoff_reaches_its_cap() {
|
||||||
|
let mut now = Instant::now();
|
||||||
|
let mut tracker = RegistrationTracker::new();
|
||||||
|
|
||||||
|
tracker.mark_sent(now);
|
||||||
|
for failure in 1..=12 {
|
||||||
|
now += tracker.response_timeout;
|
||||||
|
assert_eq!(
|
||||||
|
tracker.poll(now),
|
||||||
|
RegistrationDecision::Retry {
|
||||||
|
consecutive_timeouts: failure
|
||||||
|
}
|
||||||
|
);
|
||||||
|
tracker.mark_sent(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(tracker.response_timeout, Duration::from_secs(30));
|
||||||
|
assert_eq!(tracker.consecutive_timeouts, 12);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_relay_server() {
|
fn test_normalize_relay_server() {
|
||||||
|
|||||||
10
src/state.rs
10
src/state.rs
@@ -7,9 +7,11 @@ use crate::auth::{SessionStore, TwoFactorService, UserStore};
|
|||||||
use crate::computer_use::ComputerUseManager;
|
use crate::computer_use::ComputerUseManager;
|
||||||
use crate::config::ConfigStore;
|
use crate::config::ConfigStore;
|
||||||
use crate::db::DatabasePool;
|
use crate::db::DatabasePool;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use crate::events::MsdDeviceMediaInfo;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo,
|
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo, SystemEvent,
|
||||||
MsdDeviceMediaInfo, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
|
TtydDeviceInfo, VideoDeviceInfo,
|
||||||
};
|
};
|
||||||
use crate::extensions::{ExtensionId, ExtensionManager};
|
use crate::extensions::{ExtensionId, ExtensionManager};
|
||||||
use crate::hid::HidController;
|
use crate::hid::HidController;
|
||||||
@@ -77,6 +79,8 @@ pub struct AppState {
|
|||||||
pub msd: Arc<RwLock<Option<MsdController>>>,
|
pub msd: Arc<RwLock<Option<MsdController>>>,
|
||||||
pub atx: Arc<RwLock<Option<AtxController>>>,
|
pub atx: Arc<RwLock<Option<AtxController>>>,
|
||||||
pub audio: Arc<AudioController>,
|
pub audio: Arc<AudioController>,
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub uac_playback: Arc<RwLock<Option<crate::audio::uac::UacPlayback>>>,
|
||||||
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
|
pub rustdesk: Arc<RwLock<Option<Arc<RustDeskService>>>>,
|
||||||
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
|
pub vnc: Arc<RwLock<Option<Arc<VncService>>>>,
|
||||||
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
|
pub rtsp: Arc<RwLock<Option<Arc<RtspService>>>>,
|
||||||
@@ -146,6 +150,8 @@ impl AppState {
|
|||||||
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
||||||
config_apply_locks: ConfigApplyLocks::new(),
|
config_apply_locks: ConfigApplyLocks::new(),
|
||||||
data_dir,
|
data_dir,
|
||||||
|
#[cfg(unix)]
|
||||||
|
uac_playback: Arc::new(RwLock::new(None)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,5 +14,29 @@ pub fn encoder_type_to_backend(encoder: EncoderType) -> Option<EncoderBackend> {
|
|||||||
EncoderType::Amf => Some(EncoderBackend::Amf),
|
EncoderType::Amf => Some(EncoderBackend::Amf),
|
||||||
EncoderType::Rkmpp => Some(EncoderBackend::Rkmpp),
|
EncoderType::Rkmpp => Some(EncoderBackend::Rkmpp),
|
||||||
EncoderType::V4l2m2m => Some(EncoderBackend::V4l2m2m),
|
EncoderType::V4l2m2m => Some(EncoderBackend::V4l2m2m),
|
||||||
|
EncoderType::Amlogic => Some(EncoderBackend::Amlogic),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_amlogic_config_to_backend() {
|
||||||
|
assert_eq!(
|
||||||
|
encoder_type_to_backend(EncoderType::Amlogic),
|
||||||
|
Some(EncoderBackend::Amlogic)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn amlogic_config_json_round_trip() {
|
||||||
|
let json = serde_json::to_string(&EncoderType::Amlogic).unwrap();
|
||||||
|
assert_eq!(json, "\"amlogic\"");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<EncoderType>(&json).unwrap(),
|
||||||
|
EncoderType::Amlogic
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,13 +110,12 @@ pub struct UpdateStatusResponse {
|
|||||||
pub struct UpdateService {
|
pub struct UpdateService {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
work_dir: PathBuf,
|
|
||||||
status: RwLock<UpdateStatusResponse>,
|
status: RwLock<UpdateStatusResponse>,
|
||||||
upgrade_permit: Arc<Semaphore>,
|
upgrade_permit: Arc<Semaphore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpdateService {
|
impl UpdateService {
|
||||||
pub fn new(work_dir: PathBuf) -> Self {
|
pub fn new() -> Self {
|
||||||
let base_url = std::env::var("ONE_KVM_UPDATE_BASE_URL")
|
let base_url = std::env::var("ONE_KVM_UPDATE_BASE_URL")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|url| !url.trim().is_empty())
|
.filter(|url| !url.trim().is_empty())
|
||||||
@@ -125,7 +124,6 @@ impl UpdateService {
|
|||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
base_url,
|
base_url,
|
||||||
work_dir,
|
|
||||||
status: RwLock::new(UpdateStatusResponse {
|
status: RwLock::new(UpdateStatusResponse {
|
||||||
success: true,
|
success: true,
|
||||||
phase: UpdatePhase::Idle,
|
phase: UpdatePhase::Idle,
|
||||||
@@ -289,10 +287,13 @@ impl UpdateService {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
tokio::fs::create_dir_all(&self.work_dir).await?;
|
let download_dir = tempfile::Builder::new()
|
||||||
let staging_path = self
|
.prefix("one-kvm-update-")
|
||||||
.work_dir
|
.tempdir()
|
||||||
.join(format!("one-kvm-{}-download", target_version));
|
.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to create update temp directory: {}", e))
|
||||||
|
})?;
|
||||||
|
let staging_path = download_dir.path().join("tmpfile");
|
||||||
|
|
||||||
let artifact_url = self.resolve_url(&artifact.url);
|
let artifact_url = self.resolve_url(&artifact.url);
|
||||||
self.download_and_verify(&artifact_url, &staging_path, &artifact)
|
self.download_and_verify(&artifact_url, &staging_path, &artifact)
|
||||||
@@ -308,6 +309,7 @@ impl UpdateService {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let restart_exe = self.install_binary(&staging_path).await?;
|
let restart_exe = self.install_binary(&staging_path).await?;
|
||||||
|
drop(download_dir);
|
||||||
|
|
||||||
self.set_status(
|
self.set_status(
|
||||||
UpdatePhase::Restarting,
|
UpdatePhase::Restarting,
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::fd::AsFd;
|
use std::os::fd::AsFd;
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
@@ -13,28 +14,21 @@ use v4l2r::bindings::{
|
|||||||
V4L2_DV_BT_656_1120,
|
V4L2_DV_BT_656_1120,
|
||||||
};
|
};
|
||||||
use v4l2r::ioctl::{
|
use v4l2r::ioctl::{
|
||||||
self, Capabilities, Capability as V4l2rCapability, Event as V4l2Event, EventType,
|
self, Capabilities, Capability as V4l2rCapability, EventType, IntoErrno, MemoryConsistency,
|
||||||
MemoryConsistency, PlaneMapping, QBufPlane, QBuffer, QueryBuffer, QueryDvTimingsError,
|
PlaneMapping, QBufPlane, QBuffer, QueryBuffer, QueryDvTimingsError, SubscribeEventFlags,
|
||||||
SubscribeEventFlags, V4l2Buffer,
|
V4l2Buffer,
|
||||||
};
|
};
|
||||||
use v4l2r::memory::{MemoryType, MmapHandle};
|
use v4l2r::memory::{MemoryType, MmapHandle};
|
||||||
use v4l2r::nix::errno::Errno;
|
use v4l2r::nix::errno::Errno;
|
||||||
use v4l2r::{Format as V4l2rFormat, PixelFormat as V4l2rPixelFormat, QueueType};
|
use v4l2r::{Format as V4l2rFormat, PixelFormat as V4l2rPixelFormat, QueueType};
|
||||||
|
|
||||||
|
use super::CaptureReadError;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::video::device::bridge::{self as csi_bridge, CsiBridgeKind, ProbeResult};
|
use crate::video::device::bridge::{self as csi_bridge, CsiBridgeKind, ProbeResult};
|
||||||
|
use crate::video::device::VideoControlMode;
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
use crate::video::signal::SignalStatus;
|
use crate::video::signal::SignalStatus;
|
||||||
|
|
||||||
/// `io::Error` payload when the driver posts `V4L2_EVENT_SOURCE_CHANGE`.
|
|
||||||
pub const SOURCE_CHANGED_MARKER: &str = "v4l2_source_changed";
|
|
||||||
|
|
||||||
pub fn is_source_changed_error(err: &io::Error) -> bool {
|
|
||||||
err.get_ref()
|
|
||||||
.map(|inner| inner.to_string() == SOURCE_CHANGED_MARKER)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata for a captured frame.
|
/// Metadata for a captured frame.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct CaptureMeta {
|
pub struct CaptureMeta {
|
||||||
@@ -65,11 +59,22 @@ pub struct CaptureStream {
|
|||||||
queue: QueueType,
|
queue: QueueType,
|
||||||
resolution: Resolution,
|
resolution: Resolution,
|
||||||
format: PixelFormat,
|
format: PixelFormat,
|
||||||
|
source_fps: Option<f64>,
|
||||||
stride: u32,
|
stride: u32,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
mappings: Vec<Vec<PlaneMapping>>,
|
mappings: Vec<Vec<PlaneMapping>>,
|
||||||
subdev_fd: Option<File>,
|
subdev_fd: Option<File>,
|
||||||
bridge_kind: Option<CsiBridgeKind>,
|
bridge_kind: Option<CsiBridgeKind>,
|
||||||
|
native_hdmirx_state: Option<NativeHdmirxState>,
|
||||||
|
native_hdmirx_next_state_check: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_capture_device(path: &Path) -> io::Result<File> {
|
||||||
|
File::options()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.custom_flags(libc::O_NONBLOCK)
|
||||||
|
.open(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CaptureStream {
|
impl CaptureStream {
|
||||||
@@ -90,6 +95,7 @@ impl CaptureStream {
|
|||||||
buffer_count,
|
buffer_count,
|
||||||
timeout,
|
timeout,
|
||||||
BridgeContext::default(),
|
BridgeContext::default(),
|
||||||
|
VideoControlMode::Configurable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +108,7 @@ impl CaptureStream {
|
|||||||
buffer_count: u32,
|
buffer_count: u32,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
bridge: BridgeContext,
|
bridge: BridgeContext,
|
||||||
|
control_mode: VideoControlMode,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// Probe subdev before video open (RK628: no-signal must not reach capture STREAMON).
|
// Probe subdev before video open (RK628: no-signal must not reach capture STREAMON).
|
||||||
let mut subdev_fd_opt: Option<File> = None;
|
let mut subdev_fd_opt: Option<File> = None;
|
||||||
@@ -143,17 +150,14 @@ impl CaptureStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 1: open the capture (video) node ─────────────────────
|
// ── Phase 1: open the capture (video) node ─────────────────────
|
||||||
let mut fd = File::options()
|
let mut fd = open_capture_device(device_path.as_ref())
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(device_path.as_ref())
|
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to open device: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("Failed to open device: {}", e)))?;
|
||||||
|
|
||||||
let caps: V4l2rCapability = ioctl::querycap(&fd)
|
let caps: V4l2rCapability = ioctl::querycap(&fd)
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to query capabilities: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("Failed to query capabilities: {}", e)))?;
|
||||||
let caps_flags = caps.device_caps();
|
let caps_flags = caps.device_caps();
|
||||||
let driver_name = caps.driver.to_string();
|
let is_source_following = control_mode == VideoControlMode::SourceFollowing;
|
||||||
let is_csi_bridge = is_csi_bridge_driver(&driver_name);
|
let is_native_hdmirx = bridge.kind == Some(CsiBridgeKind::RkHdmirx);
|
||||||
|
|
||||||
// Prefer multi-planar capture when available, as it is required for some
|
// Prefer multi-planar capture when available, as it is required for some
|
||||||
// devices/pixel formats (e.g. NV12 via VIDEO_CAPTURE_MPLANE).
|
// devices/pixel formats (e.g. NV12 via VIDEO_CAPTURE_MPLANE).
|
||||||
@@ -176,9 +180,14 @@ impl CaptureStream {
|
|||||||
width: mode.width,
|
width: mode.width,
|
||||||
height: mode.height,
|
height: mode.height,
|
||||||
fps: mode.fps,
|
fps: mode.fps,
|
||||||
|
signature: None,
|
||||||
})
|
})
|
||||||
} else if is_csi_bridge {
|
} else if is_source_following {
|
||||||
Some(probe_and_apply_dv_timings(&fd)?)
|
// The native RK3588 HDMI RX driver already latches detected
|
||||||
|
// timings while locking the input. S_DV_TIMINGS is unnecessary
|
||||||
|
// there and rejects some otherwise valid sources whose measured
|
||||||
|
// porches do not exactly match its CEA table.
|
||||||
|
Some(probe_dv_timings(&fd, !is_native_hdmirx)?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -188,7 +197,7 @@ impl CaptureStream {
|
|||||||
// `v4l2-ctl --set-fmt-video=width=…,height=…`).
|
// `v4l2-ctl --set-fmt-video=width=…,height=…`).
|
||||||
let mut fmt: V4l2rFormat = match (
|
let mut fmt: V4l2rFormat = match (
|
||||||
ioctl::g_fmt::<V4l2rFormat>(&fd, queue),
|
ioctl::g_fmt::<V4l2rFormat>(&fd, queue),
|
||||||
is_csi_bridge,
|
is_source_following,
|
||||||
dv_mode.as_ref(),
|
dv_mode.as_ref(),
|
||||||
) {
|
) {
|
||||||
(Ok(f), _, _) if f.width > 0 && f.height > 0 => f,
|
(Ok(f), _, _) if f.width > 0 && f.height > 0 => f,
|
||||||
@@ -208,19 +217,51 @@ impl CaptureStream {
|
|||||||
// Prefer the DV-timings-reported geometry for CSI bridges — the
|
// Prefer the DV-timings-reported geometry for CSI bridges — the
|
||||||
// source, not the user config, dictates what the capture hardware
|
// source, not the user config, dictates what the capture hardware
|
||||||
// will actually deliver.
|
// will actually deliver.
|
||||||
let (target_w, target_h) = match dv_mode {
|
let (target_w, target_h) = match dv_mode.as_ref() {
|
||||||
Some(DvTimingsMode { width, height, .. }) => (width, height),
|
Some(DvTimingsMode { width, height, .. }) => (*width, *height),
|
||||||
None => (resolution.width, resolution.height),
|
None => (resolution.width, resolution.height),
|
||||||
};
|
};
|
||||||
fmt.width = target_w;
|
fmt.width = target_w;
|
||||||
fmt.height = target_h;
|
fmt.height = target_h;
|
||||||
fmt.pixelformat = V4l2rPixelFormat::from(&format.to_fourcc());
|
let requested_fourcc = V4l2rPixelFormat::from(&format.to_fourcc());
|
||||||
|
if is_native_hdmirx && fmt.pixelformat != requested_fourcc {
|
||||||
|
// rk_hdmirx exposes all possible HDMI input encodings through
|
||||||
|
// ENUM_FMT but can capture only the encoding currently present on
|
||||||
|
// the wire. Follow G_FMT so a source-side RGB/YUV transition can
|
||||||
|
// recover even if the saved configuration still names the old
|
||||||
|
// FourCC. The negotiated format is returned to the caller, which
|
||||||
|
// rebuilds the encoder when it changed.
|
||||||
|
info!(
|
||||||
|
"rk_hdmirx input format changed/requested {:?}, following active {:?}",
|
||||||
|
requested_fourcc, fmt.pixelformat
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fmt.pixelformat = requested_fourcc;
|
||||||
|
}
|
||||||
|
|
||||||
let actual_fmt: V4l2rFormat = ioctl::s_fmt(&mut fd, (queue, &fmt))
|
let actual_fmt: V4l2rFormat = ioctl::s_fmt(&mut fd, (queue, &fmt))
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to set device format: {}", e)))?;
|
.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_resolution = Resolution::new(actual_fmt.width, actual_fmt.height);
|
||||||
let actual_format = PixelFormat::from_v4l2r(actual_fmt.pixelformat).unwrap_or(format);
|
let actual_format = match PixelFormat::from_v4l2r(actual_fmt.pixelformat) {
|
||||||
|
Some(format) => format,
|
||||||
|
None if is_native_hdmirx => {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"Native HDMI RX input format {:?} is not supported; configure the HDMI source for 8-bit RGB/YUV output",
|
||||||
|
actual_fmt.pixelformat
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => format,
|
||||||
|
};
|
||||||
|
|
||||||
|
let native_hdmirx_state = is_native_hdmirx.then(|| NativeHdmirxState {
|
||||||
|
width: actual_fmt.width,
|
||||||
|
height: actual_fmt.height,
|
||||||
|
pixelformat: actual_fmt.pixelformat,
|
||||||
|
timings: dv_mode.as_ref().and_then(|mode| mode.signature),
|
||||||
|
});
|
||||||
|
let native_hdmirx_next_state_check =
|
||||||
|
native_hdmirx_state.map(|_| Instant::now() + Duration::from_secs(1));
|
||||||
|
|
||||||
let stride = actual_fmt
|
let stride = actual_fmt
|
||||||
.plane_fmt
|
.plane_fmt
|
||||||
@@ -283,11 +324,14 @@ impl CaptureStream {
|
|||||||
queue,
|
queue,
|
||||||
resolution: actual_resolution,
|
resolution: actual_resolution,
|
||||||
format: actual_format,
|
format: actual_format,
|
||||||
|
source_fps: dv_mode.as_ref().and_then(|mode| mode.fps),
|
||||||
stride,
|
stride,
|
||||||
timeout,
|
timeout,
|
||||||
mappings,
|
mappings,
|
||||||
subdev_fd: subdev_fd_opt,
|
subdev_fd: subdev_fd_opt,
|
||||||
bridge_kind: bridge.kind,
|
bridge_kind: bridge.kind,
|
||||||
|
native_hdmirx_state,
|
||||||
|
native_hdmirx_next_state_check,
|
||||||
};
|
};
|
||||||
|
|
||||||
stream.queue_all_buffers()?;
|
stream.queue_all_buffers()?;
|
||||||
@@ -324,6 +368,10 @@ impl CaptureStream {
|
|||||||
self.format
|
self.format
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn source_fps(&self) -> Option<f64> {
|
||||||
|
self.source_fps
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stride(&self) -> u32 {
|
pub fn stride(&self) -> u32 {
|
||||||
self.stride
|
self.stride
|
||||||
}
|
}
|
||||||
@@ -373,11 +421,40 @@ impl CaptureStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_into(&mut self, dst: &mut Vec<u8>) -> io::Result<CaptureMeta> {
|
pub fn next_into(
|
||||||
|
&mut self,
|
||||||
|
dst: &mut Vec<u8>,
|
||||||
|
) -> std::result::Result<CaptureMeta, CaptureReadError> {
|
||||||
self.wait_ready()?;
|
self.wait_ready()?;
|
||||||
|
|
||||||
let dqbuf: V4l2Buffer = ioctl::dqbuf(&self.fd, self.queue, MemoryType::Mmap)
|
// Several vendor BSPs update G_FMT/DV timings without making the
|
||||||
.map_err(|e| io::Error::other(format!("dqbuf failed: {}", e)))?;
|
// subscribed video fd poll as POLLPRI. Check once per second so a
|
||||||
|
// genuine source mode change cannot leave us dequeuing buffers with
|
||||||
|
// stale geometry forever. Transient ioctl failures are ignored here;
|
||||||
|
// the capture timeout/error path remains responsible for recovery.
|
||||||
|
if self
|
||||||
|
.native_hdmirx_next_state_check
|
||||||
|
.is_some_and(|next| Instant::now() >= next)
|
||||||
|
{
|
||||||
|
self.native_hdmirx_next_state_check = Some(Instant::now() + Duration::from_secs(1));
|
||||||
|
if self.native_hdmirx_state_changed() {
|
||||||
|
info!(
|
||||||
|
"Native HDMI RX active format/timings changed without a usable event; requesting stream re-open"
|
||||||
|
);
|
||||||
|
return Err(CaptureReadError::SourceChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dqbuf: V4l2Buffer =
|
||||||
|
ioctl::dqbuf(&self.fd, self.queue, MemoryType::Mmap).map_err(|error| {
|
||||||
|
let message = error.to_string();
|
||||||
|
let error = if error.into_errno() == Errno::EAGAIN as i32 {
|
||||||
|
io::Error::from(io::ErrorKind::WouldBlock)
|
||||||
|
} else {
|
||||||
|
io::Error::other(format!("dqbuf failed: {}", message))
|
||||||
|
};
|
||||||
|
CaptureReadError::Io(error)
|
||||||
|
})?;
|
||||||
let index = dqbuf.as_v4l2_buffer().index as usize;
|
let index = dqbuf.as_v4l2_buffer().index as usize;
|
||||||
let sequence = dqbuf.as_v4l2_buffer().sequence as u64;
|
let sequence = dqbuf.as_v4l2_buffer().sequence as u64;
|
||||||
|
|
||||||
@@ -427,7 +504,7 @@ impl CaptureStream {
|
|||||||
self.resolution.height,
|
self.resolution.height,
|
||||||
self.stride
|
self.stride
|
||||||
);
|
);
|
||||||
return Err(io::Error::other(SOURCE_CHANGED_MARKER));
|
return Err(CaptureReadError::SourceChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,10 +514,12 @@ impl CaptureStream {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wait_ready(&self) -> io::Result<()> {
|
fn wait_ready(&self) -> std::result::Result<(), CaptureReadError> {
|
||||||
if self.timeout.is_zero() {
|
if self.timeout.is_zero() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let deadline = Instant::now() + self.timeout;
|
||||||
|
loop {
|
||||||
// Multiplex video fd (POLLIN for DQBUF, POLLPRI as fallback for
|
// Multiplex video fd (POLLIN for DQBUF, POLLPRI as fallback for
|
||||||
// drivers that deliver events here) and the optional subdev fd
|
// drivers that deliver events here) and the optional subdev fd
|
||||||
// (POLLPRI only — SOURCE_CHANGE on RK628 / rkcif).
|
// (POLLPRI only — SOURCE_CHANGE on RK628 / rkcif).
|
||||||
@@ -452,26 +531,36 @@ impl CaptureStream {
|
|||||||
if let Some(subdev_fd) = self.subdev_fd.as_ref() {
|
if let Some(subdev_fd) = self.subdev_fd.as_ref() {
|
||||||
poll_fds.push(PollFd::new(subdev_fd.as_fd(), PollFlags::POLLPRI));
|
poll_fds.push(PollFd::new(subdev_fd.as_fd(), PollFlags::POLLPRI));
|
||||||
}
|
}
|
||||||
let timeout_ms = self.timeout.as_millis().min(u16::MAX as u128) as u16;
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||||
let ready = poll(&mut poll_fds, PollTimeout::from(timeout_ms))?;
|
if remaining.is_zero() {
|
||||||
|
return Err(io::Error::new(io::ErrorKind::TimedOut, "capture timeout").into());
|
||||||
|
}
|
||||||
|
// `nix::poll` accepts a u16 millisecond timeout. Round sub-ms
|
||||||
|
// durations up, and preserve the original deadline if a very long
|
||||||
|
// timeout needs more than one poll call.
|
||||||
|
let timeout_ms = remaining.as_millis().clamp(1, u16::MAX as u128) as u16;
|
||||||
|
let ready = poll(&mut poll_fds, PollTimeout::from(timeout_ms))
|
||||||
|
.map_err(|error| CaptureReadError::Io(error.into()))?;
|
||||||
if ready == 0 {
|
if ready == 0 {
|
||||||
return Err(io::Error::new(io::ErrorKind::TimedOut, "capture timeout"));
|
if Instant::now() >= deadline {
|
||||||
|
return Err(io::Error::new(io::ErrorKind::TimedOut, "capture timeout").into());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subdev POLLPRI fires first on rkcif/RK628 when the source-side
|
// Subdev POLLPRI fires first on rkcif/RK628 when the source-side
|
||||||
// HDMI timings changed. Drain all pending events and bubble up
|
// HDMI timings changed. Native HDMI RX uses the video node and
|
||||||
// the `source_changed` marker so the upper layer re-opens with a
|
// is validated separately below.
|
||||||
// fresh DV_TIMINGS probe.
|
|
||||||
if let Some(subdev_fd) = self.subdev_fd.as_ref() {
|
if let Some(subdev_fd) = self.subdev_fd.as_ref() {
|
||||||
if let Some(revents) = poll_fds.get(1).and_then(|f| f.revents()) {
|
if let Some(revents) = poll_fds.get(1).and_then(|f| f.revents()) {
|
||||||
if revents.contains(PollFlags::POLLPRI) {
|
if revents.contains(PollFlags::POLLPRI) {
|
||||||
let drained = drain_events(subdev_fd);
|
let drained = csi_bridge::drain_v4l2_events(subdev_fd);
|
||||||
info!(
|
info!(
|
||||||
"Subdev SOURCE_CHANGE detected (drained {} event(s)), \
|
"Subdev SOURCE_CHANGE detected (drained {} event(s)), \
|
||||||
requesting stream re-open",
|
requesting stream re-open",
|
||||||
drained
|
drained
|
||||||
);
|
);
|
||||||
return Err(io::Error::other(SOURCE_CHANGED_MARKER));
|
return Err(CaptureReadError::SourceChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,16 +571,26 @@ impl CaptureStream {
|
|||||||
"capture poll: video revents={:?} (ERR/HUP) — requesting stream re-open",
|
"capture poll: video revents={:?} (ERR/HUP) — requesting stream re-open",
|
||||||
revents
|
revents
|
||||||
);
|
);
|
||||||
return Err(io::Error::other(SOURCE_CHANGED_MARKER));
|
return Err(CaptureReadError::SourceChanged);
|
||||||
}
|
}
|
||||||
if revents.contains(PollFlags::POLLPRI) {
|
if revents.contains(PollFlags::POLLPRI) {
|
||||||
let drained = drain_events(&self.fd);
|
let drained = csi_bridge::drain_v4l2_events(&self.fd);
|
||||||
|
if self.native_hdmirx_state_unchanged() {
|
||||||
|
debug!(
|
||||||
|
"Ignoring {} spurious native HDMI RX SOURCE_CHANGE event(s): active format/timings are unchanged",
|
||||||
|
drained
|
||||||
|
);
|
||||||
|
if revents.contains(PollFlags::POLLIN) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
info!(
|
info!(
|
||||||
"Video-node SOURCE_CHANGE detected (drained {} event(s)), \
|
"Video-node SOURCE_CHANGE detected (drained {} event(s)), \
|
||||||
requesting stream re-open",
|
requesting stream re-open",
|
||||||
drained
|
drained
|
||||||
);
|
);
|
||||||
return Err(io::Error::other(SOURCE_CHANGED_MARKER));
|
return Err(CaptureReadError::SourceChanged);
|
||||||
}
|
}
|
||||||
if !revents.contains(PollFlags::POLLIN) {
|
if !revents.contains(PollFlags::POLLIN) {
|
||||||
// rkcif + RK628: the driver may wake `poll` after internally
|
// rkcif + RK628: the driver may wake `poll` after internally
|
||||||
@@ -501,7 +600,7 @@ impl CaptureStream {
|
|||||||
"capture poll: ready={} video revents={:?} (no POLLIN) — requesting stream re-open",
|
"capture poll: ready={} video revents={:?} (no POLLIN) — requesting stream re-open",
|
||||||
ready, revents
|
ready, revents
|
||||||
);
|
);
|
||||||
return Err(io::Error::other(SOURCE_CHANGED_MARKER));
|
return Err(CaptureReadError::SourceChanged);
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -510,7 +609,52 @@ impl CaptureStream {
|
|||||||
"capture poll: ready={} but video revents unavailable — requesting stream re-open",
|
"capture poll: ready={} but video revents unavailable — requesting stream re-open",
|
||||||
ready
|
ready
|
||||||
);
|
);
|
||||||
Err(io::Error::other(SOURCE_CHANGED_MARKER))
|
return Err(CaptureReadError::SourceChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn native_hdmirx_state_unchanged(&self) -> bool {
|
||||||
|
let Some(expected) = self.native_hdmirx_state.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(current_fmt) = ioctl::g_fmt::<V4l2rFormat>(&self.fd, self.queue) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !expected.format_matches(
|
||||||
|
current_fmt.width,
|
||||||
|
current_fmt.height,
|
||||||
|
current_fmt.pixelformat,
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let observed_timings = ioctl::query_dv_timings::<v4l2_dv_timings>(&self.fd)
|
||||||
|
.ok()
|
||||||
|
.and_then(|timings| dv_timings_signature(&timings));
|
||||||
|
expected.timings_match(observed_timings).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn native_hdmirx_state_changed(&self) -> bool {
|
||||||
|
let Some(expected) = self.native_hdmirx_state.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(current_fmt) = ioctl::g_fmt::<V4l2rFormat>(&self.fd, self.queue) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !expected.format_matches(
|
||||||
|
current_fmt.width,
|
||||||
|
current_fmt.height,
|
||||||
|
current_fmt.pixelformat,
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let observed_timings = ioctl::query_dv_timings::<v4l2_dv_timings>(&self.fd)
|
||||||
|
.ok()
|
||||||
|
.and_then(|timings| dv_timings_signature(&timings));
|
||||||
|
expected
|
||||||
|
.timings_match(observed_timings)
|
||||||
|
.is_some_and(|matches| !matches)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_all_buffers(&mut self) -> Result<()> {
|
fn queue_all_buffers(&mut self) -> Result<()> {
|
||||||
@@ -570,29 +714,6 @@ impl Drop for CaptureStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Driver-name check for CSI/HDMI bridge devices (rk_hdmirx, rkcif, tc358743,
|
|
||||||
/// …) that expose DV timings. Kept in sync with `video::device::is_csi_hdmi_bridge`
|
|
||||||
/// but queries the raw V4L2 driver string so we don't need a full
|
|
||||||
/// `VideoDeviceInfo` at `CaptureStream::open` time.
|
|
||||||
fn is_csi_bridge_driver(driver: &str) -> bool {
|
|
||||||
let d = driver.to_ascii_lowercase();
|
|
||||||
d == "rk_hdmirx" || d == "rkcif" || d == "tc358743" || d.starts_with("rkcif")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain any pending `V4L2_EVENT_*` events on `fd`. Used after POLLPRI to
|
|
||||||
/// clear the queue so the next poll doesn't immediately wake up on stale
|
|
||||||
/// state. Capped at 16 events per call.
|
|
||||||
fn drain_events(fd: &File) -> u32 {
|
|
||||||
let mut drained = 0u32;
|
|
||||||
while let Ok(_ev) = ioctl::dqevent::<V4l2Event>(fd) {
|
|
||||||
drained = drained.saturating_add(1);
|
|
||||||
if drained >= 16 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drained
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of a successful `VIDIOC_QUERY_DV_TIMINGS` + `VIDIOC_S_DV_TIMINGS`
|
/// Result of a successful `VIDIOC_QUERY_DV_TIMINGS` + `VIDIOC_S_DV_TIMINGS`
|
||||||
/// probe. Used by the CSI bridge path to override the requested resolution
|
/// probe. Used by the CSI bridge path to override the requested resolution
|
||||||
/// with the source-reported geometry before `S_FMT`.
|
/// with the source-reported geometry before `S_FMT`.
|
||||||
@@ -602,6 +723,63 @@ struct DvTimingsMode {
|
|||||||
height: u32,
|
height: u32,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fps: Option<f64>,
|
fps: Option<f64>,
|
||||||
|
signature: Option<DvTimingsSignature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct NativeHdmirxState {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
pixelformat: V4l2rPixelFormat,
|
||||||
|
timings: Option<DvTimingsSignature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeHdmirxState {
|
||||||
|
fn format_matches(self, width: u32, height: u32, pixelformat: V4l2rPixelFormat) -> bool {
|
||||||
|
self.width == width && self.height == height && self.pixelformat == pixelformat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `None` means the expected state contains timings but the current
|
||||||
|
/// timings could not be observed. Event handling treats that uncertainty
|
||||||
|
/// conservatively as changed; periodic fallback probing ignores it so a
|
||||||
|
/// single transient ioctl failure cannot tear down a healthy stream.
|
||||||
|
fn timings_match(self, observed: Option<DvTimingsSignature>) -> Option<bool> {
|
||||||
|
match (self.timings, observed) {
|
||||||
|
(None, _) => Some(true),
|
||||||
|
(Some(expected), Some(current)) => Some(current.matches(expected)),
|
||||||
|
(Some(_), None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct DvTimingsSignature {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
interlaced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DvTimingsSignature {
|
||||||
|
fn matches(self, other: Self) -> bool {
|
||||||
|
self.width == other.width
|
||||||
|
&& self.height == other.height
|
||||||
|
&& self.interlaced == other.interlaced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dv_timings_signature(timings: &v4l2_dv_timings) -> Option<DvTimingsSignature> {
|
||||||
|
let timings_type = timings.type_;
|
||||||
|
if timings_type != V4L2_DV_BT_656_1120 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bt = unsafe { timings.__bindgen_anon_1.bt };
|
||||||
|
let width = bt.width;
|
||||||
|
let height = bt.height;
|
||||||
|
Some(DvTimingsSignature {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
interlaced: bt.interlaced != 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe DV timings from the source and latch them into the driver.
|
/// Probe DV timings from the source and latch them into the driver.
|
||||||
@@ -619,7 +797,7 @@ struct DvTimingsMode {
|
|||||||
/// * `ENODATA` → `NoSignal` (driver says "no DV timings support on
|
/// * `ENODATA` → `NoSignal` (driver says "no DV timings support on
|
||||||
/// this input", e.g. EDID not applied yet)
|
/// this input", e.g. EDID not applied yet)
|
||||||
/// * anything else → `NoSignal` (fallback, keeps the retry loop going)
|
/// * anything else → `NoSignal` (fallback, keeps the retry loop going)
|
||||||
fn probe_and_apply_dv_timings(fd: &File) -> Result<DvTimingsMode> {
|
fn probe_dv_timings(fd: &File, apply: bool) -> Result<DvTimingsMode> {
|
||||||
let timings: v4l2_dv_timings = match ioctl::query_dv_timings(fd) {
|
let timings: v4l2_dv_timings = match ioctl::query_dv_timings(fd) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -638,7 +816,7 @@ fn probe_and_apply_dv_timings(fd: &File) -> Result<DvTimingsMode> {
|
|||||||
| QueryDvTimingsError::IoctlError(Errno::ETIMEDOUT) => SignalStatus::NoSync,
|
| QueryDvTimingsError::IoctlError(Errno::ETIMEDOUT) => SignalStatus::NoSync,
|
||||||
QueryDvTimingsError::IoctlError(_) => SignalStatus::NoSignal,
|
QueryDvTimingsError::IoctlError(_) => SignalStatus::NoSignal,
|
||||||
};
|
};
|
||||||
info!(
|
debug!(
|
||||||
"VIDIOC_QUERY_DV_TIMINGS failed: {} -> SignalStatus::{:?}",
|
"VIDIOC_QUERY_DV_TIMINGS failed: {} -> SignalStatus::{:?}",
|
||||||
err, status
|
err, status
|
||||||
);
|
);
|
||||||
@@ -687,12 +865,14 @@ fn probe_and_apply_dv_timings(fd: &File) -> Result<DvTimingsMode> {
|
|||||||
// right pixel clock + blanking. Failure here is *not* fatal on some
|
// right pixel clock + blanking. Failure here is *not* fatal on some
|
||||||
// drivers (rkcif doesn't implement S_DV_TIMINGS per-output-device, only
|
// drivers (rkcif doesn't implement S_DV_TIMINGS per-output-device, only
|
||||||
// on the bridging subdev), so degrade to a warning and keep going.
|
// on the bridging subdev), so degrade to a warning and keep going.
|
||||||
|
if apply {
|
||||||
if let Err(e) = ioctl::s_dv_timings::<_, v4l2_dv_timings>(fd, timings) {
|
if let Err(e) = ioctl::s_dv_timings::<_, v4l2_dv_timings>(fd, timings) {
|
||||||
debug!(
|
debug!(
|
||||||
"VIDIOC_S_DV_TIMINGS failed ({}), continuing with queried timings for S_FMT",
|
"VIDIOC_S_DV_TIMINGS failed ({}), continuing with queried timings for S_FMT",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let fps = dv_timings_fps_from_scalars(
|
let fps = dv_timings_fps_from_scalars(
|
||||||
bt_width,
|
bt_width,
|
||||||
@@ -714,6 +894,7 @@ fn probe_and_apply_dv_timings(fd: &File) -> Result<DvTimingsMode> {
|
|||||||
width: bt_width,
|
width: bt_width,
|
||||||
height: bt_height,
|
height: bt_height,
|
||||||
fps,
|
fps,
|
||||||
|
signature: dv_timings_signature(&timings),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,3 +930,66 @@ fn set_fps(fd: &File, queue: QueueType, fps: u32) -> std::result::Result<(), ioc
|
|||||||
let _actual: v4l2_streamparm = ioctl::s_parm(fd, params)?;
|
let _actual: v4l2_streamparm = ioctl::s_parm(fd, params)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{open_capture_device, DvTimingsSignature, NativeHdmirxState};
|
||||||
|
use crate::video::format::PixelFormat;
|
||||||
|
|
||||||
|
fn timing() -> DvTimingsSignature {
|
||||||
|
DvTimingsSignature {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
interlaced: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timing_match_uses_only_active_geometry_and_scan_mode() {
|
||||||
|
assert!(timing().matches(timing()));
|
||||||
|
|
||||||
|
let mut different_width = timing();
|
||||||
|
different_width.width = 1280;
|
||||||
|
assert!(!timing().matches(different_width));
|
||||||
|
|
||||||
|
let mut interlaced = timing();
|
||||||
|
interlaced.interlaced = true;
|
||||||
|
assert!(!timing().matches(interlaced));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_hdmirx_state_distinguishes_spurious_and_real_changes() {
|
||||||
|
let bgr24 = PixelFormat::Bgr24.to_v4l2r();
|
||||||
|
let state = NativeHdmirxState {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
pixelformat: bgr24,
|
||||||
|
timings: Some(timing()),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(state.format_matches(1920, 1080, bgr24));
|
||||||
|
assert!(!state.format_matches(1280, 720, bgr24));
|
||||||
|
assert!(!state.format_matches(1920, 1080, PixelFormat::Nv12.to_v4l2r()));
|
||||||
|
assert_eq!(state.timings_match(Some(timing())), Some(true));
|
||||||
|
let mut interlaced = timing();
|
||||||
|
interlaced.interlaced = true;
|
||||||
|
assert_eq!(state.timings_match(Some(interlaced)), Some(false));
|
||||||
|
assert_eq!(state.timings_match(None), None);
|
||||||
|
|
||||||
|
let no_timing_state = NativeHdmirxState {
|
||||||
|
timings: None,
|
||||||
|
..state
|
||||||
|
};
|
||||||
|
assert_eq!(no_timing_state.timings_match(None), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_device_handles_are_non_blocking() {
|
||||||
|
let temp = tempfile::NamedTempFile::new().expect("create temporary device file");
|
||||||
|
let opened = open_capture_device(temp.path()).expect("open capture device");
|
||||||
|
let flags =
|
||||||
|
nix::fcntl::fcntl(&opened, nix::fcntl::FcntlArg::F_GETFL).expect("read file flags");
|
||||||
|
|
||||||
|
assert_ne!(flags & libc::O_NONBLOCK, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,51 @@
|
|||||||
//! Video capture implementations and capture-state helpers.
|
//! Video capture implementations and capture-state helpers.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
pub(crate) mod runtime;
|
pub(crate) mod runtime;
|
||||||
pub(crate) mod status;
|
pub(crate) mod status;
|
||||||
|
|
||||||
pub const DEFAULT_CAPTURE_BUFFER_COUNT: u32 = 4;
|
pub const DEFAULT_CAPTURE_BUFFER_COUNT: u32 = 4;
|
||||||
|
|
||||||
|
/// Expected source changes are control flow, not stringly typed I/O errors.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CaptureReadError {
|
||||||
|
SourceChanged,
|
||||||
|
Io(io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureReadError {
|
||||||
|
pub fn as_io_error(&self) -> Option<&io::Error> {
|
||||||
|
match self {
|
||||||
|
Self::SourceChanged => None,
|
||||||
|
Self::Io(error) => Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<io::Error> for CaptureReadError {
|
||||||
|
fn from(error: io::Error) -> Self {
|
||||||
|
Self::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CaptureReadError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::SourceChanged => formatter.write_str("capture source changed"),
|
||||||
|
Self::Io(error) => error.fmt(formatter),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CaptureReadError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
self.as_io_error()
|
||||||
|
.map(|error| error as &(dyn std::error::Error + 'static))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
mod linux;
|
mod linux;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::video::capture::status::signal_status_from_capture_kind;
|
use crate::video::capture::status::signal_status_from_capture_kind;
|
||||||
|
use crate::video::device::VideoControlMode;
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
use crate::video::signal::SignalStatus;
|
use crate::video::signal::SignalStatus;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ pub fn open_capture_stream(
|
|||||||
buffer_count: u32,
|
buffer_count: u32,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
bridge_ctx: BridgeContext,
|
bridge_ctx: BridgeContext,
|
||||||
|
control_mode: VideoControlMode,
|
||||||
) -> Result<CaptureStream, AppError> {
|
) -> Result<CaptureStream, AppError> {
|
||||||
CaptureStream::open_with_bridge(
|
CaptureStream::open_with_bridge(
|
||||||
device_path,
|
device_path,
|
||||||
@@ -32,6 +34,7 @@ pub fn open_capture_stream(
|
|||||||
buffer_count.max(1),
|
buffer_count.max(1),
|
||||||
timeout,
|
timeout,
|
||||||
bridge_ctx,
|
bridge_ctx,
|
||||||
|
control_mode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ pub fn open_capture_stream_for_retry(
|
|||||||
buffer_count: u32,
|
buffer_count: u32,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
bridge_ctx: BridgeContext,
|
bridge_ctx: BridgeContext,
|
||||||
|
control_mode: VideoControlMode,
|
||||||
is_device_lost_message: impl FnOnce(&str) -> bool,
|
is_device_lost_message: impl FnOnce(&str) -> bool,
|
||||||
) -> CaptureOpenResult {
|
) -> CaptureOpenResult {
|
||||||
match open_capture_stream(
|
match open_capture_stream(
|
||||||
@@ -53,6 +57,7 @@ pub fn open_capture_stream_for_retry(
|
|||||||
buffer_count,
|
buffer_count,
|
||||||
timeout,
|
timeout,
|
||||||
bridge_ctx,
|
bridge_ctx,
|
||||||
|
control_mode,
|
||||||
) {
|
) {
|
||||||
Ok(stream) => CaptureOpenResult::Opened(stream),
|
Ok(stream) => CaptureOpenResult::Opened(stream),
|
||||||
Err(AppError::CaptureNoSignal { kind }) => {
|
Err(AppError::CaptureNoSignal { kind }) => {
|
||||||
|
|||||||
@@ -2,19 +2,14 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::CaptureReadError;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::video::device::bridge::{CsiBridgeKind, ProbeResult};
|
use crate::video::device::bridge::{CsiBridgeKind, ProbeResult};
|
||||||
use crate::video::device::{directshow_display_name_from_path, normalize_windows_device_path};
|
use crate::video::device::{
|
||||||
|
directshow_display_name_from_path, normalize_windows_device_path, VideoControlMode,
|
||||||
|
};
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
|
|
||||||
pub const SOURCE_CHANGED_MARKER: &str = "dshow_source_changed";
|
|
||||||
|
|
||||||
pub fn is_source_changed_error(err: &io::Error) -> bool {
|
|
||||||
err.get_ref()
|
|
||||||
.map(|inner| inner.to_string() == SOURCE_CHANGED_MARKER)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct CaptureMeta {
|
pub struct CaptureMeta {
|
||||||
pub bytes_used: usize,
|
pub bytes_used: usize,
|
||||||
@@ -95,8 +90,9 @@ impl CaptureStream {
|
|||||||
buffer_count: u32,
|
buffer_count: u32,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
bridge: BridgeContext,
|
bridge: BridgeContext,
|
||||||
|
control_mode: VideoControlMode,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let _ = bridge;
|
let _ = (bridge, control_mode);
|
||||||
Self::open(device_path, resolution, format, fps, buffer_count, timeout)
|
Self::open(device_path, resolution, format, fps, buffer_count, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +104,18 @@ impl CaptureStream {
|
|||||||
self.format
|
self.format
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn source_fps(&self) -> Option<f64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stride(&self) -> u32 {
|
pub fn stride(&self) -> u32 {
|
||||||
self.stride
|
self.stride
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_into(&mut self, dst: &mut Vec<u8>) -> io::Result<CaptureMeta> {
|
pub fn next_into(
|
||||||
|
&mut self,
|
||||||
|
dst: &mut Vec<u8>,
|
||||||
|
) -> std::result::Result<CaptureMeta, CaptureReadError> {
|
||||||
match self.capture.read_packet() {
|
match self.capture.read_packet() {
|
||||||
Ok((packet, sequence)) => {
|
Ok((packet, sequence)) => {
|
||||||
dst.clear();
|
dst.clear();
|
||||||
@@ -128,7 +131,7 @@ impl CaptureStream {
|
|||||||
} else {
|
} else {
|
||||||
io::ErrorKind::Other
|
io::ErrorKind::Other
|
||||||
};
|
};
|
||||||
Err(io::Error::new(kind, err.message))
|
Err(CaptureReadError::Io(io::Error::new(kind, err.message)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
985
src/video/codec/amlenc.rs
Normal file
985
src/video/codec/amlenc.rs
Normal file
@@ -0,0 +1,985 @@
|
|||||||
|
//! Native Amlogic AMLENC bindings for the S912/GXM vendor Linux 4.9 stack.
|
||||||
|
//!
|
||||||
|
//! The vendor libraries are deliberately loaded at runtime. They must be built
|
||||||
|
//! with the One-KVM ABI v1 patch from the standalone `amlenc` repository;
|
||||||
|
//! unpatched 0.4 libraries
|
||||||
|
//! are rejected before any device access is attempted.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::ffi::{c_int, c_long, c_uchar, c_uint, OsStr};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use libloading::Library;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::video::format::Resolution;
|
||||||
|
|
||||||
|
pub const AMLENC_ABI_VERSION: c_int = 1;
|
||||||
|
pub const AMLENC_H264_CODEC_NAME: &str = "h264_amlenc";
|
||||||
|
pub const AMLENC_H265_CODEC_NAME: &str = "hevc_amlenc";
|
||||||
|
pub const AMLENC_H264_DEFAULT_LIBRARY: &str = "libvpcodec.so";
|
||||||
|
pub const AMLENC_H265_DEFAULT_LIBRARY: &str = "libvphevcodec.so";
|
||||||
|
|
||||||
|
const AMLENC_MAX_WIDTH: u32 = 1920;
|
||||||
|
const AMLENC_MAX_HEIGHT: u32 = 1080;
|
||||||
|
const AMLENC_MAX_FPS: u32 = 60;
|
||||||
|
const MIN_OUTPUT_BUFFER_SIZE: usize = 1024 * 1024;
|
||||||
|
const OUTPUT_STALL_TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
|
const CODEC_ID_H264: c_int = 4;
|
||||||
|
const CODEC_ID_H265: c_int = 5;
|
||||||
|
const IMG_FMT_NV12: c_int = 1;
|
||||||
|
const FRAME_TYPE_AUTO: c_int = 1;
|
||||||
|
const FRAME_TYPE_IDR: c_int = 2;
|
||||||
|
const H264_NV12_FORMAT: c_int = 0;
|
||||||
|
const H265_NV12_FORMAT: c_int = 1;
|
||||||
|
|
||||||
|
type AbiVersionFn = unsafe extern "C" fn() -> c_int;
|
||||||
|
type H264InitFn = unsafe extern "C" fn(c_int, c_int, c_int, c_int, c_int, c_int, c_int) -> c_long;
|
||||||
|
type H265InitFn = unsafe extern "C" fn(c_int, c_int, c_int, c_int, c_int, c_int) -> c_long;
|
||||||
|
type H264EncodeFn =
|
||||||
|
unsafe extern "C" fn(c_long, c_int, *mut c_uchar, c_int, *mut c_uchar, c_int) -> c_int;
|
||||||
|
type H265EncodeFn =
|
||||||
|
unsafe extern "C" fn(c_long, c_int, *mut c_uchar, c_uint, *mut c_uchar, c_int) -> c_int;
|
||||||
|
type DestroyFn = unsafe extern "C" fn(c_long) -> c_int;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AmlencCodec {
|
||||||
|
H264,
|
||||||
|
H265,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AmlencCodec {
|
||||||
|
pub fn codec_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::H264 => AMLENC_H264_CODEC_NAME,
|
||||||
|
Self::H265 => AMLENC_H265_CODEC_NAME,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_library(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::H264 => AMLENC_H264_DEFAULT_LIBRARY,
|
||||||
|
Self::H265 => AMLENC_H265_DEFAULT_LIBRARY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn library_env(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::H264 => "ONE_KVM_AMLENC_H264_LIB",
|
||||||
|
Self::H265 => "ONE_KVM_AMLENC_H265_LIB",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn device_node(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::H264 => "/dev/amvenc_avc",
|
||||||
|
Self::H265 => "/dev/HevcEnc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct AmlencConfig {
|
||||||
|
pub codec: AmlencCodec,
|
||||||
|
pub resolution: Resolution,
|
||||||
|
pub fps: u32,
|
||||||
|
pub bitrate_kbps: u32,
|
||||||
|
pub gop: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AmlencConfig {
|
||||||
|
pub fn validate(self) -> Result<()> {
|
||||||
|
let width = self.resolution.width;
|
||||||
|
let height = self.resolution.height;
|
||||||
|
if width == 0
|
||||||
|
|| height == 0
|
||||||
|
|| width > AMLENC_MAX_WIDTH
|
||||||
|
|| height > AMLENC_MAX_HEIGHT
|
||||||
|
|| width % 16 != 0
|
||||||
|
|| height % 2 != 0
|
||||||
|
{
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"AMLENC requires NV12 with 16-aligned width, even height, and at most 1920x1080 (got {}x{})",
|
||||||
|
width, height
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !(1..=AMLENC_MAX_FPS).contains(&self.fps) {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"AMLENC supports 1-60 fps (got {})",
|
||||||
|
self.fps
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if self.bitrate_kbps == 0 || self.bitrate_kbps > (c_int::MAX as u32 / 1000) {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"Invalid AMLENC bitrate: {} kbps",
|
||||||
|
self.bitrate_kbps
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if self.gop > c_int::MAX as u32 {
|
||||||
|
return Err(AppError::VideoError("AMLENC GOP is too large".to_string()));
|
||||||
|
}
|
||||||
|
nv12_frame_size(self.resolution)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bitrate_bps(self) -> c_int {
|
||||||
|
(self.bitrate_kbps * 1000) as c_int
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vendor_gop(self) -> c_int {
|
||||||
|
match self.codec {
|
||||||
|
// GXM's H.264 microcode can time out on a later natural IDR for
|
||||||
|
// complex 1080p pictures. The pinned vendor library defines zero
|
||||||
|
// as an infinite GOP (one IDR when the instance is created).
|
||||||
|
AmlencCodec::H264 => 0,
|
||||||
|
AmlencCodec::H265 => self.gop as c_int,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nv12_frame_size(resolution: Resolution) -> Result<usize> {
|
||||||
|
let pixels = (resolution.width as usize)
|
||||||
|
.checked_mul(resolution.height as usize)
|
||||||
|
.ok_or_else(|| AppError::VideoError("AMLENC NV12 frame size overflow".to_string()))?;
|
||||||
|
pixels
|
||||||
|
.checked_mul(3)
|
||||||
|
.map(|value| value / 2)
|
||||||
|
.ok_or_else(|| AppError::VideoError("AMLENC NV12 frame size overflow".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_abi_version(version: c_int, path: &Path) -> Result<()> {
|
||||||
|
if version != AMLENC_ABI_VERSION {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"AMLENC library {} has ABI {}, expected ABI v{}; apply the one-kvm-amlenc-abi-v1.patch from the standalone amlenc repository",
|
||||||
|
path.display(),
|
||||||
|
version,
|
||||||
|
AMLENC_ABI_VERSION
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct H264Api {
|
||||||
|
_library: Library,
|
||||||
|
init: H264InitFn,
|
||||||
|
encode: H264EncodeFn,
|
||||||
|
destroy: DestroyFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct H265Api {
|
||||||
|
_library: Library,
|
||||||
|
init: H265InitFn,
|
||||||
|
encode: H265EncodeFn,
|
||||||
|
destroy: DestroyFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AmlencApi {
|
||||||
|
H264(H264Api),
|
||||||
|
H265(H265Api),
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn required_symbol<T: Copy>(library: &Library, name: &[u8], path: &Path) -> Result<T> {
|
||||||
|
// SAFETY: the caller supplies the signature from the fixed upstream headers.
|
||||||
|
unsafe { library.get::<T>(name) }
|
||||||
|
.map(|symbol| *symbol)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::VideoError(format!(
|
||||||
|
"AMLENC library {} is missing {}: {}",
|
||||||
|
path.display(),
|
||||||
|
String::from_utf8_lossy(name).trim_end_matches('\0'),
|
||||||
|
error
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AmlencApi {
|
||||||
|
fn load(codec: AmlencCodec, path: &Path) -> Result<Self> {
|
||||||
|
// SAFETY: all calls are made through signatures checked against the pinned headers,
|
||||||
|
// and the Library remains owned by the API object for the lifetime of the pointers.
|
||||||
|
let library = unsafe { Library::new(path) }.map_err(|error| {
|
||||||
|
AppError::VideoError(format!(
|
||||||
|
"Failed to load AMLENC {} library {}: {}",
|
||||||
|
codec.codec_name(),
|
||||||
|
path.display(),
|
||||||
|
error
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let abi_version: AbiVersionFn =
|
||||||
|
unsafe { required_symbol(&library, b"one_kvm_amlenc_abi_version\0", path)? };
|
||||||
|
// SAFETY: the ABI marker has no arguments or side effects.
|
||||||
|
validate_abi_version(unsafe { abi_version() }, path)?;
|
||||||
|
|
||||||
|
Ok(match codec {
|
||||||
|
AmlencCodec::H264 => {
|
||||||
|
let init: H264InitFn =
|
||||||
|
unsafe { required_symbol(&library, b"vl_video_encoder_init\0", path)? };
|
||||||
|
let encode: H264EncodeFn =
|
||||||
|
unsafe { required_symbol(&library, b"vl_video_encoder_encode\0", path)? };
|
||||||
|
let destroy: DestroyFn =
|
||||||
|
unsafe { required_symbol(&library, b"vl_video_encoder_destory\0", path)? };
|
||||||
|
Self::H264(H264Api {
|
||||||
|
_library: library,
|
||||||
|
init,
|
||||||
|
encode,
|
||||||
|
destroy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
AmlencCodec::H265 => {
|
||||||
|
let init: H265InitFn =
|
||||||
|
unsafe { required_symbol(&library, b"vl_video_encoder_init\0", path)? };
|
||||||
|
let encode: H265EncodeFn =
|
||||||
|
unsafe { required_symbol(&library, b"vl_video_encoder_encode\0", path)? };
|
||||||
|
let destroy: DestroyFn =
|
||||||
|
unsafe { required_symbol(&library, b"vl_video_encoder_destory\0", path)? };
|
||||||
|
Self::H265(H265Api {
|
||||||
|
_library: library,
|
||||||
|
init,
|
||||||
|
encode,
|
||||||
|
destroy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn init(&self, config: AmlencConfig) -> c_long {
|
||||||
|
let width = config.resolution.width as c_int;
|
||||||
|
let height = config.resolution.height as c_int;
|
||||||
|
match self {
|
||||||
|
Self::H264(api) => unsafe {
|
||||||
|
(api.init)(
|
||||||
|
CODEC_ID_H264,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
config.fps as c_int,
|
||||||
|
config.bitrate_bps(),
|
||||||
|
config.vendor_gop(),
|
||||||
|
IMG_FMT_NV12,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Self::H265(api) => unsafe {
|
||||||
|
(api.init)(
|
||||||
|
CODEC_ID_H265,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
config.fps as c_int,
|
||||||
|
config.bitrate_bps(),
|
||||||
|
config.gop as c_int,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn encode(
|
||||||
|
&self,
|
||||||
|
handle: c_long,
|
||||||
|
frame_type: c_int,
|
||||||
|
input: *mut c_uchar,
|
||||||
|
output: *mut c_uchar,
|
||||||
|
output_len: usize,
|
||||||
|
) -> c_int {
|
||||||
|
match self {
|
||||||
|
// H.264's fourth argument is documented as input length, but the pinned
|
||||||
|
// implementation uses it exclusively as output capacity.
|
||||||
|
Self::H264(api) => unsafe {
|
||||||
|
(api.encode)(
|
||||||
|
handle,
|
||||||
|
frame_type,
|
||||||
|
input,
|
||||||
|
output_len as c_int,
|
||||||
|
output,
|
||||||
|
H264_NV12_FORMAT,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Self::H265(api) => unsafe {
|
||||||
|
(api.encode)(
|
||||||
|
handle,
|
||||||
|
frame_type,
|
||||||
|
input,
|
||||||
|
output_len as c_uint,
|
||||||
|
output,
|
||||||
|
H265_NV12_FORMAT,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn destroy(&self, handle: c_long) {
|
||||||
|
match self {
|
||||||
|
Self::H264(api) => {
|
||||||
|
unsafe { (api.destroy)(handle) };
|
||||||
|
}
|
||||||
|
Self::H265(api) => {
|
||||||
|
unsafe { (api.destroy)(handle) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static AMLENC_INSTANCE_ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
struct ExclusiveInstance;
|
||||||
|
|
||||||
|
impl ExclusiveInstance {
|
||||||
|
fn acquire() -> Result<Self> {
|
||||||
|
AMLENC_INSTANCE_ACTIVE
|
||||||
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::VideoError(
|
||||||
|
"AMLENC hardware is already in use by another encoder or self-check"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ExclusiveInstance {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
AMLENC_INSTANCE_ACTIVE.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AmlencEncoder {
|
||||||
|
api: AmlencApi,
|
||||||
|
handle: c_long,
|
||||||
|
config: AmlencConfig,
|
||||||
|
output: Vec<u8>,
|
||||||
|
force_keyframe: bool,
|
||||||
|
rebuild_before_next_frame: bool,
|
||||||
|
expect_parameterized_keyframe: bool,
|
||||||
|
last_output: Instant,
|
||||||
|
_exclusive: ExclusiveInstance,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AmlencEncoder {
|
||||||
|
pub fn new(config: AmlencConfig) -> Result<Self> {
|
||||||
|
let path = env::var_os(config.codec.library_env())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(config.codec.default_library()));
|
||||||
|
Self::with_library(config, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_library(config: AmlencConfig, path: impl AsRef<OsStr>) -> Result<Self> {
|
||||||
|
config.validate()?;
|
||||||
|
let exclusive = ExclusiveInstance::acquire()?;
|
||||||
|
let path = PathBuf::from(path.as_ref());
|
||||||
|
let api = AmlencApi::load(config.codec, &path)?;
|
||||||
|
let frame_size = nv12_frame_size(config.resolution)?;
|
||||||
|
let output = vec![0; frame_size.max(MIN_OUTPUT_BUFFER_SIZE)];
|
||||||
|
let mut encoder = Self {
|
||||||
|
api,
|
||||||
|
handle: 0,
|
||||||
|
config,
|
||||||
|
output,
|
||||||
|
force_keyframe: false,
|
||||||
|
rebuild_before_next_frame: false,
|
||||||
|
expect_parameterized_keyframe: true,
|
||||||
|
last_output: Instant::now(),
|
||||||
|
_exclusive: exclusive,
|
||||||
|
};
|
||||||
|
encoder.create_handle()?;
|
||||||
|
Ok(encoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn codec_name(&self) -> &'static str {
|
||||||
|
self.config.codec.codec_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(&self) -> AmlencConfig {
|
||||||
|
self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_handle(&mut self) -> Result<()> {
|
||||||
|
debug!(
|
||||||
|
"Creating {} at {}x{} {} fps {} kbps",
|
||||||
|
self.codec_name(),
|
||||||
|
self.config.resolution.width,
|
||||||
|
self.config.resolution.height,
|
||||||
|
self.config.fps,
|
||||||
|
self.config.bitrate_kbps
|
||||||
|
);
|
||||||
|
// SAFETY: config validation guarantees values accepted by ABI v1.
|
||||||
|
self.handle = unsafe { self.api.init(self.config) };
|
||||||
|
if self.handle <= 0 {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"AMLENC {} initialization failed; check {}, firmware, CMA, and device permissions",
|
||||||
|
self.codec_name(),
|
||||||
|
self.config.codec.device_node()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// The first H.264 picture is naturally an IDR. Never pass the
|
||||||
|
// in-place FORCE_IDR command to the GXM H.264 microcode: later IDRs can
|
||||||
|
// wedge it. H.265 does not share that observed defect and retains its
|
||||||
|
// ABI-v1 forced-IRAP behavior.
|
||||||
|
self.force_keyframe = self.config.codec == AmlencCodec::H265;
|
||||||
|
self.rebuild_before_next_frame = false;
|
||||||
|
self.expect_parameterized_keyframe = true;
|
||||||
|
self.last_output = Instant::now();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn destroy_handle(&mut self) {
|
||||||
|
if self.handle > 0 {
|
||||||
|
// SAFETY: the handle was returned by this API instance and is destroyed once.
|
||||||
|
unsafe { self.api.destroy(self.handle) };
|
||||||
|
self.handle = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild(&mut self, reason: &str) -> Result<()> {
|
||||||
|
warn!("Rebuilding {} encoder: {}", self.codec_name(), reason);
|
||||||
|
self.destroy_handle();
|
||||||
|
self.create_handle()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_keyframe(&mut self) {
|
||||||
|
if self.config.codec == AmlencCodec::H264 {
|
||||||
|
// A fresh encoder reliably emits SPS/PPS + IDR on its first AUTO
|
||||||
|
// frame. Coalesce repeated client requests while a rebuild or
|
||||||
|
// fresh first frame is already pending.
|
||||||
|
if !self.expect_parameterized_keyframe {
|
||||||
|
self.rebuild_before_next_frame = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.force_keyframe = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||||
|
let mut updated = self.config;
|
||||||
|
updated.bitrate_kbps = bitrate_kbps;
|
||||||
|
updated.validate()?;
|
||||||
|
self.config = updated;
|
||||||
|
self.rebuild("bitrate changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_raw(&mut self, data: &[u8]) -> Result<Option<(Bytes, bool)>> {
|
||||||
|
let expected = nv12_frame_size(self.config.resolution)?;
|
||||||
|
if data.len() != expected {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"AMLENC requires contiguous NV12 data of exactly {} bytes (got {})",
|
||||||
|
expected,
|
||||||
|
data.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.rebuild_before_next_frame {
|
||||||
|
self.rebuild("H.264 keyframe requested")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.encode_once(data) {
|
||||||
|
Ok(frame) => Ok(frame),
|
||||||
|
Err(first_error) => {
|
||||||
|
self.rebuild(&format!("vendor encode call failed: {first_error}"))?;
|
||||||
|
self.encode_once(data).map_err(|retry_error| {
|
||||||
|
AppError::VideoError(format!(
|
||||||
|
"AMLENC encode failed after one rebuild: {}; retry: {}",
|
||||||
|
first_error, retry_error
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_once(&mut self, data: &[u8]) -> Result<Option<(Bytes, bool)>> {
|
||||||
|
if self.handle <= 0 {
|
||||||
|
return Err(AppError::VideoError(
|
||||||
|
"AMLENC handle is not initialized".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let forced = self.force_keyframe;
|
||||||
|
let require_parameterized_keyframe = self.expect_parameterized_keyframe || forced;
|
||||||
|
let frame_type = if forced {
|
||||||
|
FRAME_TYPE_IDR
|
||||||
|
} else {
|
||||||
|
FRAME_TYPE_AUTO
|
||||||
|
};
|
||||||
|
// The vendor API takes a mutable pointer but does not modify VMALLOC input.
|
||||||
|
// SAFETY: input/output live for the call, capacities are ABI-sized and the
|
||||||
|
// output length is validated before any slice is formed.
|
||||||
|
let length = unsafe {
|
||||||
|
self.api.encode(
|
||||||
|
self.handle,
|
||||||
|
frame_type,
|
||||||
|
data.as_ptr() as *mut c_uchar,
|
||||||
|
self.output.as_mut_ptr(),
|
||||||
|
self.output.len(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if length < 0 {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"{} vendor library returned {}",
|
||||||
|
self.codec_name(),
|
||||||
|
length
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// A keyframe request applies to one submitted frame. Repeating IDR on
|
||||||
|
// every zero-output call can trap the S912 driver in its light-reset
|
||||||
|
// loop; WebRTC will issue another request if this attempt was skipped.
|
||||||
|
if forced {
|
||||||
|
self.force_keyframe = false;
|
||||||
|
}
|
||||||
|
let length = length as usize;
|
||||||
|
if length > self.output.len() {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"{} returned oversized output {} > {}",
|
||||||
|
self.codec_name(),
|
||||||
|
length,
|
||||||
|
self.output.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if length == 0 {
|
||||||
|
if forced {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"{} produced no output for a forced keyframe",
|
||||||
|
self.codec_name()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// The vendor ABI uses zero for rate-control skips and recoverable
|
||||||
|
// hardware timeouts. Do not rebuild for a few skipped frames, but
|
||||||
|
// recover if the vendor stops producing output altogether.
|
||||||
|
if self.last_output.elapsed() >= OUTPUT_STALL_TIMEOUT {
|
||||||
|
self.rebuild("no encoded output for one second")?;
|
||||||
|
}
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoded = &self.output[..length];
|
||||||
|
let nal_summary = inspect_annex_b(self.config.codec, encoded);
|
||||||
|
let keyframe = nal_summary.keyframe;
|
||||||
|
if require_parameterized_keyframe
|
||||||
|
&& (!keyframe || !nal_summary.has_parameter_sets(self.config.codec))
|
||||||
|
{
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"{} fresh/forced keyframe did not contain an IRAP/IDR and complete parameter sets",
|
||||||
|
self.codec_name()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.force_keyframe = false;
|
||||||
|
self.expect_parameterized_keyframe = false;
|
||||||
|
self.last_output = Instant::now();
|
||||||
|
Ok(Some((Bytes::copy_from_slice(encoded), keyframe)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AmlencEncoder {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.destroy_handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AnnexBNalSummary {
|
||||||
|
keyframe: bool,
|
||||||
|
vps: bool,
|
||||||
|
sps: bool,
|
||||||
|
pps: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnnexBNalSummary {
|
||||||
|
fn has_parameter_sets(&self, codec: AmlencCodec) -> bool {
|
||||||
|
match codec {
|
||||||
|
AmlencCodec::H264 => self.sps && self.pps,
|
||||||
|
AmlencCodec::H265 => self.vps && self.sps && self.pps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_annex_b(codec: AmlencCodec, data: &[u8]) -> AnnexBNalSummary {
|
||||||
|
let mut summary = AnnexBNalSummary::default();
|
||||||
|
let mut index = 0;
|
||||||
|
while index + 3 <= data.len() {
|
||||||
|
let start_len = if index + 4 <= data.len() && data[index..index + 4] == [0, 0, 0, 1] {
|
||||||
|
4
|
||||||
|
} else if data[index..index + 3] == [0, 0, 1] {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let nal = index + start_len;
|
||||||
|
if nal >= data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let nal_type = match codec {
|
||||||
|
AmlencCodec::H264 => data[nal] & 0x1f,
|
||||||
|
AmlencCodec::H265 => (data[nal] >> 1) & 0x3f,
|
||||||
|
};
|
||||||
|
match codec {
|
||||||
|
AmlencCodec::H264 => match nal_type {
|
||||||
|
5 => summary.keyframe = true,
|
||||||
|
7 => summary.sps = true,
|
||||||
|
8 => summary.pps = true,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
AmlencCodec::H265 => match nal_type {
|
||||||
|
16..=23 => summary.keyframe = true,
|
||||||
|
32 => summary.vps = true,
|
||||||
|
33 => summary.sps = true,
|
||||||
|
34 => summary.pps = true,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
index = nal + 1;
|
||||||
|
}
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_keyframe(codec: AmlencCodec, data: &[u8]) -> bool {
|
||||||
|
inspect_annex_b(codec, data).keyframe
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_parameter_sets(codec: AmlencCodec, data: &[u8]) -> bool {
|
||||||
|
inspect_annex_b(codec, data).has_parameter_sets(codec)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(
|
||||||
|
not(any(test, all(target_os = "linux", target_arch = "aarch64"))),
|
||||||
|
allow(dead_code)
|
||||||
|
)]
|
||||||
|
fn is_s912_gxm_compatible(compatible: &[u8]) -> bool {
|
||||||
|
let compatible = String::from_utf8_lossy(compatible).to_ascii_lowercase();
|
||||||
|
compatible.contains("amlogic,gxm")
|
||||||
|
|| compatible.contains("amlogic, gxm")
|
||||||
|
|| compatible.contains("amlogic,meson-gxm")
|
||||||
|
|| compatible.contains("amlogic,s912")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn system_is_s912_gxm() -> Result<bool> {
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
|
||||||
|
{
|
||||||
|
let compatible = std::fs::read("/proc/device-tree/compatible").map_err(|error| {
|
||||||
|
AppError::VideoError(format!(
|
||||||
|
"Cannot read /proc/device-tree/compatible for AMLENC detection: {}",
|
||||||
|
error
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
return Ok(is_s912_gxm_compatible(&compatible));
|
||||||
|
}
|
||||||
|
#[cfg(not(all(target_os = "linux", target_arch = "aarch64")))]
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the destructive part of backend detection: initialize and encode one
|
||||||
|
/// 640x480 NV12 frame. The caller must first check SoC compatibility and node.
|
||||||
|
pub fn smoke_test(codec: AmlencCodec) -> Result<()> {
|
||||||
|
let resolution = Resolution::new(640, 480);
|
||||||
|
let config = AmlencConfig {
|
||||||
|
codec,
|
||||||
|
resolution,
|
||||||
|
fps: 30,
|
||||||
|
bitrate_kbps: 1_000,
|
||||||
|
gop: 30,
|
||||||
|
};
|
||||||
|
let mut encoder = AmlencEncoder::new(config)?;
|
||||||
|
let mut frame = vec![0x80; nv12_frame_size(resolution)?];
|
||||||
|
frame[..(resolution.width * resolution.height) as usize].fill(0x10);
|
||||||
|
for _ in 0..3 {
|
||||||
|
if encoder.encode_raw(&frame)?.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(AppError::VideoError(format!(
|
||||||
|
"{} produced no output during the 640x480 probe",
|
||||||
|
codec.codec_name()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::process::Command;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
static TEST_INSTANCE_MUTEX: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
const H264_FIXTURE: &str = r#"
|
||||||
|
static int values[16];
|
||||||
|
static int mode;
|
||||||
|
static int fail_pending;
|
||||||
|
int one_kvm_amlenc_abi_version(void) { return 1; }
|
||||||
|
long vl_video_encoder_init(int codec, int width, int height, int fps,
|
||||||
|
int bitrate, int gop, int image_format) {
|
||||||
|
values[0]++; values[1] = codec; values[2] = width; values[3] = height;
|
||||||
|
values[4] = fps; values[5] = bitrate; values[6] = gop;
|
||||||
|
values[7] = image_format; return 1;
|
||||||
|
}
|
||||||
|
int vl_video_encoder_encode(long handle, int frame_type, unsigned char *in,
|
||||||
|
int in_size, unsigned char *out, int format) {
|
||||||
|
(void)handle; (void)in; values[8]++; values[9] = frame_type;
|
||||||
|
values[10] = in_size; values[11] = format;
|
||||||
|
if (fail_pending) { fail_pending = 0; return -9; }
|
||||||
|
if (mode == 2) return 0;
|
||||||
|
if (mode == 3) return 2000000;
|
||||||
|
{ unsigned char data[] = {0,0,1,0x67,0,0,1,0x68,0,0,1,0x65};
|
||||||
|
for (unsigned long i = 0; i < sizeof(data); i++) out[i] = data[i];
|
||||||
|
return sizeof(data); }
|
||||||
|
}
|
||||||
|
int vl_video_encoder_destory(long handle) { (void)handle; values[12]++; return 1; }
|
||||||
|
int test_get(int index) { return values[index]; }
|
||||||
|
void test_set_mode(int value) { mode = value; }
|
||||||
|
void test_fail_once(void) { fail_pending = 1; }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
const H265_FIXTURE: &str = r#"
|
||||||
|
static int values[16];
|
||||||
|
static int mode;
|
||||||
|
int one_kvm_amlenc_abi_version(void) { return 1; }
|
||||||
|
long vl_video_encoder_init(int codec, int width, int height, int fps,
|
||||||
|
int bitrate, int gop) {
|
||||||
|
values[0]++; values[1] = codec; values[2] = width; values[3] = height;
|
||||||
|
values[4] = fps; values[5] = bitrate; values[6] = gop; return 1;
|
||||||
|
}
|
||||||
|
int vl_video_encoder_encode(long handle, int frame_type, unsigned char *in,
|
||||||
|
unsigned int output_len, unsigned char *out, int format) {
|
||||||
|
(void)handle; (void)in; values[8]++; values[9] = frame_type;
|
||||||
|
values[10] = output_len; values[11] = format;
|
||||||
|
if (mode == 3) return output_len + 1;
|
||||||
|
{ unsigned char data[] = {0,0,1,0x40,1,0,0,1,0x42,1,0,0,1,0x44,1,
|
||||||
|
0,0,1,0x26,1};
|
||||||
|
for (unsigned long i = 0; i < sizeof(data); i++) out[i] = data[i];
|
||||||
|
return sizeof(data); }
|
||||||
|
}
|
||||||
|
int vl_video_encoder_destory(long handle) { (void)handle; values[12]++; return 1; }
|
||||||
|
int test_get(int index) { return values[index]; }
|
||||||
|
void test_set_mode(int value) { mode = value; }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn build_fixture(directory: &Path, name: &str, source: &str) -> PathBuf {
|
||||||
|
let source_path = directory.join(format!("{name}.c"));
|
||||||
|
let library_path = directory.join(format!("lib{name}.so"));
|
||||||
|
std::fs::write(&source_path, source).unwrap();
|
||||||
|
let status = Command::new("cc")
|
||||||
|
.args(["-shared", "-fPIC"])
|
||||||
|
.arg(&source_path)
|
||||||
|
.arg("-o")
|
||||||
|
.arg(&library_path)
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success());
|
||||||
|
library_path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_geometry_fps_and_nv12_size() {
|
||||||
|
let valid = AmlencConfig {
|
||||||
|
codec: AmlencCodec::H264,
|
||||||
|
resolution: Resolution::new(1920, 1080),
|
||||||
|
fps: 60,
|
||||||
|
bitrate_kbps: 8_000,
|
||||||
|
gop: 60,
|
||||||
|
};
|
||||||
|
assert!(valid.validate().is_ok());
|
||||||
|
assert_eq!(nv12_frame_size(valid.resolution).unwrap(), 3_110_400);
|
||||||
|
|
||||||
|
for invalid in [
|
||||||
|
AmlencConfig {
|
||||||
|
resolution: Resolution::new(1919, 1080),
|
||||||
|
..valid
|
||||||
|
},
|
||||||
|
AmlencConfig {
|
||||||
|
resolution: Resolution::new(1920, 1079),
|
||||||
|
..valid
|
||||||
|
},
|
||||||
|
AmlencConfig {
|
||||||
|
resolution: Resolution::new(2560, 1440),
|
||||||
|
..valid
|
||||||
|
},
|
||||||
|
AmlencConfig { fps: 61, ..valid },
|
||||||
|
] {
|
||||||
|
assert!(invalid.validate().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognizes_vendor_and_mainline_gxm_compatibles() {
|
||||||
|
assert!(is_s912_gxm_compatible(b"amlogic, Gxm\0khadas,kvim2"));
|
||||||
|
assert!(is_s912_gxm_compatible(
|
||||||
|
b"amlogic,q200\0amlogic,s912\0amlogic,meson-gxm"
|
||||||
|
));
|
||||||
|
assert!(!is_s912_gxm_compatible(b"rockchip,rk3588"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_abi_marker() {
|
||||||
|
let path = Path::new("libvpcodec.so");
|
||||||
|
assert!(validate_abi_version(AMLENC_ABI_VERSION, path).is_ok());
|
||||||
|
assert!(validate_abi_version(0, path).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_h264_idr_and_parameter_sets() {
|
||||||
|
let data = [0, 0, 0, 1, 0x67, 1, 0, 0, 1, 0x68, 2, 0, 0, 0, 1, 0x65, 3];
|
||||||
|
assert!(is_keyframe(AmlencCodec::H264, &data));
|
||||||
|
assert!(has_parameter_sets(AmlencCodec::H264, &data));
|
||||||
|
assert!(!is_keyframe(AmlencCodec::H264, &[0, 0, 1, 0x41]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_h265_irap_and_parameter_sets() {
|
||||||
|
let data = [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
32 << 1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
33 << 1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
34 << 1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
19 << 1,
|
||||||
|
1,
|
||||||
|
];
|
||||||
|
assert!(is_keyframe(AmlencCodec::H265, &data));
|
||||||
|
assert!(has_parameter_sets(AmlencCodec::H265, &data));
|
||||||
|
assert!(!is_keyframe(AmlencCodec::H265, &[0, 0, 1, 1 << 1, 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn loads_symbols_maps_both_abis_and_recovers() {
|
||||||
|
let _test_instance = TEST_INSTANCE_MUTEX.lock().unwrap();
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let h264_path = build_fixture(directory.path(), "amlenc_h264", H264_FIXTURE);
|
||||||
|
let h265_path = build_fixture(directory.path(), "amlenc_h265", H265_FIXTURE);
|
||||||
|
|
||||||
|
type GetFn = unsafe extern "C" fn(c_int) -> c_int;
|
||||||
|
type SetModeFn = unsafe extern "C" fn(c_int);
|
||||||
|
type FailOnceFn = unsafe extern "C" fn();
|
||||||
|
|
||||||
|
// Keep this second dlopen alive so the fixture's counters remain available.
|
||||||
|
let h264_control = unsafe { Library::new(&h264_path) }.unwrap();
|
||||||
|
let h264_get: GetFn = unsafe { *h264_control.get(b"test_get\0").unwrap() };
|
||||||
|
let h264_set_mode: SetModeFn = unsafe { *h264_control.get(b"test_set_mode\0").unwrap() };
|
||||||
|
let h264_fail_once: FailOnceFn = unsafe { *h264_control.get(b"test_fail_once\0").unwrap() };
|
||||||
|
|
||||||
|
let resolution = Resolution::new(640, 480);
|
||||||
|
let frame = vec![0x80; nv12_frame_size(resolution).unwrap()];
|
||||||
|
{
|
||||||
|
let mut encoder = AmlencEncoder::with_library(
|
||||||
|
AmlencConfig {
|
||||||
|
codec: AmlencCodec::H264,
|
||||||
|
resolution,
|
||||||
|
fps: 60,
|
||||||
|
bitrate_kbps: 2_000,
|
||||||
|
gop: 60,
|
||||||
|
},
|
||||||
|
&h264_path,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(encoder.encode_raw(&frame).unwrap().unwrap().1);
|
||||||
|
// SAFETY: indices and fixture signatures are fixed above.
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(h264_get(1), CODEC_ID_H264);
|
||||||
|
assert_eq!(h264_get(4), 60);
|
||||||
|
assert_eq!(h264_get(5), 2_000_000);
|
||||||
|
assert_eq!(h264_get(6), 0);
|
||||||
|
assert_eq!(h264_get(7), IMG_FMT_NV12);
|
||||||
|
assert_eq!(h264_get(9), FRAME_TYPE_AUTO);
|
||||||
|
assert_eq!(h264_get(10), MIN_OUTPUT_BUFFER_SIZE as c_int);
|
||||||
|
assert_eq!(h264_get(11), H264_NV12_FORMAT);
|
||||||
|
|
||||||
|
h264_fail_once();
|
||||||
|
}
|
||||||
|
assert!(encoder.encode_raw(&frame).unwrap().is_some());
|
||||||
|
unsafe { assert_eq!(h264_get(0), 2) };
|
||||||
|
|
||||||
|
unsafe { h264_set_mode(2) };
|
||||||
|
encoder.request_keyframe();
|
||||||
|
assert!(encoder.encode_raw(&frame).unwrap().is_none());
|
||||||
|
unsafe { assert_eq!(h264_get(9), FRAME_TYPE_AUTO) };
|
||||||
|
unsafe { assert_eq!(h264_get(0), 3) };
|
||||||
|
assert!(encoder.encode_raw(&frame).unwrap().is_none());
|
||||||
|
unsafe { assert_eq!(h264_get(9), FRAME_TYPE_AUTO) };
|
||||||
|
assert!(encoder.encode_raw(&frame).unwrap().is_none());
|
||||||
|
unsafe { assert_eq!(h264_get(0), 3) };
|
||||||
|
|
||||||
|
encoder.last_output = Instant::now() - OUTPUT_STALL_TIMEOUT;
|
||||||
|
assert!(encoder.encode_raw(&frame).unwrap().is_none());
|
||||||
|
unsafe { assert_eq!(h264_get(0), 4) };
|
||||||
|
|
||||||
|
unsafe { h264_set_mode(0) };
|
||||||
|
encoder.set_bitrate(3_000).unwrap();
|
||||||
|
assert!(encoder.encode_raw(&frame).unwrap().unwrap().1);
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(h264_get(5), 3_000_000);
|
||||||
|
assert_eq!(h264_get(9), FRAME_TYPE_AUTO);
|
||||||
|
assert_eq!(h264_get(0), 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let h265_control = unsafe { Library::new(&h265_path) }.unwrap();
|
||||||
|
let h265_get: GetFn = unsafe { *h265_control.get(b"test_get\0").unwrap() };
|
||||||
|
let h265_set_mode: SetModeFn = unsafe { *h265_control.get(b"test_set_mode\0").unwrap() };
|
||||||
|
{
|
||||||
|
let mut encoder = AmlencEncoder::with_library(
|
||||||
|
AmlencConfig {
|
||||||
|
codec: AmlencCodec::H265,
|
||||||
|
resolution,
|
||||||
|
fps: 30,
|
||||||
|
bitrate_kbps: 1_500,
|
||||||
|
gop: 30,
|
||||||
|
},
|
||||||
|
&h265_path,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(encoder.encode_raw(&frame).unwrap().unwrap().1);
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(h265_get(1), CODEC_ID_H265);
|
||||||
|
assert_eq!(h265_get(4), 30);
|
||||||
|
assert_eq!(h265_get(5), 1_500_000);
|
||||||
|
assert_eq!(h265_get(9), FRAME_TYPE_IDR);
|
||||||
|
assert_eq!(h265_get(10), MIN_OUTPUT_BUFFER_SIZE as c_int);
|
||||||
|
assert_eq!(h265_get(11), H265_NV12_FORMAT);
|
||||||
|
h265_set_mode(3);
|
||||||
|
}
|
||||||
|
let error = encoder.encode_raw(&frame).unwrap_err().to_string();
|
||||||
|
assert!(error.contains("oversized output"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn rejects_unpatched_library_without_abi_symbol() {
|
||||||
|
let _test_instance = TEST_INSTANCE_MUTEX.lock().unwrap();
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let path = build_fixture(
|
||||||
|
directory.path(),
|
||||||
|
"unpatched_amlenc",
|
||||||
|
"long vl_video_encoder_init(void) { return 1; }",
|
||||||
|
);
|
||||||
|
let error = AmlencEncoder::with_library(
|
||||||
|
AmlencConfig {
|
||||||
|
codec: AmlencCodec::H264,
|
||||||
|
resolution: Resolution::new(640, 480),
|
||||||
|
fps: 30,
|
||||||
|
bitrate_kbps: 1_000,
|
||||||
|
gop: 30,
|
||||||
|
},
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
.err()
|
||||||
|
.expect("unpatched library must be rejected")
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("one_kvm_amlenc_abi_version"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -558,13 +558,34 @@ impl MjpegToNv12Decoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode(&mut self, input: &[u8]) -> Result<&[u8]> {
|
pub fn decode(&mut self, input: &[u8]) -> Result<&[u8]> {
|
||||||
|
self.check_size(input)?;
|
||||||
let width = self.resolution.width as i32;
|
let width = self.resolution.width as i32;
|
||||||
let height = self.resolution.height as i32;
|
let height = self.resolution.height as i32;
|
||||||
|
|
||||||
if !self.size_checked {
|
libyuv::mjpg_to_nv12(input, self.output_buffer.as_bytes_mut(), width, height)
|
||||||
let (src_width, src_height) = libyuv::mjpg_size(input).map_err(|e| {
|
.map_err(|e| AppError::VideoError(format!("libyuv MJPEG->NV12 failed: {}", e)))?;
|
||||||
AppError::VideoError(format!("libyuv MJPEG header read failed: {}", e))
|
|
||||||
})?;
|
Ok(self.output_buffer.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode into caller-owned storage so capture and encoding can run on
|
||||||
|
/// separate threads without copying a full NV12 frame.
|
||||||
|
pub fn decode_into(&mut self, input: &[u8], output: &mut Vec<u8>) -> Result<()> {
|
||||||
|
self.check_size(input)?;
|
||||||
|
let width = self.resolution.width as i32;
|
||||||
|
let height = self.resolution.height as i32;
|
||||||
|
libyuv::mjpg_to_nv12_vec(input, output, width, height)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv MJPEG->NV12 failed: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_size(&mut self, input: &[u8]) -> Result<()> {
|
||||||
|
if self.size_checked {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let width = self.resolution.width as i32;
|
||||||
|
let height = self.resolution.height as i32;
|
||||||
|
let (src_width, src_height) = libyuv::mjpg_size(input)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv MJPEG header read failed: {}", e)))?;
|
||||||
if src_width != width || src_height != height {
|
if src_width != width || src_height != height {
|
||||||
return Err(AppError::VideoError(format!(
|
return Err(AppError::VideoError(format!(
|
||||||
"libyuv MJPEG size mismatch: {}x{} (expected {}x{})",
|
"libyuv MJPEG size mismatch: {}x{} (expected {}x{})",
|
||||||
@@ -572,12 +593,7 @@ impl MjpegToNv12Decoder {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
self.size_checked = true;
|
self.size_checked = true;
|
||||||
}
|
Ok(())
|
||||||
|
|
||||||
libyuv::mjpg_to_nv12(input, self.output_buffer.as_bytes_mut(), width, height)
|
|
||||||
.map_err(|e| AppError::VideoError(format!("libyuv MJPEG->NV12 failed: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(self.output_buffer.as_bytes())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,4 +878,34 @@ mod tests {
|
|||||||
let result = converter.convert(&yuyv).unwrap();
|
let result = converter.convert(&yuyv).unwrap();
|
||||||
assert_eq!(result.len(), 24); // 4*4 + 2*2 + 2*2 = 24 bytes
|
assert_eq!(result.len(), 24); // 4*4 + 2*2 + 2*2 = 24 bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mjpeg_decode_into_reuses_output_allocation() {
|
||||||
|
let resolution = Resolution::new(16, 16);
|
||||||
|
let pixels = vec![0x80; 16 * 16 * 3];
|
||||||
|
let image = turbojpeg::Image {
|
||||||
|
pixels: pixels.as_slice(),
|
||||||
|
width: 16,
|
||||||
|
pitch: 16 * 3,
|
||||||
|
height: 16,
|
||||||
|
format: turbojpeg::PixelFormat::RGB,
|
||||||
|
};
|
||||||
|
let mut compressor = turbojpeg::Compressor::new().unwrap();
|
||||||
|
compressor.set_quality(80).unwrap();
|
||||||
|
compressor.set_subsamp(turbojpeg::Subsamp::Sub2x2).unwrap();
|
||||||
|
let jpeg = compressor.compress_to_vec(image).unwrap();
|
||||||
|
|
||||||
|
let output_size = 16 * 16 * 3 / 2;
|
||||||
|
let mut output = Vec::with_capacity(output_size);
|
||||||
|
let allocation = output.as_ptr();
|
||||||
|
let mut decoder = MjpegToNv12Decoder::new(resolution);
|
||||||
|
|
||||||
|
decoder.decode_into(&jpeg, &mut output).unwrap();
|
||||||
|
assert_eq!(output.len(), output_size);
|
||||||
|
assert_eq!(output.as_ptr(), allocation);
|
||||||
|
|
||||||
|
decoder.decode_into(&jpeg, &mut output).unwrap();
|
||||||
|
assert_eq!(output.len(), output_size);
|
||||||
|
assert_eq!(output.as_ptr(), allocation);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ pub enum H264EncoderType {
|
|||||||
Rkmpp,
|
Rkmpp,
|
||||||
/// V4L2 M2M (ARM generic) - requires hwcodec extension
|
/// V4L2 M2M (ARM generic) - requires hwcodec extension
|
||||||
V4l2M2m,
|
V4l2M2m,
|
||||||
|
/// Amlogic S912/GXM AMLENC
|
||||||
|
Amlogic,
|
||||||
/// Software encoding (libx264/openh264)
|
/// Software encoding (libx264/openh264)
|
||||||
Software,
|
Software,
|
||||||
/// No encoder available
|
/// No encoder available
|
||||||
@@ -64,6 +66,7 @@ impl std::fmt::Display for H264EncoderType {
|
|||||||
H264EncoderType::Vaapi => write!(f, "VAAPI"),
|
H264EncoderType::Vaapi => write!(f, "VAAPI"),
|
||||||
H264EncoderType::Rkmpp => write!(f, "RKMPP"),
|
H264EncoderType::Rkmpp => write!(f, "RKMPP"),
|
||||||
H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
||||||
|
H264EncoderType::Amlogic => write!(f, "AMLENC"),
|
||||||
H264EncoderType::Software => write!(f, "Software"),
|
H264EncoderType::Software => write!(f, "Software"),
|
||||||
H264EncoderType::None => write!(f, "None"),
|
H264EncoderType::None => write!(f, "None"),
|
||||||
}
|
}
|
||||||
@@ -80,6 +83,7 @@ impl From<EncoderBackend> for H264EncoderType {
|
|||||||
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
|
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
|
||||||
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
|
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
|
||||||
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
|
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
|
||||||
|
EncoderBackend::Amlogic => H264EncoderType::Amlogic,
|
||||||
EncoderBackend::Software => H264EncoderType::Software,
|
EncoderBackend::Software => H264EncoderType::Software,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ pub enum H265EncoderType {
|
|||||||
Rkmpp,
|
Rkmpp,
|
||||||
/// V4L2 M2M (ARM generic)
|
/// V4L2 M2M (ARM generic)
|
||||||
V4l2M2m,
|
V4l2M2m,
|
||||||
|
/// Amlogic S912/GXM AMLENC
|
||||||
|
Amlogic,
|
||||||
/// Software encoder (libx265)
|
/// Software encoder (libx265)
|
||||||
Software,
|
Software,
|
||||||
/// No encoder available
|
/// No encoder available
|
||||||
@@ -61,6 +63,7 @@ impl std::fmt::Display for H265EncoderType {
|
|||||||
H265EncoderType::Vaapi => write!(f, "VAAPI"),
|
H265EncoderType::Vaapi => write!(f, "VAAPI"),
|
||||||
H265EncoderType::Rkmpp => write!(f, "RKMPP"),
|
H265EncoderType::Rkmpp => write!(f, "RKMPP"),
|
||||||
H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
||||||
|
H265EncoderType::Amlogic => write!(f, "AMLENC"),
|
||||||
H265EncoderType::Software => write!(f, "Software"),
|
H265EncoderType::Software => write!(f, "Software"),
|
||||||
H265EncoderType::None => write!(f, "None"),
|
H265EncoderType::None => write!(f, "None"),
|
||||||
}
|
}
|
||||||
@@ -76,6 +79,7 @@ impl From<EncoderBackend> for H265EncoderType {
|
|||||||
EncoderBackend::Vaapi => H265EncoderType::Vaapi,
|
EncoderBackend::Vaapi => H265EncoderType::Vaapi,
|
||||||
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
|
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
|
||||||
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
|
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
|
||||||
|
EncoderBackend::Amlogic => H265EncoderType::Amlogic,
|
||||||
EncoderBackend::Software => H265EncoderType::Software,
|
EncoderBackend::Software => H265EncoderType::Software,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use hwcodec::common::DataFormat;
|
use hwcodec::common::DataFormat;
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
|
pub mod amlenc;
|
||||||
pub mod convert;
|
pub mod convert;
|
||||||
|
|
||||||
pub mod h264;
|
pub mod h264;
|
||||||
@@ -19,6 +20,7 @@ pub mod vp9;
|
|||||||
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
|
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
|
||||||
pub mod mjpeg_rkmpp;
|
pub mod mjpeg_rkmpp;
|
||||||
|
|
||||||
|
pub use amlenc::{AmlencCodec, AmlencConfig, AmlencEncoder};
|
||||||
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};
|
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};
|
||||||
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
||||||
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ use std::sync::OnceLock;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use super::amlenc::{self, AmlencCodec, AMLENC_H264_CODEC_NAME, AMLENC_H265_CODEC_NAME};
|
||||||
|
|
||||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||||
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
|
// Keep native AMLENC behind the highest-priority desktop GPU backends while
|
||||||
|
// ensuring it is selected before hwcodec's software priority (3).
|
||||||
|
const AMLENC_PRIORITY: i32 = 2;
|
||||||
|
|
||||||
/// Video encoder format type
|
/// Video encoder format type
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum VideoEncoderType {
|
pub enum VideoEncoderType {
|
||||||
@@ -96,6 +102,8 @@ pub enum EncoderBackend {
|
|||||||
Rkmpp,
|
Rkmpp,
|
||||||
/// V4L2 Memory-to-Memory (ARM)
|
/// V4L2 Memory-to-Memory (ARM)
|
||||||
V4l2m2m,
|
V4l2m2m,
|
||||||
|
/// Amlogic S912/GXM vendor AMLENC
|
||||||
|
Amlogic,
|
||||||
/// Software encoding (libx264, libx265, libvpx)
|
/// Software encoding (libx264, libx265, libvpx)
|
||||||
Software,
|
Software,
|
||||||
}
|
}
|
||||||
@@ -115,6 +123,8 @@ impl EncoderBackend {
|
|||||||
EncoderBackend::Rkmpp
|
EncoderBackend::Rkmpp
|
||||||
} else if name.contains("v4l2m2m") {
|
} else if name.contains("v4l2m2m") {
|
||||||
EncoderBackend::V4l2m2m
|
EncoderBackend::V4l2m2m
|
||||||
|
} else if name.contains("amlenc") {
|
||||||
|
EncoderBackend::Amlogic
|
||||||
} else {
|
} else {
|
||||||
EncoderBackend::Software
|
EncoderBackend::Software
|
||||||
}
|
}
|
||||||
@@ -134,6 +144,7 @@ impl EncoderBackend {
|
|||||||
EncoderBackend::Amf => "AMF",
|
EncoderBackend::Amf => "AMF",
|
||||||
EncoderBackend::Rkmpp => "RKMPP",
|
EncoderBackend::Rkmpp => "RKMPP",
|
||||||
EncoderBackend::V4l2m2m => "V4L2 M2M",
|
EncoderBackend::V4l2m2m => "V4L2 M2M",
|
||||||
|
EncoderBackend::Amlogic => "AMLENC",
|
||||||
EncoderBackend::Software => "Software",
|
EncoderBackend::Software => "Software",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +159,7 @@ impl EncoderBackend {
|
|||||||
"amf" => Some(EncoderBackend::Amf),
|
"amf" => Some(EncoderBackend::Amf),
|
||||||
"rkmpp" => Some(EncoderBackend::Rkmpp),
|
"rkmpp" => Some(EncoderBackend::Rkmpp),
|
||||||
"v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m),
|
"v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m),
|
||||||
|
"amlogic" | "amlenc" => Some(EncoderBackend::Amlogic),
|
||||||
"software" | "cpu" => Some(EncoderBackend::Software),
|
"software" | "cpu" => Some(EncoderBackend::Software),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -274,6 +286,79 @@ impl EncoderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn detect_amlenc(&mut self) {
|
||||||
|
match amlenc::system_is_s912_gxm() {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => {
|
||||||
|
debug!("AMLENC skipped: host is not Linux/aarch64 S912/GXM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!("AMLENC skipped: {}", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.detect_amlenc_candidates(
|
||||||
|
true,
|
||||||
|
|codec| std::path::Path::new(codec.device_node()).exists(),
|
||||||
|
amlenc::smoke_test,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_amlenc_candidates<NodeExists, SmokeTest>(
|
||||||
|
&mut self,
|
||||||
|
compatible: bool,
|
||||||
|
mut node_exists: NodeExists,
|
||||||
|
mut smoke_test: SmokeTest,
|
||||||
|
) where
|
||||||
|
NodeExists: FnMut(AmlencCodec) -> bool,
|
||||||
|
SmokeTest: FnMut(AmlencCodec) -> crate::error::Result<()>,
|
||||||
|
{
|
||||||
|
if !compatible {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (codec, format, codec_name) in [
|
||||||
|
(
|
||||||
|
AmlencCodec::H264,
|
||||||
|
VideoEncoderType::H264,
|
||||||
|
AMLENC_H264_CODEC_NAME,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AmlencCodec::H265,
|
||||||
|
VideoEncoderType::H265,
|
||||||
|
AMLENC_H265_CODEC_NAME,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let node = codec.device_node();
|
||||||
|
if !node_exists(codec) {
|
||||||
|
warn!(
|
||||||
|
"AMLENC {} unavailable: device node {} is missing",
|
||||||
|
format, node
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match smoke_test(codec) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.encoders
|
||||||
|
.entry(format)
|
||||||
|
.or_default()
|
||||||
|
.push(AvailableEncoder {
|
||||||
|
format,
|
||||||
|
codec_name: codec_name.to_string(),
|
||||||
|
backend: EncoderBackend::Amlogic,
|
||||||
|
priority: AMLENC_PRIORITY,
|
||||||
|
is_hardware: true,
|
||||||
|
});
|
||||||
|
info!("Registered native AMLENC encoder: {}", codec_name);
|
||||||
|
}
|
||||||
|
Err(error) => warn!("AMLENC {} unavailable ({}): {}", format, node, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the global registry instance
|
/// Get the global registry instance
|
||||||
///
|
///
|
||||||
/// The registry is initialized lazily on first access with 1280x720 detection.
|
/// The registry is initialized lazily on first access with 1280x720 detection.
|
||||||
@@ -341,6 +426,8 @@ impl EncoderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.detect_amlenc();
|
||||||
|
|
||||||
// Sort encoders by priority (lower is better)
|
// Sort encoders by priority (lower is better)
|
||||||
for encoders in self.encoders.values_mut() {
|
for encoders in self.encoders.values_mut() {
|
||||||
encoders.sort_by_key(|e| e.priority);
|
encoders.sort_by_key(|e| e.priority);
|
||||||
@@ -537,6 +624,14 @@ mod tests {
|
|||||||
EncoderBackend::from_codec_name("libx264"),
|
EncoderBackend::from_codec_name("libx264"),
|
||||||
EncoderBackend::Software
|
EncoderBackend::Software
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
EncoderBackend::from_codec_name("h264_amlenc"),
|
||||||
|
EncoderBackend::Amlogic
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
EncoderBackend::from_str("amlogic"),
|
||||||
|
Some(EncoderBackend::Amlogic)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -561,4 +656,65 @@ mod tests {
|
|||||||
println!("Available formats: {:?}", registry.available_formats(false));
|
println!("Available formats: {:?}", registry.available_formats(false));
|
||||||
println!("Selectable formats: {:?}", registry.selectable_formats());
|
println!("Selectable formats: {:?}", registry.selectable_formats());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_amlenc_registration_prerequisite_matrix() {
|
||||||
|
let ok = |_codec| Ok(());
|
||||||
|
|
||||||
|
let mut incompatible = EncoderRegistry::new();
|
||||||
|
incompatible.detect_amlenc_candidates(false, |_| true, ok);
|
||||||
|
assert!(incompatible.encoders.is_empty());
|
||||||
|
|
||||||
|
let mut no_nodes = EncoderRegistry::new();
|
||||||
|
no_nodes.detect_amlenc_candidates(true, |_| false, ok);
|
||||||
|
assert!(no_nodes.encoders.is_empty());
|
||||||
|
|
||||||
|
for reason in ["library missing", "ABI marker missing"] {
|
||||||
|
let mut rejected = EncoderRegistry::new();
|
||||||
|
rejected.detect_amlenc_candidates(
|
||||||
|
true,
|
||||||
|
|_| true,
|
||||||
|
|_| Err(crate::error::AppError::VideoError(reason.to_string())),
|
||||||
|
);
|
||||||
|
assert!(rejected.encoders.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut h264_only = EncoderRegistry::new();
|
||||||
|
h264_only.detect_amlenc_candidates(true, |codec| codec == AmlencCodec::H264, ok);
|
||||||
|
assert!(h264_only
|
||||||
|
.encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Amlogic)
|
||||||
|
.is_some());
|
||||||
|
assert!(h264_only
|
||||||
|
.encoder_with_backend(VideoEncoderType::H265, EncoderBackend::Amlogic)
|
||||||
|
.is_none());
|
||||||
|
|
||||||
|
let mut both = EncoderRegistry::new();
|
||||||
|
both.detect_amlenc_candidates(true, |_| true, ok);
|
||||||
|
assert!(both
|
||||||
|
.encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Amlogic)
|
||||||
|
.is_some());
|
||||||
|
assert!(both
|
||||||
|
.encoder_with_backend(VideoEncoderType::H265, EncoderBackend::Amlogic)
|
||||||
|
.is_some());
|
||||||
|
|
||||||
|
both.encoders
|
||||||
|
.entry(VideoEncoderType::H264)
|
||||||
|
.or_default()
|
||||||
|
.push(AvailableEncoder {
|
||||||
|
format: VideoEncoderType::H264,
|
||||||
|
codec_name: "libx264".to_string(),
|
||||||
|
backend: EncoderBackend::Software,
|
||||||
|
priority: 3,
|
||||||
|
is_hardware: false,
|
||||||
|
});
|
||||||
|
both.encoders
|
||||||
|
.get_mut(&VideoEncoderType::H264)
|
||||||
|
.unwrap()
|
||||||
|
.sort_by_key(|encoder| encoder.priority);
|
||||||
|
assert_eq!(
|
||||||
|
both.best_available_encoder(VideoEncoderType::H264)
|
||||||
|
.map(|encoder| encoder.backend),
|
||||||
|
Some(EncoderBackend::Amlogic)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use std::sync::mpsc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
|
AmlencCodec, AmlencConfig, AmlencEncoder, EncoderRegistry, H264Config, H264Encoder, H265Config,
|
||||||
VP9Config, VP9Encoder, VideoEncoderType,
|
H265Encoder, VP8Config, VP8Encoder, VP9Config, VP9Encoder, VideoEncoderType,
|
||||||
};
|
};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
@@ -226,6 +226,9 @@ fn run_smoke_test(
|
|||||||
resolution: Resolution,
|
resolution: Resolution,
|
||||||
codec_name_ffmpeg: &str,
|
codec_name_ffmpeg: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
if codec_name_ffmpeg.contains("amlenc") {
|
||||||
|
return run_amlenc_smoke_test(codec, resolution);
|
||||||
|
}
|
||||||
match codec {
|
match codec {
|
||||||
VideoEncoderType::H264 => run_h264_smoke_test(resolution, codec_name_ffmpeg),
|
VideoEncoderType::H264 => run_h264_smoke_test(resolution, codec_name_ffmpeg),
|
||||||
VideoEncoderType::H265 => run_h265_smoke_test(resolution, codec_name_ffmpeg),
|
VideoEncoderType::H265 => run_h265_smoke_test(resolution, codec_name_ffmpeg),
|
||||||
@@ -234,6 +237,37 @@ fn run_smoke_test(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_amlenc_smoke_test(codec: VideoEncoderType, resolution: Resolution) -> Result<()> {
|
||||||
|
let amlenc_codec = match codec {
|
||||||
|
VideoEncoderType::H264 => AmlencCodec::H264,
|
||||||
|
VideoEncoderType::H265 => AmlencCodec::H265,
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::VideoError(
|
||||||
|
"AMLENC only supports H.264 and H.265".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut encoder = AmlencEncoder::new(AmlencConfig {
|
||||||
|
codec: amlenc_codec,
|
||||||
|
resolution,
|
||||||
|
fps: 30,
|
||||||
|
bitrate_kbps: bitrate_kbps_for_resolution(resolution),
|
||||||
|
gop: 30,
|
||||||
|
})?;
|
||||||
|
let frame_len = PixelFormat::Nv12.frame_size(resolution).ok_or_else(|| {
|
||||||
|
AppError::VideoError("Cannot calculate AMLENC NV12 self-check size".to_string())
|
||||||
|
})?;
|
||||||
|
let frame = build_nv12_test_frame(resolution, frame_len);
|
||||||
|
for _ in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||||
|
if encoder.encode_raw(&frame)?.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(AppError::VideoError(
|
||||||
|
"AMLENC produced no output after multiple frames".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||||
let mut encoder = H264Encoder::with_codec(
|
let mut encoder = H264Encoder::with_codec(
|
||||||
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
//! CSI/HDMI bridge helpers: subdev discovery, DV probe, RK628 "fake VGA" filter (must run before `S_FMT` / `STREAMON` on capture — see RK628 driver).
|
//! CSI/HDMI bridge helpers: subdev discovery, DV probe, RK628 "fake VGA" filter (must run before `S_FMT` / `STREAMON` on capture — see RK628 driver).
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::fd::{AsFd, AsRawFd, FromRawFd};
|
use std::os::fd::{AsFd, AsRawFd, FromRawFd};
|
||||||
|
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
@@ -14,7 +16,9 @@ use tracing::{debug, info, warn};
|
|||||||
use v4l2r::bindings::{
|
use v4l2r::bindings::{
|
||||||
v4l2_bt_timings, v4l2_dv_timings, V4L2_DV_BT_656_1120, V4L2_DV_FL_HAS_CEA861_VIC,
|
v4l2_bt_timings, v4l2_dv_timings, V4L2_DV_BT_656_1120, V4L2_DV_FL_HAS_CEA861_VIC,
|
||||||
};
|
};
|
||||||
use v4l2r::ioctl::{self, Event as V4l2Event, EventType, QueryDvTimingsError, SubscribeEventFlags};
|
use v4l2r::ioctl::{
|
||||||
|
self, Event as V4l2Event, EventType, IntoErrno, QueryDvTimingsError, SubscribeEventFlags,
|
||||||
|
};
|
||||||
use v4l2r::nix::errno::Errno;
|
use v4l2r::nix::errno::Errno;
|
||||||
|
|
||||||
use crate::video::signal::SignalStatus;
|
use crate::video::signal::SignalStatus;
|
||||||
@@ -53,6 +57,7 @@ pub enum ProbeResult {
|
|||||||
NoSync,
|
NoSync,
|
||||||
OutOfRange,
|
OutOfRange,
|
||||||
NoSignal,
|
NoSignal,
|
||||||
|
Unavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProbeResult {
|
impl ProbeResult {
|
||||||
@@ -63,6 +68,7 @@ impl ProbeResult {
|
|||||||
ProbeResult::NoSync => Some(SignalStatus::NoSync),
|
ProbeResult::NoSync => Some(SignalStatus::NoSync),
|
||||||
ProbeResult::OutOfRange => Some(SignalStatus::OutOfRange),
|
ProbeResult::OutOfRange => Some(SignalStatus::OutOfRange),
|
||||||
ProbeResult::NoSignal => Some(SignalStatus::NoSignal),
|
ProbeResult::NoSignal => Some(SignalStatus::NoSignal),
|
||||||
|
ProbeResult::Unavailable => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,59 +98,446 @@ impl std::fmt::Debug for DvTimingsMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Heuristic: scan `/sys/class/video4linux/v4l-subdev*` names for rk628 / hdmirx / tc358743.
|
const SYSFS_VIDEO4LINUX: &str = "/sys/class/video4linux";
|
||||||
pub fn discover_subdev_for_video(video_path: &Path) -> Option<(PathBuf, CsiBridgeKind)> {
|
const DEV_ROOT: &str = "/dev";
|
||||||
let sysfs_base = Path::new("/sys/class/video4linux");
|
const MEDIA_ENT_ID_FLAG_NEXT: u32 = 1 << 31;
|
||||||
let entries = std::fs::read_dir(sysfs_base).ok()?;
|
const MEDIA_LNK_FL_ENABLED: u32 = 1 << 0;
|
||||||
|
const MEDIA_LNK_FL_LINK_TYPE: u32 = 0xf << 28;
|
||||||
|
const MEDIA_LNK_FL_DATA_LINK: u32 = 0 << 28;
|
||||||
|
|
||||||
for entry in entries.flatten() {
|
#[repr(C)]
|
||||||
let name = entry.file_name();
|
#[derive(Clone, Copy)]
|
||||||
let name_str = name.to_string_lossy();
|
struct MediaEntityDesc {
|
||||||
if !name_str.starts_with("v4l-subdev") {
|
id: u32,
|
||||||
continue;
|
name: [u8; 32],
|
||||||
|
type_: u32,
|
||||||
|
revision: u32,
|
||||||
|
flags: u32,
|
||||||
|
group_id: u32,
|
||||||
|
pads: u16,
|
||||||
|
links: u16,
|
||||||
|
reserved: [u32; 4],
|
||||||
|
info: MediaEntityInfo,
|
||||||
}
|
}
|
||||||
let Some(kind) = read_sysfs_name(&entry.path())
|
|
||||||
.as_deref()
|
#[repr(C)]
|
||||||
.and_then(CsiBridgeKind::from_subdev_name)
|
#[derive(Clone, Copy)]
|
||||||
else {
|
struct MediaDeviceNode {
|
||||||
continue;
|
major: u32,
|
||||||
};
|
minor: u32,
|
||||||
let dev_path = PathBuf::from("/dev").join(&*name_str);
|
}
|
||||||
if dev_path.exists() {
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
union MediaEntityInfo {
|
||||||
|
dev: MediaDeviceNode,
|
||||||
|
_raw: [u8; 184],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MediaEntityDesc {
|
||||||
|
fn default() -> Self {
|
||||||
|
// This mirrors the zero-initialization required by the media UAPI.
|
||||||
|
unsafe { std::mem::zeroed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
struct MediaPadDesc {
|
||||||
|
entity: u32,
|
||||||
|
index: u16,
|
||||||
|
flags: u32,
|
||||||
|
reserved: [u32; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
struct MediaLinkDesc {
|
||||||
|
source: MediaPadDesc,
|
||||||
|
sink: MediaPadDesc,
|
||||||
|
flags: u32,
|
||||||
|
reserved: [u32; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct MediaLinksEnum {
|
||||||
|
entity: u32,
|
||||||
|
pads: *mut MediaPadDesc,
|
||||||
|
links: *mut MediaLinkDesc,
|
||||||
|
reserved: [u32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
nix::ioctl_readwrite!(media_ioc_enum_entities, b'|', 0x01, MediaEntityDesc);
|
||||||
|
nix::ioctl_readwrite!(media_ioc_enum_links, b'|', 0x02, MediaLinksEnum);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct MediaGraphEntity {
|
||||||
|
id: u32,
|
||||||
|
name: String,
|
||||||
|
major: u32,
|
||||||
|
minor: u32,
|
||||||
|
pads: u16,
|
||||||
|
links: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct MediaGraphLink {
|
||||||
|
source: u32,
|
||||||
|
sink: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MediaGraph {
|
||||||
|
path: PathBuf,
|
||||||
|
entities: Vec<MediaGraphEntity>,
|
||||||
|
links: Vec<MediaGraphLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the CSI/HDMI bridge that is connected to `video_path` in the same
|
||||||
|
/// media-controller graph. Name-only global scans are deliberately avoided:
|
||||||
|
/// boards can expose RK628, native HDMI RX and USB capture at the same time.
|
||||||
|
pub fn discover_subdev_for_video(video_path: &Path) -> Option<(PathBuf, CsiBridgeKind)> {
|
||||||
|
match discover_subdev_for_video_inner(video_path) {
|
||||||
|
Ok(Some((path, kind, media_path))) => {
|
||||||
info!(
|
info!(
|
||||||
"Discovered CSI bridge subdev for {:?}: {:?} ({:?})",
|
"Discovered CSI bridge subdev for {:?}: {:?} ({:?}) via {:?}",
|
||||||
video_path, dev_path, kind
|
video_path, path, kind, media_path
|
||||||
);
|
);
|
||||||
return Some((dev_path, kind));
|
Some((path, kind))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Ok(None) => {
|
||||||
debug!(
|
debug!(
|
||||||
"No CSI bridge subdev found in /sys/class/video4linux for {:?}",
|
"No connected CSI bridge subdev found in media topology for {:?}",
|
||||||
video_path
|
video_path
|
||||||
);
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!(
|
||||||
|
"Failed to inspect media topology for {:?}: {}",
|
||||||
|
video_path, error
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn read_sysfs_name(subdev_sysfs: &Path) -> Option<String> {
|
fn discover_subdev_for_video_inner(
|
||||||
std::fs::read_to_string(subdev_sysfs.join("name"))
|
video_path: &Path,
|
||||||
.ok()
|
) -> io::Result<Option<(PathBuf, CsiBridgeKind, PathBuf)>> {
|
||||||
.map(|s| s.trim().to_string())
|
let video_device = device_numbers(video_path)?;
|
||||||
|
let mut graphs = Vec::new();
|
||||||
|
let mut first_error = None;
|
||||||
|
|
||||||
|
// A physical device may expose more than one media controller. Inspect
|
||||||
|
// every controller and use the graph that actually contains video_path;
|
||||||
|
// choosing the first mediaN node merely moves the old global-scan bug.
|
||||||
|
for media_path in media_device_paths()? {
|
||||||
|
let graph = File::open(&media_path).and_then(|media| {
|
||||||
|
read_media_graph(&media).map(|(entities, links)| MediaGraph {
|
||||||
|
path: media_path.clone(),
|
||||||
|
entities,
|
||||||
|
links,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
match graph {
|
||||||
|
Ok(graph) => graphs.push(graph),
|
||||||
|
Err(error) => {
|
||||||
|
debug!(
|
||||||
|
"Failed to inspect media controller {:?}: {}",
|
||||||
|
media_path, error
|
||||||
|
);
|
||||||
|
first_error.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let graph_contains_video = graphs.iter().any(|graph| {
|
||||||
|
graph
|
||||||
|
.entities
|
||||||
|
.iter()
|
||||||
|
.any(|entity| (entity.major, entity.minor) == video_device)
|
||||||
|
});
|
||||||
|
let Some((media_path, entity, kind)) = connected_bridge_in_media_graphs(&graphs, video_device)
|
||||||
|
else {
|
||||||
|
if !graph_contains_video {
|
||||||
|
if let Some(error) = first_error {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(subdev_path) = video4linux_devnode((entity.major, entity.minor))? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if !subdev_path
|
||||||
|
.file_name()
|
||||||
|
.is_some_and(|name| name.to_string_lossy().starts_with("v4l-subdev"))
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some((subdev_path, kind, media_path.to_path_buf())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_numbers(path: &Path) -> io::Result<(u32, u32)> {
|
||||||
|
let rdev = std::fs::metadata(path)?.rdev();
|
||||||
|
let major = nix::sys::stat::major(rdev);
|
||||||
|
let minor = nix::sys::stat::minor(rdev);
|
||||||
|
Ok((major as u32, minor as u32))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_device_numbers(value: &str) -> Option<(u32, u32)> {
|
||||||
|
let (major, minor) = value.trim().split_once(':')?;
|
||||||
|
Some((major.parse().ok()?, minor.parse().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn video4linux_class_entry(device: (u32, u32)) -> io::Result<Option<PathBuf>> {
|
||||||
|
for entry in std::fs::read_dir(SYSFS_VIDEO4LINUX)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let dev = match std::fs::read_to_string(entry.path().join("dev")) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if parse_device_numbers(&dev) == Some(device) {
|
||||||
|
return Ok(Some(entry.path()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn video4linux_devnode(device: (u32, u32)) -> io::Result<Option<PathBuf>> {
|
||||||
|
let Some(class_entry) = video4linux_class_entry(device)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(name) = class_entry.file_name() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let path = Path::new(DEV_ROOT).join(name);
|
||||||
|
Ok(path.exists().then_some(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_device_paths() -> io::Result<Vec<PathBuf>> {
|
||||||
|
let mut media_nodes = std::fs::read_dir(DEV_ROOT)?
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_str()?;
|
||||||
|
let index = media_device_index(name)?;
|
||||||
|
Some((index, entry.path()))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
media_nodes.sort_by(|(left_index, left_path), (right_index, right_path)| {
|
||||||
|
left_index
|
||||||
|
.cmp(right_index)
|
||||||
|
.then_with(|| left_path.cmp(right_path))
|
||||||
|
});
|
||||||
|
Ok(media_nodes.into_iter().map(|(_, path)| path).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn media_device_index(name: &str) -> Option<u32> {
|
||||||
|
let suffix = name.strip_prefix("media")?;
|
||||||
|
if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
suffix.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_media_graph(media: &File) -> io::Result<(Vec<MediaGraphEntity>, Vec<MediaGraphLink>)> {
|
||||||
|
let mut entities = Vec::new();
|
||||||
|
let mut previous_id = 0u32;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut desc = MediaEntityDesc {
|
||||||
|
id: previous_id | MEDIA_ENT_ID_FLAG_NEXT,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: `desc` has the exact media_entity_desc UAPI layout and is
|
||||||
|
// writable for the duration of the ioctl.
|
||||||
|
match unsafe { media_ioc_enum_entities(media.as_raw_fd(), &mut desc) } {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(Errno::EINVAL) => break,
|
||||||
|
Err(error) => return Err(io::Error::from_raw_os_error(error as i32)),
|
||||||
|
}
|
||||||
|
if desc.id == previous_id || entities.len() >= 4096 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"media entity enumeration did not advance",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
previous_id = desc.id;
|
||||||
|
|
||||||
|
let nul = desc
|
||||||
|
.name
|
||||||
|
.iter()
|
||||||
|
.position(|byte| *byte == 0)
|
||||||
|
.unwrap_or(desc.name.len());
|
||||||
|
let name = String::from_utf8_lossy(&desc.name[..nul]).into_owned();
|
||||||
|
// SAFETY: the kernel filled the `dev` member of the media UAPI union
|
||||||
|
// for V4L2 devnode entities. Non-devnode entities report zeroes.
|
||||||
|
let device = unsafe { desc.info.dev };
|
||||||
|
entities.push(MediaGraphEntity {
|
||||||
|
id: desc.id,
|
||||||
|
name,
|
||||||
|
major: device.major,
|
||||||
|
minor: device.minor,
|
||||||
|
pads: desc.pads,
|
||||||
|
links: desc.links,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut graph_links = HashSet::new();
|
||||||
|
for entity in &entities {
|
||||||
|
let mut pads = vec![MediaPadDesc::default(); entity.pads as usize];
|
||||||
|
let mut links = vec![MediaLinkDesc::default(); entity.links as usize];
|
||||||
|
let mut request = MediaLinksEnum {
|
||||||
|
entity: entity.id,
|
||||||
|
pads: if pads.is_empty() {
|
||||||
|
std::ptr::null_mut()
|
||||||
|
} else {
|
||||||
|
pads.as_mut_ptr()
|
||||||
|
},
|
||||||
|
links: if links.is_empty() {
|
||||||
|
std::ptr::null_mut()
|
||||||
|
} else {
|
||||||
|
links.as_mut_ptr()
|
||||||
|
},
|
||||||
|
reserved: [0; 4],
|
||||||
|
};
|
||||||
|
// SAFETY: the vectors provide the number of entries reported by the
|
||||||
|
// entity descriptor and remain alive while the kernel fills them.
|
||||||
|
unsafe { media_ioc_enum_links(media.as_raw_fd(), &mut request) }
|
||||||
|
.map_err(|error| io::Error::from_raw_os_error(error as i32))?;
|
||||||
|
|
||||||
|
for link in links {
|
||||||
|
if link.flags & MEDIA_LNK_FL_ENABLED == 0
|
||||||
|
|| link.flags & MEDIA_LNK_FL_LINK_TYPE != MEDIA_LNK_FL_DATA_LINK
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
graph_links.insert((link.source.entity, link.sink.entity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut links = graph_links
|
||||||
|
.into_iter()
|
||||||
|
.map(|(source, sink)| MediaGraphLink { source, sink })
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
links.sort_by_key(|link| (link.source, link.sink));
|
||||||
|
Ok((entities, links))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connected_bridge_entity<'a>(
|
||||||
|
entities: &'a [MediaGraphEntity],
|
||||||
|
links: &[MediaGraphLink],
|
||||||
|
video_device: (u32, u32),
|
||||||
|
) -> Option<(&'a MediaGraphEntity, CsiBridgeKind)> {
|
||||||
|
let start = entities
|
||||||
|
.iter()
|
||||||
|
.find(|entity| (entity.major, entity.minor) == video_device)?;
|
||||||
|
let by_id = entities
|
||||||
|
.iter()
|
||||||
|
.map(|entity| (entity.id, entity))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let mut upstream = HashMap::<u32, Vec<u32>>::new();
|
||||||
|
for link in links {
|
||||||
|
// Media data flows source -> sink. Starting at a capture video node,
|
||||||
|
// only sink -> source traversal can lead to its real input bridge.
|
||||||
|
upstream.entry(link.sink).or_default().push(link.source);
|
||||||
|
}
|
||||||
|
for neighbors in upstream.values_mut() {
|
||||||
|
neighbors.sort_unstable();
|
||||||
|
neighbors.dedup();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visited = HashSet::from([start.id]);
|
||||||
|
let mut queue = VecDeque::from([start.id]);
|
||||||
|
while let Some(id) = queue.pop_front() {
|
||||||
|
if id != start.id {
|
||||||
|
if let Some(entity) = by_id.get(&id) {
|
||||||
|
if entity.major != 0 || entity.minor != 0 {
|
||||||
|
if let Some(kind) = CsiBridgeKind::from_subdev_name(&entity.name) {
|
||||||
|
return Some((entity, kind));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(neighbors) = upstream.get(&id) {
|
||||||
|
for neighbor in neighbors {
|
||||||
|
if visited.insert(*neighbor) {
|
||||||
|
queue.push_back(*neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connected_bridge_in_media_graphs(
|
||||||
|
graphs: &[MediaGraph],
|
||||||
|
video_device: (u32, u32),
|
||||||
|
) -> Option<(&Path, &MediaGraphEntity, CsiBridgeKind)> {
|
||||||
|
graphs.iter().find_map(|graph| {
|
||||||
|
connected_bridge_entity(&graph.entities, &graph.links, video_device)
|
||||||
|
.map(|(entity, kind)| (graph.path.as_path(), entity, kind))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_subdev(path: &Path) -> io::Result<File> {
|
pub fn open_subdev(path: &Path) -> io::Result<File> {
|
||||||
File::options().read(true).write(true).open(path)
|
File::options()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.custom_flags(libc::O_NONBLOCK)
|
||||||
|
.open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain a non-blocking V4L2 event queue until the driver reports that it is
|
||||||
|
/// empty. Both video nodes and subdevices use the same event ioctl contract.
|
||||||
|
pub fn drain_v4l2_events(fd: &File) -> u32 {
|
||||||
|
let mut drained = 0u32;
|
||||||
|
loop {
|
||||||
|
match ioctl::dqevent::<V4l2Event>(fd) {
|
||||||
|
Ok(_event) => {
|
||||||
|
drained = drained.saturating_add(1);
|
||||||
|
if drained >= 16 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let message = error.to_string();
|
||||||
|
let errno = error.into_errno();
|
||||||
|
if errno != Errno::EAGAIN as i32 && errno != Errno::ENOENT as i32 {
|
||||||
|
debug!("Failed to drain V4L2 event queue: {}", message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drained
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn probe_signal(subdev_fd: &impl AsRawFd, kind: CsiBridgeKind) -> ProbeResult {
|
pub fn probe_signal(subdev_fd: &impl AsRawFd, kind: CsiBridgeKind) -> ProbeResult {
|
||||||
match ioctl::query_dv_timings::<v4l2_dv_timings>(subdev_fd) {
|
match ioctl::query_dv_timings::<v4l2_dv_timings>(subdev_fd) {
|
||||||
Ok(timings) => classify_timings(timings, kind),
|
Ok(timings) => classify_timings(timings, kind),
|
||||||
Err(QueryDvTimingsError::NoLink) => ProbeResult::NoCable,
|
Err(error) => classify_query_error(&error, kind),
|
||||||
Err(QueryDvTimingsError::UnstableSignal) => ProbeResult::NoSync,
|
}
|
||||||
Err(QueryDvTimingsError::IoctlError(Errno::ERANGE)) => ProbeResult::OutOfRange,
|
}
|
||||||
Err(QueryDvTimingsError::IoctlError(Errno::EIO | Errno::EREMOTEIO | Errno::ETIMEDOUT)) => {
|
|
||||||
|
fn classify_query_error(error: &QueryDvTimingsError, kind: CsiBridgeKind) -> ProbeResult {
|
||||||
|
match error {
|
||||||
|
QueryDvTimingsError::NoLink => ProbeResult::NoCable,
|
||||||
|
QueryDvTimingsError::UnstableSignal => ProbeResult::NoSync,
|
||||||
|
QueryDvTimingsError::IoctlError(Errno::ERANGE) => ProbeResult::OutOfRange,
|
||||||
|
QueryDvTimingsError::IoctlError(Errno::EIO | Errno::EREMOTEIO | Errno::ETIMEDOUT) => {
|
||||||
ProbeResult::NoSync
|
ProbeResult::NoSync
|
||||||
}
|
}
|
||||||
Err(QueryDvTimingsError::Unsupported) | Err(QueryDvTimingsError::IoctlError(_)) => {
|
QueryDvTimingsError::Unsupported
|
||||||
|
| QueryDvTimingsError::IoctlError(
|
||||||
|
Errno::ENOTTY | Errno::EINVAL | Errno::ENOSYS | Errno::EOPNOTSUPP,
|
||||||
|
) if kind == CsiBridgeKind::Unknown => ProbeResult::Unavailable,
|
||||||
|
QueryDvTimingsError::Unsupported | QueryDvTimingsError::IoctlError(_) => {
|
||||||
ProbeResult::NoSignal
|
ProbeResult::NoSignal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +572,7 @@ pub fn probe_signal_thread_timeout(
|
|||||||
Some(r)
|
Some(r)
|
||||||
}
|
}
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
warn!(
|
debug!(
|
||||||
"QUERY_DV_TIMINGS exceeded {:?} (RK628 HDMI mode change?) — abandoning probe thread",
|
"QUERY_DV_TIMINGS exceeded {:?} (RK628 HDMI mode change?) — abandoning probe thread",
|
||||||
limit
|
limit
|
||||||
);
|
);
|
||||||
@@ -286,13 +679,7 @@ pub fn wait_source_change(subdev_fd: &File, timeout: Duration) -> io::Result<boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut drained = 0u32;
|
let drained = drain_v4l2_events(subdev_fd);
|
||||||
while let Ok(_ev) = ioctl::dqevent::<V4l2Event>(subdev_fd) {
|
|
||||||
drained = drained.saturating_add(1);
|
|
||||||
if drained >= 16 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debug!("subdev source_change drained {} event(s)", drained);
|
debug!("subdev source_change drained {} event(s)", drained);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -301,6 +688,144 @@ pub fn wait_source_change(subdev_fd: &File, timeout: Duration) -> io::Result<boo
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn media_graph(
|
||||||
|
path: &str,
|
||||||
|
entities: Vec<MediaGraphEntity>,
|
||||||
|
links: Vec<MediaGraphLink>,
|
||||||
|
) -> MediaGraph {
|
||||||
|
MediaGraph {
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
entities,
|
||||||
|
links,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_entity(id: u32, name: &str, device: (u32, u32)) -> MediaGraphEntity {
|
||||||
|
MediaGraphEntity {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
major: device.0,
|
||||||
|
minor: device.1,
|
||||||
|
pads: 0,
|
||||||
|
links: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_link(source: u32, sink: u32) -> MediaGraphLink {
|
||||||
|
MediaGraphLink { source, sink }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_uapi_layout_matches_linux_legacy_api() {
|
||||||
|
assert_eq!(std::mem::size_of::<MediaEntityDesc>(), 256);
|
||||||
|
assert_eq!(std::mem::size_of::<MediaPadDesc>(), 20);
|
||||||
|
assert_eq!(std::mem::size_of::<MediaLinkDesc>(), 52);
|
||||||
|
#[cfg(target_pointer_width = "64")]
|
||||||
|
assert_eq!(std::mem::size_of::<MediaLinksEnum>(), 40);
|
||||||
|
#[cfg(target_pointer_width = "32")]
|
||||||
|
assert_eq!(std::mem::size_of::<MediaLinksEnum>(), 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_device_names_require_a_numeric_suffix() {
|
||||||
|
assert_eq!(media_device_index("media0"), Some(0));
|
||||||
|
assert_eq!(media_device_index("media12"), Some(12));
|
||||||
|
assert_eq!(media_device_index("media"), None);
|
||||||
|
assert_eq!(media_device_index("media-controller"), None);
|
||||||
|
assert_eq!(media_device_index("video0"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subdevice_handles_are_non_blocking() {
|
||||||
|
let file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let opened = open_subdev(file.path()).unwrap();
|
||||||
|
let flags = unsafe { libc::fcntl(opened.as_raw_fd(), libc::F_GETFL) };
|
||||||
|
assert!(flags >= 0);
|
||||||
|
assert_ne!(flags & libc::O_NONBLOCK, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_graph_finds_only_the_connected_rk628() {
|
||||||
|
// Captured shape of the RK3588 RKCIF graph:
|
||||||
|
// video0 <- mipi-csi2 <- dphy <- RK628. A second RK628 entity is
|
||||||
|
// present in the same synthetic topology but is not connected.
|
||||||
|
let entities = vec![
|
||||||
|
graph_entity(1, "stream_cif_mipi_id0", (81, 0)),
|
||||||
|
graph_entity(45, "rockchip-mipi-csi2", (0, 0)),
|
||||||
|
graph_entity(58, "rockchip-csi2-dphy0", (0, 0)),
|
||||||
|
graph_entity(63, "m00_b_rk628-csi 3-0050", (81, 16)),
|
||||||
|
graph_entity(90, "other-rk628-csi 7-0050", (81, 19)),
|
||||||
|
];
|
||||||
|
let links = vec![graph_link(63, 58), graph_link(58, 45), graph_link(45, 1)];
|
||||||
|
|
||||||
|
let (entity, kind) = connected_bridge_entity(&entities, &links, (81, 0)).unwrap();
|
||||||
|
assert_eq!(entity.id, 63);
|
||||||
|
assert_eq!(kind, CsiBridgeKind::Rk628);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_graph_search_only_walks_towards_link_sources() {
|
||||||
|
let entities = vec![
|
||||||
|
graph_entity(1, "stream_cif_mipi_id0", (81, 0)),
|
||||||
|
graph_entity(20, "rockchip-mipi-csi2", (0, 0)),
|
||||||
|
graph_entity(63, "unrelated-rk628-csi 7-0050", (81, 19)),
|
||||||
|
];
|
||||||
|
// Entity 20 is upstream of the capture node. Entity 63 is downstream
|
||||||
|
// of 20 and must not be reached while tracing the capture input.
|
||||||
|
let links = vec![graph_link(20, 1), graph_link(20, 63)];
|
||||||
|
|
||||||
|
assert!(connected_bridge_entity(&entities, &links, (81, 0)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_graphs_select_the_controller_containing_the_video_node() {
|
||||||
|
let graphs = vec![
|
||||||
|
media_graph(
|
||||||
|
"/dev/media0",
|
||||||
|
vec![
|
||||||
|
graph_entity(1, "other-video", (81, 4)),
|
||||||
|
graph_entity(63, "wrong-rk628-csi", (81, 16)),
|
||||||
|
],
|
||||||
|
vec![graph_link(63, 1)],
|
||||||
|
),
|
||||||
|
media_graph(
|
||||||
|
"/dev/media1",
|
||||||
|
vec![
|
||||||
|
graph_entity(10, "stream_cif_mipi_id0", (81, 0)),
|
||||||
|
graph_entity(75, "tc358743 2-000f", (81, 20)),
|
||||||
|
],
|
||||||
|
vec![graph_link(75, 10)],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let (path, entity, kind) = connected_bridge_in_media_graphs(&graphs, (81, 0)).unwrap();
|
||||||
|
assert_eq!(path, Path::new("/dev/media1"));
|
||||||
|
assert_eq!(entity.id, 75);
|
||||||
|
assert_eq!(kind, CsiBridgeKind::Tc358743);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_graph_does_not_attach_an_unrelated_rk628_to_native_hdmirx() {
|
||||||
|
let entities = vec![
|
||||||
|
graph_entity(1, "rk_hdmirx", (81, 11)),
|
||||||
|
graph_entity(63, "m00_b_rk628-csi 3-0050", (81, 16)),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(connected_bridge_entity(&entities, &[], (81, 11)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_graph_leaves_usb_capture_without_a_csi_bridge() {
|
||||||
|
let entities = vec![
|
||||||
|
graph_entity(1, "USB Video: USB Video", (81, 12)),
|
||||||
|
graph_entity(8, "Processing 2", (0, 0)),
|
||||||
|
graph_entity(11, "Input 1", (0, 0)),
|
||||||
|
];
|
||||||
|
let links = vec![graph_link(11, 8), graph_link(8, 1)];
|
||||||
|
|
||||||
|
assert!(connected_bridge_entity(&entities, &links, (81, 12)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rk628_fingerprint_matches_vga() {
|
fn rk628_fingerprint_matches_vga() {
|
||||||
let mut bt: v4l2_bt_timings = unsafe { std::mem::zeroed() };
|
let mut bt: v4l2_bt_timings = unsafe { std::mem::zeroed() };
|
||||||
@@ -352,4 +877,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(CsiBridgeKind::from_subdev_name("mystery"), None);
|
assert_eq!(CsiBridgeKind::from_subdev_name("mystery"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_errno_mapping_distinguishes_signal_loss_from_unsupported_nodes() {
|
||||||
|
assert!(matches!(
|
||||||
|
classify_query_error(&QueryDvTimingsError::NoLink, CsiBridgeKind::RkHdmirx),
|
||||||
|
ProbeResult::NoCable
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
classify_query_error(
|
||||||
|
&QueryDvTimingsError::UnstableSignal,
|
||||||
|
CsiBridgeKind::RkHdmirx
|
||||||
|
),
|
||||||
|
ProbeResult::NoSync
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
classify_query_error(&QueryDvTimingsError::Unsupported, CsiBridgeKind::RkHdmirx),
|
||||||
|
ProbeResult::NoSignal
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
classify_query_error(&QueryDvTimingsError::Unsupported, CsiBridgeKind::Unknown),
|
||||||
|
ProbeResult::Unavailable
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub enum ProbeResult {
|
|||||||
NoSync,
|
NoSync,
|
||||||
OutOfRange,
|
OutOfRange,
|
||||||
NoSignal,
|
NoSignal,
|
||||||
|
Unavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProbeResult {
|
impl ProbeResult {
|
||||||
@@ -32,6 +33,7 @@ impl ProbeResult {
|
|||||||
ProbeResult::NoSync => Some(SignalStatus::NoSync),
|
ProbeResult::NoSync => Some(SignalStatus::NoSync),
|
||||||
ProbeResult::OutOfRange => Some(SignalStatus::OutOfRange),
|
ProbeResult::OutOfRange => Some(SignalStatus::OutOfRange),
|
||||||
ProbeResult::NoSignal => Some(SignalStatus::NoSignal),
|
ProbeResult::NoSignal => Some(SignalStatus::NoSignal),
|
||||||
|
ProbeResult::Unavailable => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ use v4l2r::nix::errno::Errno;
|
|||||||
use v4l2r::{Format as V4l2rFormat, QueueType};
|
use v4l2r::{Format as V4l2rFormat, QueueType};
|
||||||
|
|
||||||
use super::bridge as csi_bridge;
|
use super::bridge as csi_bridge;
|
||||||
use super::{is_rk_hdmirx_driver, is_rkcif_driver};
|
use super::{
|
||||||
|
control_mode, is_rk_hdmirx_driver, is_rkcif_driver, VideoControlMode, VideoInputStatus,
|
||||||
|
};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
|
|
||||||
@@ -48,6 +50,8 @@ pub struct VideoDeviceInfo {
|
|||||||
/// Whether an HDMI signal is currently detected (CSI/HDMI bridge devices only;
|
/// Whether an HDMI signal is currently detected (CSI/HDMI bridge devices only;
|
||||||
/// always `true` for USB capture cards).
|
/// always `true` for USB capture cards).
|
||||||
pub has_signal: bool,
|
pub has_signal: bool,
|
||||||
|
pub control_mode: VideoControlMode,
|
||||||
|
pub input_status: VideoInputStatus,
|
||||||
/// Path of the bridge subdev (`/dev/v4l-subdevN`) paired with this
|
/// Path of the bridge subdev (`/dev/v4l-subdevN`) paired with this
|
||||||
/// capture node, if any. On Rockchip boards that wire an RK628 /
|
/// capture node, if any. On Rockchip boards that wire an RK628 /
|
||||||
/// TC358746 / RK-HDMIRX through `rkcif`, `QUERY_DV_TIMINGS`,
|
/// TC358746 / RK-HDMIRX through `rkcif`, `QUERY_DV_TIMINGS`,
|
||||||
@@ -129,6 +133,16 @@ pub struct VideoDevice {
|
|||||||
fd: File,
|
fd: File,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LiveInputProbe {
|
||||||
|
control_mode: VideoControlMode,
|
||||||
|
input_status: VideoInputStatus,
|
||||||
|
has_signal: bool,
|
||||||
|
hdmi_mode: Option<(u32, u32, Option<f64>)>,
|
||||||
|
hdmi_fps: Option<f64>,
|
||||||
|
subdev_path: Option<PathBuf>,
|
||||||
|
bridge_kind: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl VideoDevice {
|
impl VideoDevice {
|
||||||
/// Open a video device by path
|
/// Open a video device by path
|
||||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||||
@@ -173,6 +187,106 @@ impl VideoDevice {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn input_status(&self) -> Result<VideoInputStatus> {
|
||||||
|
let caps: V4l2rCapability = ioctl::querycap(&self.fd)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("Failed to query capabilities: {}", e)))?;
|
||||||
|
Ok(self.probe_live_input(&caps).input_status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_live_input(&self, caps: &V4l2rCapability) -> LiveInputProbe {
|
||||||
|
let control_mode = control_mode(&caps.driver, &caps.card);
|
||||||
|
if control_mode == VideoControlMode::Configurable {
|
||||||
|
let input_status = self
|
||||||
|
.get_format()
|
||||||
|
.ok()
|
||||||
|
.and_then(|fmt| {
|
||||||
|
PixelFormat::from_v4l2r(fmt.pixelformat)
|
||||||
|
.map(|format| (format, fmt.width, fmt.height))
|
||||||
|
})
|
||||||
|
.map(|(format, width, height)| {
|
||||||
|
VideoInputStatus::locked(
|
||||||
|
format,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
self.current_parm_fps().unwrap_or(0.0),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(VideoInputStatus::unavailable);
|
||||||
|
return LiveInputProbe {
|
||||||
|
control_mode,
|
||||||
|
input_status,
|
||||||
|
has_signal: true,
|
||||||
|
hdmi_mode: None,
|
||||||
|
hdmi_fps: None,
|
||||||
|
subdev_path: None,
|
||||||
|
bridge_kind: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let (subdev_path, bridge_kind) = match csi_bridge::discover_subdev_for_video(&self.path) {
|
||||||
|
Some((path, kind)) => (Some(path), Some(format!("{:?}", kind).to_lowercase())),
|
||||||
|
None if is_rk_hdmirx_driver(&caps.driver, &caps.card) => {
|
||||||
|
(None, Some("rkhdmirx".to_string()))
|
||||||
|
}
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let probe = if let Some(path) = subdev_path.as_ref() {
|
||||||
|
match csi_bridge::open_subdev(path) {
|
||||||
|
Ok(fd) => {
|
||||||
|
let kind = parse_bridge_kind(bridge_kind.as_deref())
|
||||||
|
.unwrap_or(csi_bridge::CsiBridgeKind::Unknown);
|
||||||
|
csi_bridge::probe_signal_thread_timeout(
|
||||||
|
&fd,
|
||||||
|
kind,
|
||||||
|
csi_bridge::RK628_SUBDEV_PROBE_TIMEOUT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Failed to open subdev {:?}: {}", path, error);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let kind = if is_rk_hdmirx_driver(&caps.driver, &caps.card) {
|
||||||
|
csi_bridge::CsiBridgeKind::RkHdmirx
|
||||||
|
} else {
|
||||||
|
csi_bridge::CsiBridgeKind::Unknown
|
||||||
|
};
|
||||||
|
Some(csi_bridge::probe_signal(&self.fd, kind))
|
||||||
|
};
|
||||||
|
|
||||||
|
let (input_status, hdmi_mode, hdmi_fps, has_signal) = match probe {
|
||||||
|
Some(csi_bridge::ProbeResult::Locked(mode)) if mode.width > 64 && mode.height > 64 => {
|
||||||
|
let fps = mode.fps.or_else(|| self.current_parm_fps());
|
||||||
|
let hdmi_mode = Some((mode.width, mode.height, fps));
|
||||||
|
let status = VideoInputStatus::locked_with_optional_fps(
|
||||||
|
self.current_active_format(),
|
||||||
|
mode.width,
|
||||||
|
mode.height,
|
||||||
|
fps,
|
||||||
|
);
|
||||||
|
(status, hdmi_mode, fps, true)
|
||||||
|
}
|
||||||
|
Some(csi_bridge::ProbeResult::Unavailable) => {
|
||||||
|
(VideoInputStatus::unavailable(), None, None, false)
|
||||||
|
}
|
||||||
|
Some(_) => (VideoInputStatus::no_signal(), None, None, false),
|
||||||
|
None if subdev_path.is_some() => (VideoInputStatus::unavailable(), None, None, false),
|
||||||
|
None => (VideoInputStatus::unavailable(), None, None, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
LiveInputProbe {
|
||||||
|
control_mode,
|
||||||
|
input_status,
|
||||||
|
has_signal,
|
||||||
|
hdmi_mode,
|
||||||
|
hdmi_fps,
|
||||||
|
subdev_path,
|
||||||
|
bridge_kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get detailed device information
|
/// Get detailed device information
|
||||||
pub fn info(&self) -> Result<VideoDeviceInfo> {
|
pub fn info(&self) -> Result<VideoDeviceInfo> {
|
||||||
let caps: V4l2rCapability = ioctl::querycap(&self.fd)
|
let caps: V4l2rCapability = ioctl::querycap(&self.fd)
|
||||||
@@ -186,89 +300,13 @@ impl VideoDevice {
|
|||||||
read_write: flags.contains(Capabilities::READWRITE),
|
read_write: flags.contains(Capabilities::READWRITE),
|
||||||
};
|
};
|
||||||
|
|
||||||
// For CSI/HDMI bridges, try to locate the paired subdev *before*
|
let live = self.probe_live_input(&caps);
|
||||||
// the signal check: RK628 + rkcif places QUERY_DV_TIMINGS on the
|
let subdev_hdmi_mode = live.hdmi_mode;
|
||||||
// subdev (the video node returns ENOTTY). Tc358743 and rk_hdmirx
|
let hdmi_fps = live.hdmi_fps;
|
||||||
// typically expose DV ioctls on the video node itself, but having
|
let has_signal = live.has_signal;
|
||||||
// the subdev handle for EDID/event subscription doesn't hurt.
|
|
||||||
let (subdev_path, bridge_kind) =
|
|
||||||
if is_rkcif_driver(&caps.driver) || is_rk_hdmirx_driver(&caps.driver, &caps.card) {
|
|
||||||
match csi_bridge::discover_subdev_for_video(&self.path) {
|
|
||||||
Some((path, kind)) => (Some(path), Some(format!("{:?}", kind).to_lowercase())),
|
|
||||||
None => (None, None),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Probe the HDMI source for both signal presence *and* the live
|
let native_hdmirx = is_rk_hdmirx_driver(&caps.driver, &caps.card);
|
||||||
// frame-rate. rkcif's `VIDIOC_ENUM_FRAMEINTERVALS` returns a
|
let mut formats = if native_hdmirx || is_rkcif_driver(&caps.driver) {
|
||||||
// meaningless `1.0..30.0` StepWise range, so the only trustworthy
|
|
||||||
// fps for rkcif + RK628 / rk_hdmirx boards comes from the bridge
|
|
||||||
// subdev's DV timings (pixelclock / total_width / total_height).
|
|
||||||
//
|
|
||||||
// Preference order:
|
|
||||||
// 1. Bridge subdev — on rkcif boards this is the *only* node
|
|
||||||
// where QUERY_DV_TIMINGS works, and it lets the RK628
|
|
||||||
// fingerprint filter kick in before we return has_signal=true.
|
|
||||||
// 2. Video node fallback — for rk_hdmirx / tc358743 where DV
|
|
||||||
// timings are exposed on the capture node directly.
|
|
||||||
// 3. USB UVC — always true (no signal concept), no hdmi_fps.
|
|
||||||
// Subdev-reported HDMI source mode (width, height, fps). On rkcif +
|
|
||||||
// RK628 boards this is the *only* place DV timings work; the video
|
|
||||||
// node itself returns ENOTTY for QUERY/G_DV_TIMINGS, so without
|
|
||||||
// threading this through to `enumerate_bridge_formats` the format
|
|
||||||
// list ends up with zero resolutions and `select_resolution` falls
|
|
||||||
// back to the user's preferred value (e.g. 4K) even when the real
|
|
||||||
// source is 1080p.
|
|
||||||
let mut subdev_hdmi_mode: Option<(u32, u32, Option<f64>)> = None;
|
|
||||||
|
|
||||||
let (has_signal, hdmi_fps) = if let Some(subdev_path) = subdev_path.as_ref() {
|
|
||||||
match csi_bridge::open_subdev(subdev_path) {
|
|
||||||
Ok(subdev_fd) => {
|
|
||||||
let kind = parse_bridge_kind(bridge_kind.as_deref())
|
|
||||||
.unwrap_or(csi_bridge::CsiBridgeKind::Unknown);
|
|
||||||
let probe = csi_bridge::probe_signal(&subdev_fd, kind);
|
|
||||||
debug!(
|
|
||||||
"has_signal via subdev {:?} ({:?}): {:?}",
|
|
||||||
subdev_path, kind, probe
|
|
||||||
);
|
|
||||||
let fps = match &probe {
|
|
||||||
csi_bridge::ProbeResult::Locked(mode) => {
|
|
||||||
subdev_hdmi_mode = Some((mode.width, mode.height, mode.fps));
|
|
||||||
mode.fps
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
(probe.is_locked(), fps)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to open subdev {:?}: {}", subdev_path, e);
|
|
||||||
(false, None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if is_rk_hdmirx_driver(&caps.driver, &caps.card) || is_rkcif_driver(&caps.driver) {
|
|
||||||
let dv = self.current_dv_timings_mode();
|
|
||||||
debug!(
|
|
||||||
"has_signal via video node {:?} (driver={}): dv_timings={:?}",
|
|
||||||
self.path, caps.driver, dv
|
|
||||||
);
|
|
||||||
let has_signal = dv
|
|
||||||
.as_ref()
|
|
||||||
.map(|(w, h, _)| *w > 64 && *h > 64)
|
|
||||||
.unwrap_or(false);
|
|
||||||
let fps = if has_signal {
|
|
||||||
dv.and_then(|(_, _, f)| f)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
(has_signal, fps)
|
|
||||||
} else {
|
|
||||||
(true, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut formats =
|
|
||||||
if is_rk_hdmirx_driver(&caps.driver, &caps.card) || is_rkcif_driver(&caps.driver) {
|
|
||||||
// CSI/HDMI bridge drivers (rk_hdmirx, rkcif) expose multiple pixel
|
// CSI/HDMI bridge drivers (rk_hdmirx, rkcif) expose multiple pixel
|
||||||
// formats via ENUM_FMT (e.g. rk_hdmirx: BGR3/NV24/NV16/NV12) but
|
// formats via ENUM_FMT (e.g. rk_hdmirx: BGR3/NV24/NV16/NV12) but
|
||||||
// `ENUM_FRAMESIZES` is fiction for these drivers (rkcif reports a
|
// `ENUM_FRAMESIZES` is fiction for these drivers (rkcif reports a
|
||||||
@@ -277,7 +315,7 @@ impl VideoDevice {
|
|||||||
// resolution is whatever the bridge subdev's DV timings report,
|
// resolution is whatever the bridge subdev's DV timings report,
|
||||||
// so we treat the HDMI source mode as the single allowed
|
// so we treat the HDMI source mode as the single allowed
|
||||||
// resolution for every pixel format.
|
// resolution for every pixel format.
|
||||||
self.enumerate_bridge_formats(subdev_hdmi_mode)?
|
self.enumerate_bridge_formats(subdev_hdmi_mode, native_hdmirx)?
|
||||||
} else {
|
} else {
|
||||||
self.enumerate_formats()?
|
self.enumerate_formats()?
|
||||||
};
|
};
|
||||||
@@ -299,7 +337,7 @@ impl VideoDevice {
|
|||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Device {:?}: {} formats, priority={}, has_signal={}, hdmi_fps={:?}, is_capture_card={}, subdev={:?}",
|
"Device {:?}: {} formats, priority={}, has_signal={}, hdmi_fps={:?}, is_capture_card={}, subdev={:?}",
|
||||||
self.path, formats.len(), priority, has_signal, hdmi_fps, is_capture_card, subdev_path
|
self.path, formats.len(), priority, has_signal, hdmi_fps, is_capture_card, live.subdev_path
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(VideoDeviceInfo {
|
Ok(VideoDeviceInfo {
|
||||||
@@ -313,8 +351,10 @@ impl VideoDevice {
|
|||||||
is_capture_card,
|
is_capture_card,
|
||||||
priority,
|
priority,
|
||||||
has_signal,
|
has_signal,
|
||||||
subdev_path,
|
control_mode: live.control_mode,
|
||||||
bridge_kind,
|
input_status: live.input_status,
|
||||||
|
subdev_path: live.subdev_path,
|
||||||
|
bridge_kind: live.bridge_kind,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,12 +413,13 @@ impl VideoDevice {
|
|||||||
/// HDMI source mode.
|
/// HDMI source mode.
|
||||||
///
|
///
|
||||||
/// Returned formats are sorted by `PixelFormat::priority()` so the
|
/// Returned formats are sorted by `PixelFormat::priority()` so the
|
||||||
/// higher-level `select_format` picks a sensible default (NV12 > YUYV on
|
/// higher-level `select_format` picks a sensible default for conversion-
|
||||||
/// rkcif / rk_hdmirx) instead of whatever the driver happens to
|
/// capable rkcif paths. Native HDMI RX is reduced to its single current
|
||||||
/// have stuck as the current active format.
|
/// wire format before sorting.
|
||||||
fn enumerate_bridge_formats(
|
fn enumerate_bridge_formats(
|
||||||
&self,
|
&self,
|
||||||
subdev_hdmi_mode: Option<(u32, u32, Option<f64>)>,
|
subdev_hdmi_mode: Option<(u32, u32, Option<f64>)>,
|
||||||
|
current_format_only: bool,
|
||||||
) -> Result<Vec<FormatInfo>> {
|
) -> Result<Vec<FormatInfo>> {
|
||||||
let queue = self.capture_queue_type()?;
|
let queue = self.capture_queue_type()?;
|
||||||
let current_fmt = self.get_format().ok();
|
let current_fmt = self.get_format().ok();
|
||||||
@@ -432,6 +473,31 @@ impl VideoDevice {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Native RK3588 HDMI RX does not perform pixel-format conversion.
|
||||||
|
// ENUM_FMT reports every input encoding the controller can receive,
|
||||||
|
// but TRY_FMT/S_FMT accept only the FourCC corresponding to the
|
||||||
|
// source's current AVI InfoFrame (RGB -> BGR3, YUV444 -> NV24,
|
||||||
|
// YUV422 -> NV16, YUV420 -> NV12). Advertising the full ENUM_FMT
|
||||||
|
// list makes the higher layer prefer NV12 even for an RGB source,
|
||||||
|
// and capture then fails with EINVAL. G_FMT is the driver's
|
||||||
|
// authoritative current-input format.
|
||||||
|
if current_format_only {
|
||||||
|
let Some(active) = current_fmt.as_ref() else {
|
||||||
|
debug!(
|
||||||
|
"enumerate_bridge_formats: skipping native HDMI RX format {:?} because G_FMT is unavailable",
|
||||||
|
desc.pixelformat
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if active.pixelformat != desc.pixelformat {
|
||||||
|
debug!(
|
||||||
|
"enumerate_bridge_formats: skipping inactive rk_hdmirx format {:?}; current is {:?}",
|
||||||
|
desc.pixelformat, active.pixelformat
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let resolutions = hdmi_mode.clone().into_iter().collect();
|
let resolutions = hdmi_mode.clone().into_iter().collect();
|
||||||
|
|
||||||
formats.push(FormatInfo {
|
formats.push(FormatInfo {
|
||||||
@@ -685,6 +751,7 @@ impl VideoDevice {
|
|||||||
"uvc",
|
"uvc",
|
||||||
"rkcif",
|
"rkcif",
|
||||||
"rk_hdmirx",
|
"rk_hdmirx",
|
||||||
|
"snps_hdmirx",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check card/driver names
|
// Check card/driver names
|
||||||
@@ -783,9 +850,7 @@ impl VideoDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn current_dv_timings_mode(&self) -> Option<(u32, u32, Option<f64>)> {
|
fn current_dv_timings_mode(&self) -> Option<(u32, u32, Option<f64>)> {
|
||||||
let timings = ioctl::query_dv_timings::<v4l2_dv_timings>(&self.fd)
|
let timings = ioctl::query_dv_timings::<v4l2_dv_timings>(&self.fd).ok()?;
|
||||||
.or_else(|_| ioctl::g_dv_timings::<v4l2_dv_timings>(&self.fd))
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
if timings.type_ != V4L2_DV_BT_656_1120 {
|
if timings.type_ != V4L2_DV_BT_656_1120 {
|
||||||
return None;
|
return None;
|
||||||
@@ -1145,7 +1210,34 @@ fn sysfs_maybe_capture(path: &Path) -> bool {
|
|||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
let driver = extract_uevent_value(&uevent, "driver");
|
let driver = extract_uevent_value(&uevent, "driver");
|
||||||
|
|
||||||
let mut maybe_capture = false;
|
sysfs_identity_maybe_capture(&sysfs_name, driver.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sysfs_identity_maybe_capture(sysfs_name: &str, driver: Option<&str>) -> bool {
|
||||||
|
// rkisp mainpath/selfpath are real frame-capture queues even though their
|
||||||
|
// names contain the generic "isp" skip hint below. Keep the allow-list
|
||||||
|
// narrow: rkisp also registers metadata and raw read/write nodes that
|
||||||
|
// must not be probed as ordinary frame-capture devices.
|
||||||
|
let is_rkisp_capture = sysfs_name.contains("rkisp")
|
||||||
|
&& (sysfs_name.contains("mainpath") || sysfs_name.contains("selfpath"));
|
||||||
|
let is_rkisp_non_capture = [
|
||||||
|
"rkisp1_stats",
|
||||||
|
"rkisp1_params",
|
||||||
|
"rkisp_stats",
|
||||||
|
"rkisp_params",
|
||||||
|
"rkisp-statistics",
|
||||||
|
"rkisp-input-params",
|
||||||
|
"rkisp_rawrd",
|
||||||
|
"rkisp_rawwr",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|hint| sysfs_name.contains(hint));
|
||||||
|
|
||||||
|
if is_rkisp_non_capture {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut maybe_capture = is_rkisp_capture;
|
||||||
let capture_hints = [
|
let capture_hints = [
|
||||||
"capture",
|
"capture",
|
||||||
"hdmi",
|
"hdmi",
|
||||||
@@ -1158,15 +1250,17 @@ fn sysfs_maybe_capture(path: &Path) -> bool {
|
|||||||
"grabber",
|
"grabber",
|
||||||
"rkcif",
|
"rkcif",
|
||||||
"rk_hdmirx",
|
"rk_hdmirx",
|
||||||
|
"snps_hdmirx",
|
||||||
];
|
];
|
||||||
if capture_hints.iter().any(|hint| sysfs_name.contains(hint)) {
|
if capture_hints.iter().any(|hint| sysfs_name.contains(hint)) {
|
||||||
maybe_capture = true;
|
maybe_capture = true;
|
||||||
}
|
}
|
||||||
if let Some(driver) = &driver {
|
if let Some(driver) = driver {
|
||||||
if driver.contains("uvcvideo")
|
if driver.contains("uvcvideo")
|
||||||
|| driver.contains("tc358743")
|
|| driver.contains("tc358743")
|
||||||
|| driver.contains("rkcif")
|
|| driver.contains("rkcif")
|
||||||
|| driver.contains("rk_hdmirx")
|
|| driver.contains("rk_hdmirx")
|
||||||
|
|| driver.contains("snps_hdmirx")
|
||||||
{
|
{
|
||||||
maybe_capture = true;
|
maybe_capture = true;
|
||||||
}
|
}
|
||||||
@@ -1185,28 +1279,15 @@ fn sysfs_maybe_capture(path: &Path) -> bool {
|
|||||||
"mpp_",
|
"mpp_",
|
||||||
"rockchip-vpu",
|
"rockchip-vpu",
|
||||||
];
|
];
|
||||||
if let Some(driver) = &driver {
|
if let Some(driver) = driver {
|
||||||
if driver_skip.iter().any(|hint| driver.contains(hint)) {
|
if driver_skip.iter().any(|hint| driver.contains(hint)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let skip_hints = [
|
let skip_hints = [
|
||||||
"codec",
|
"codec", "decoder", "encoder", "isp", "mem2mem", "m2m", "vbi", "radio", "metadata",
|
||||||
"decoder",
|
|
||||||
"encoder",
|
|
||||||
"isp",
|
|
||||||
"mem2mem",
|
|
||||||
"m2m",
|
|
||||||
"vbi",
|
|
||||||
"radio",
|
|
||||||
"metadata",
|
|
||||||
"output",
|
"output",
|
||||||
// rkisp sub-nodes that are not video capture queues
|
|
||||||
"rkisp-statistics",
|
|
||||||
"rkisp-input-params",
|
|
||||||
"rkisp_rawrd",
|
|
||||||
"rkisp_rawwr",
|
|
||||||
];
|
];
|
||||||
if skip_hints.iter().any(|hint| sysfs_name.contains(hint)) && !maybe_capture {
|
if skip_hints.iter().any(|hint| sysfs_name.contains(hint)) && !maybe_capture {
|
||||||
return false;
|
return false;
|
||||||
@@ -1329,6 +1410,8 @@ mod tests {
|
|||||||
is_capture_card,
|
is_capture_card,
|
||||||
priority,
|
priority,
|
||||||
has_signal: true,
|
has_signal: true,
|
||||||
|
control_mode: control_mode(driver, card),
|
||||||
|
input_status: VideoInputStatus::unavailable(),
|
||||||
subdev_path: None,
|
subdev_path: None,
|
||||||
bridge_kind: None,
|
bridge_kind: None,
|
||||||
}
|
}
|
||||||
@@ -1350,6 +1433,39 @@ mod tests {
|
|||||||
assert!(res.is_valid());
|
assert!(res.is_valid());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sysfs_filter_keeps_rkisp_frame_capture_nodes() {
|
||||||
|
assert!(sysfs_identity_maybe_capture(
|
||||||
|
"rkisp1_mainpath",
|
||||||
|
Some("rkisp1")
|
||||||
|
));
|
||||||
|
assert!(sysfs_identity_maybe_capture(
|
||||||
|
"rkisp1_selfpath",
|
||||||
|
Some("rkisp1")
|
||||||
|
));
|
||||||
|
assert!(sysfs_identity_maybe_capture(
|
||||||
|
"rkisp_mainpath",
|
||||||
|
Some("rkisp1_v0")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sysfs_filter_rejects_rkisp_non_frame_nodes() {
|
||||||
|
for name in [
|
||||||
|
"rkisp1_stats",
|
||||||
|
"rkisp1_params",
|
||||||
|
"rkisp-statistics",
|
||||||
|
"rkisp-input-params",
|
||||||
|
"rkisp_rawrd0",
|
||||||
|
"rkisp_rawwr0",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!sysfs_identity_maybe_capture(name, Some("rkisp1")),
|
||||||
|
"{name} must not be treated as a frame-capture node"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recovery_selection_prefers_original_path() {
|
fn recovery_selection_prefers_original_path() {
|
||||||
let original = test_device(
|
let original = test_device(
|
||||||
|
|||||||
@@ -13,6 +13,82 @@ pub use linux::{
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub use windows::*;
|
pub use windows::*;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum VideoControlMode {
|
||||||
|
Configurable,
|
||||||
|
SourceFollowing,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum VideoInputState {
|
||||||
|
Locked,
|
||||||
|
NoSignal,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct VideoInputStatus {
|
||||||
|
pub state: VideoInputState,
|
||||||
|
pub format: Option<String>,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub fps: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoInputStatus {
|
||||||
|
pub fn locked(format: PixelFormat, width: u32, height: u32, fps: f64) -> Self {
|
||||||
|
Self::locked_with_optional_fps(Some(format), width, height, Some(fps))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn locked_with_optional_fps(
|
||||||
|
format: Option<PixelFormat>,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fps: Option<f64>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
state: VideoInputState::Locked,
|
||||||
|
format: format.map(|format| format.to_string()),
|
||||||
|
width: Some(width),
|
||||||
|
height: Some(height),
|
||||||
|
fps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn no_signal() -> Self {
|
||||||
|
Self {
|
||||||
|
state: VideoInputState::NoSignal,
|
||||||
|
format: None,
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
fps: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn unavailable() -> Self {
|
||||||
|
Self {
|
||||||
|
state: VideoInputState::Unavailable,
|
||||||
|
format: None,
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
fps: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct ResolvedVideoInputConfig {
|
||||||
|
pub format: PixelFormat,
|
||||||
|
pub resolution: Resolution,
|
||||||
|
pub fps: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -20,21 +96,238 @@ pub mod bridge;
|
|||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
|
|
||||||
pub(crate) fn is_rk_hdmirx_driver(driver: &str, card: &str) -> bool {
|
pub(crate) fn is_rk_hdmirx_driver(driver: &str, card: &str) -> bool {
|
||||||
driver.eq_ignore_ascii_case("rk_hdmirx") || card.eq_ignore_ascii_case("rk_hdmirx")
|
[driver, card].iter().any(|name| {
|
||||||
}
|
name.eq_ignore_ascii_case("rk_hdmirx") || name.eq_ignore_ascii_case("snps_hdmirx")
|
||||||
|
})
|
||||||
pub(crate) fn is_rk_hdmirx_device(device: &VideoDeviceInfo) -> bool {
|
|
||||||
is_rk_hdmirx_driver(&device.driver, &device.card)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_rkcif_driver(driver: &str) -> bool {
|
pub(crate) fn is_rkcif_driver(driver: &str) -> bool {
|
||||||
driver.eq_ignore_ascii_case("rkcif")
|
driver.to_ascii_lowercase().starts_with("rkcif")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn control_mode(driver: &str, card: &str) -> VideoControlMode {
|
||||||
|
if is_rkcif_driver(driver) || is_rk_hdmirx_driver(driver, card) {
|
||||||
|
VideoControlMode::SourceFollowing
|
||||||
|
} else {
|
||||||
|
VideoControlMode::Configurable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unified check for CSI/HDMI bridge devices (rk_hdmirx, rkcif, etc.)
|
/// Unified check for CSI/HDMI bridge devices (rk_hdmirx, rkcif, etc.)
|
||||||
/// that require special enumeration and format-selection logic.
|
/// that require special enumeration and format-selection logic.
|
||||||
pub(crate) fn is_csi_hdmi_bridge(device: &VideoDeviceInfo) -> bool {
|
pub(crate) fn is_csi_hdmi_bridge(device: &VideoDeviceInfo) -> bool {
|
||||||
is_rk_hdmirx_device(device) || is_rkcif_driver(&device.driver)
|
device.control_mode == VideoControlMode::SourceFollowing
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_video_input_config(
|
||||||
|
device: &VideoDeviceInfo,
|
||||||
|
requested_format: PixelFormat,
|
||||||
|
requested_resolution: Resolution,
|
||||||
|
requested_fps: u32,
|
||||||
|
) -> ResolvedVideoInputConfig {
|
||||||
|
let mut resolved = ResolvedVideoInputConfig {
|
||||||
|
format: requested_format,
|
||||||
|
resolution: requested_resolution,
|
||||||
|
fps: requested_fps,
|
||||||
|
};
|
||||||
|
|
||||||
|
if device.control_mode == VideoControlMode::SourceFollowing {
|
||||||
|
if let VideoInputStatus {
|
||||||
|
state: VideoInputState::Locked,
|
||||||
|
format: Some(format),
|
||||||
|
width: Some(width),
|
||||||
|
height: Some(height),
|
||||||
|
fps: Some(fps),
|
||||||
|
} = &device.input_status
|
||||||
|
{
|
||||||
|
if let Ok(format) = format.parse::<PixelFormat>() {
|
||||||
|
resolved = ResolvedVideoInputConfig {
|
||||||
|
format,
|
||||||
|
resolution: Resolution::new(*width, *height),
|
||||||
|
fps: fps.round().clamp(1.0, 120.0) as u32,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source-following devices do not allow One-KVM to choose the HDMI
|
||||||
|
// resolution or frame rate, but their pixel format still has to be one
|
||||||
|
// of the formats enumerated by the capture node. In particular, rkcif
|
||||||
|
// commonly exposes NV12 but One-KVM's default is MJPEG. Passing that
|
||||||
|
// unsupported default to S_FMT leaves the pipeline in an invalid state.
|
||||||
|
if !device.formats.is_empty()
|
||||||
|
&& !device
|
||||||
|
.formats
|
||||||
|
.iter()
|
||||||
|
.any(|format| format.format == resolved.format)
|
||||||
|
{
|
||||||
|
resolved.format = device.formats[0].format;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use super::linux::FormatInfo;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn device(control_mode: VideoControlMode, input_status: VideoInputStatus) -> VideoDeviceInfo {
|
||||||
|
VideoDeviceInfo {
|
||||||
|
path: "/dev/video0".into(),
|
||||||
|
name: "test".into(),
|
||||||
|
driver: "test".into(),
|
||||||
|
bus_info: "test".into(),
|
||||||
|
card: "test".into(),
|
||||||
|
formats: Vec::new(),
|
||||||
|
capabilities: Default::default(),
|
||||||
|
is_capture_card: true,
|
||||||
|
priority: 0,
|
||||||
|
has_signal: input_status.state == VideoInputState::Locked,
|
||||||
|
control_mode,
|
||||||
|
input_status,
|
||||||
|
subdev_path: None,
|
||||||
|
bridge_kind: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn format(format: PixelFormat) -> FormatInfo {
|
||||||
|
FormatInfo {
|
||||||
|
format,
|
||||||
|
resolutions: Vec::new(),
|
||||||
|
description: format.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognizes_vendor_and_upstream_native_hdmirx_names() {
|
||||||
|
assert!(is_rk_hdmirx_driver("rk_hdmirx", "rk_hdmirx"));
|
||||||
|
assert!(is_rk_hdmirx_driver("snps_hdmirx", "Synopsys HDMI RX"));
|
||||||
|
assert!(is_rk_hdmirx_driver("other", "SNPS_HDMIRX"));
|
||||||
|
assert!(!is_rk_hdmirx_driver("rkcif", "stream_cif_mipi_id0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_source_following_drivers_in_one_place() {
|
||||||
|
assert_eq!(
|
||||||
|
control_mode("rkcif", "stream_cif_mipi_id0"),
|
||||||
|
VideoControlMode::SourceFollowing
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
control_mode("rkcif-mipi", "capture"),
|
||||||
|
VideoControlMode::SourceFollowing
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
control_mode("rk_hdmirx", "capture"),
|
||||||
|
VideoControlMode::SourceFollowing
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
control_mode("uvcvideo", "USB Capture"),
|
||||||
|
VideoControlMode::Configurable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn source_following_uses_locked_hardware_mode_and_exact_fps_rounding() {
|
||||||
|
let device = device(
|
||||||
|
VideoControlMode::SourceFollowing,
|
||||||
|
VideoInputStatus::locked(PixelFormat::Nv12, 1920, 1080, 59.94),
|
||||||
|
);
|
||||||
|
let resolved = resolve_video_input_config(
|
||||||
|
&device,
|
||||||
|
PixelFormat::Mjpeg,
|
||||||
|
Resolution::new(3840, 2160),
|
||||||
|
15,
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.format, PixelFormat::Nv12);
|
||||||
|
assert_eq!(resolved.resolution, Resolution::new(1920, 1080));
|
||||||
|
assert_eq!(resolved.fps, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn no_signal_keeps_fallback_and_configurable_keeps_request() {
|
||||||
|
for (mode, status) in [
|
||||||
|
(
|
||||||
|
VideoControlMode::SourceFollowing,
|
||||||
|
VideoInputStatus::no_signal(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
VideoControlMode::Configurable,
|
||||||
|
VideoInputStatus::unavailable(),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let resolved = resolve_video_input_config(
|
||||||
|
&device(mode, status),
|
||||||
|
PixelFormat::Yuyv,
|
||||||
|
Resolution::new(1280, 720),
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.format, PixelFormat::Yuyv);
|
||||||
|
assert_eq!(resolved.resolution, Resolution::new(1280, 720));
|
||||||
|
assert_eq!(resolved.fps, 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn source_following_replaces_unenumerated_default_format_without_signal() {
|
||||||
|
let mut device = device(
|
||||||
|
VideoControlMode::SourceFollowing,
|
||||||
|
VideoInputStatus::no_signal(),
|
||||||
|
);
|
||||||
|
device.formats = vec![format(PixelFormat::Nv12), format(PixelFormat::Yuyv)];
|
||||||
|
|
||||||
|
let resolved = resolve_video_input_config(
|
||||||
|
&device,
|
||||||
|
PixelFormat::Mjpeg,
|
||||||
|
Resolution::new(1920, 1080),
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(resolved.format, PixelFormat::Nv12);
|
||||||
|
assert_eq!(resolved.resolution, Resolution::new(1920, 1080));
|
||||||
|
assert_eq!(resolved.fps, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn source_following_replaces_stale_active_format_but_keeps_input_mode() {
|
||||||
|
let mut device = device(
|
||||||
|
VideoControlMode::SourceFollowing,
|
||||||
|
VideoInputStatus::locked(PixelFormat::Mjpeg, 1280, 720, 59.94),
|
||||||
|
);
|
||||||
|
device.formats = vec![format(PixelFormat::Nv12), format(PixelFormat::Yuyv)];
|
||||||
|
|
||||||
|
let resolved = resolve_video_input_config(
|
||||||
|
&device,
|
||||||
|
PixelFormat::Mjpeg,
|
||||||
|
Resolution::new(1920, 1080),
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(resolved.format, PixelFormat::Nv12);
|
||||||
|
assert_eq!(resolved.resolution, Resolution::new(1280, 720));
|
||||||
|
assert_eq!(resolved.fps, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_signal_and_unavailable_never_expose_stale_mode_fields() {
|
||||||
|
for status in [
|
||||||
|
VideoInputStatus::no_signal(),
|
||||||
|
VideoInputStatus::unavailable(),
|
||||||
|
] {
|
||||||
|
assert!(status.format.is_none());
|
||||||
|
assert!(status.width.is_none());
|
||||||
|
assert!(status.height.is_none());
|
||||||
|
assert!(status.fps.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::{VideoControlMode, VideoInputStatus};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ pub struct VideoDeviceInfo {
|
|||||||
pub is_capture_card: bool,
|
pub is_capture_card: bool,
|
||||||
pub priority: u32,
|
pub priority: u32,
|
||||||
pub has_signal: bool,
|
pub has_signal: bool,
|
||||||
|
pub control_mode: VideoControlMode,
|
||||||
|
pub input_status: VideoInputStatus,
|
||||||
pub subdev_path: Option<PathBuf>,
|
pub subdev_path: Option<PathBuf>,
|
||||||
pub bridge_kind: Option<String>,
|
pub bridge_kind: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -113,6 +116,10 @@ impl VideoDevice {
|
|||||||
))
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn input_status(&self) -> Result<VideoInputStatus> {
|
||||||
|
Ok(self.info()?.input_status)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn normalize_windows_device_path(path: impl AsRef<Path>) -> PathBuf {
|
pub(crate) fn normalize_windows_device_path(path: impl AsRef<Path>) -> PathBuf {
|
||||||
@@ -198,6 +205,8 @@ fn directshow_device_from_name(index: usize, name: String) -> VideoDeviceInfo {
|
|||||||
is_capture_card: true,
|
is_capture_card: true,
|
||||||
priority,
|
priority,
|
||||||
has_signal: true,
|
has_signal: true,
|
||||||
|
control_mode: VideoControlMode::Configurable,
|
||||||
|
input_status: VideoInputStatus::unavailable(),
|
||||||
subdev_path: None,
|
subdev_path: None,
|
||||||
bridge_kind: None,
|
bridge_kind: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod format;
|
|||||||
pub mod frame;
|
pub mod frame;
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
pub mod recovery;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub mod stream_manager;
|
pub mod stream_manager;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::video::codec::amlenc::{AmlencCodec, AmlencConfig, AmlencEncoder};
|
||||||
use crate::video::codec::convert::{MjpegToNv12Decoder, Nv12Converter, PixelConverter};
|
use crate::video::codec::convert::{MjpegToNv12Decoder, Nv12Converter, PixelConverter};
|
||||||
use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat};
|
use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat};
|
||||||
use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat};
|
use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat};
|
||||||
@@ -116,6 +117,47 @@ impl VideoEncoderTrait for H265EncoderWrapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AmlencEncoderWrapper(AmlencEncoder);
|
||||||
|
|
||||||
|
impl VideoEncoderTrait for AmlencEncoderWrapper {
|
||||||
|
fn encode_raw(&mut self, data: &[u8], _pts_ms: i64) -> Result<Vec<EncodedFrame>> {
|
||||||
|
Ok(match self.0.encode_raw(data)? {
|
||||||
|
Some((data, keyframe)) => vec![EncodedFrame {
|
||||||
|
data,
|
||||||
|
key: i32::from(keyframe),
|
||||||
|
}],
|
||||||
|
None => Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||||
|
self.0.set_bitrate(bitrate_kbps)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_name(&self) -> &str {
|
||||||
|
self.0.codec_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_keyframe(&mut self) {
|
||||||
|
self.0.request_keyframe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_amlenc_encoder(
|
||||||
|
config: &SharedVideoPipelineConfig,
|
||||||
|
codec: AmlencCodec,
|
||||||
|
) -> Result<Box<dyn VideoEncoderTrait + Send>> {
|
||||||
|
let encoder = AmlencEncoder::new(AmlencConfig {
|
||||||
|
codec,
|
||||||
|
resolution: config.resolution,
|
||||||
|
fps: config.fps,
|
||||||
|
bitrate_kbps: config.bitrate_kbps(),
|
||||||
|
gop: config.gop_size(),
|
||||||
|
})?;
|
||||||
|
info!("Created native AMLENC encoder: {}", encoder.codec_name());
|
||||||
|
Ok(Box::new(AmlencEncoderWrapper(encoder)))
|
||||||
|
}
|
||||||
|
|
||||||
struct VP8EncoderWrapper(VP8Encoder);
|
struct VP8EncoderWrapper(VP8Encoder);
|
||||||
|
|
||||||
impl VideoEncoderTrait for VP8EncoderWrapper {
|
impl VideoEncoderTrait for VP8EncoderWrapper {
|
||||||
@@ -189,6 +231,26 @@ fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, Pix
|
|||||||
Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12))
|
Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AMLENC and libjpeg-turbo use independent CPU/hardware resources. Decode
|
||||||
|
/// MJPEG in the capture worker so encoding the previous NV12 frame can overlap
|
||||||
|
/// with decoding the next frame.
|
||||||
|
pub(super) fn should_parallel_decode_mjpeg(config: &SharedVideoPipelineConfig) -> bool {
|
||||||
|
if !config.input_format.is_compressed()
|
||||||
|
|| !matches!(
|
||||||
|
config.output_codec,
|
||||||
|
VideoEncoderType::H264 | VideoEncoderType::H265
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let registry = EncoderRegistry::global();
|
||||||
|
let selected = match config.encoder_backend {
|
||||||
|
Some(backend) => registry.encoder_with_backend(config.output_codec, backend),
|
||||||
|
None => registry.best_available_encoder(config.output_codec),
|
||||||
|
};
|
||||||
|
selected.is_some_and(|encoder| encoder.backend == EncoderBackend::Amlogic)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn build_encoder_state(
|
pub(super) fn build_encoder_state(
|
||||||
config: &SharedVideoPipelineConfig,
|
config: &SharedVideoPipelineConfig,
|
||||||
) -> Result<EncoderThreadState> {
|
) -> Result<EncoderThreadState> {
|
||||||
@@ -210,9 +272,14 @@ pub(super) fn build_encoder_state(
|
|||||||
let is_rkmpp_available = registry
|
let is_rkmpp_available = registry
|
||||||
.encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Rkmpp)
|
.encoder_with_backend(VideoEncoderType::H264, EncoderBackend::Rkmpp)
|
||||||
.is_some();
|
.is_some();
|
||||||
let use_yuyv_direct =
|
let rkmpp_is_allowed =
|
||||||
is_rkmpp_available && !needs_mjpeg_decode && config.input_format == PixelFormat::Yuyv;
|
config.encoder_backend.is_none() || config.encoder_backend == Some(EncoderBackend::Rkmpp);
|
||||||
|
let use_yuyv_direct = is_rkmpp_available
|
||||||
|
&& rkmpp_is_allowed
|
||||||
|
&& !needs_mjpeg_decode
|
||||||
|
&& config.input_format == PixelFormat::Yuyv;
|
||||||
let use_rkmpp_direct = is_rkmpp_available
|
let use_rkmpp_direct = is_rkmpp_available
|
||||||
|
&& rkmpp_is_allowed
|
||||||
&& !needs_mjpeg_decode
|
&& !needs_mjpeg_decode
|
||||||
&& matches!(
|
&& matches!(
|
||||||
config.input_format,
|
config.input_format,
|
||||||
@@ -348,7 +415,11 @@ pub(super) fn build_encoder_state(
|
|||||||
let encoder: Box<dyn VideoEncoderTrait + Send> = match config.output_codec {
|
let encoder: Box<dyn VideoEncoderTrait + Send> = match config.output_codec {
|
||||||
VideoEncoderType::H264 => {
|
VideoEncoderType::H264 => {
|
||||||
let codec_name = selected_codec_name.clone();
|
let codec_name = selected_codec_name.clone();
|
||||||
let direct_input_format = h264_direct_input_format(&codec_name, pipeline_input_format);
|
if codec_name == crate::video::codec::amlenc::AMLENC_H264_CODEC_NAME {
|
||||||
|
create_amlenc_encoder(config, AmlencCodec::H264)?
|
||||||
|
} else {
|
||||||
|
let direct_input_format =
|
||||||
|
h264_direct_input_format(&codec_name, pipeline_input_format);
|
||||||
let input_format = direct_input_format.unwrap_or_else(|| {
|
let input_format = direct_input_format.unwrap_or_else(|| {
|
||||||
if codec_name.contains("libx264") {
|
if codec_name.contains("libx264") {
|
||||||
H264InputFormat::Yuv420p
|
H264InputFormat::Yuv420p
|
||||||
@@ -371,9 +442,14 @@ pub(super) fn build_encoder_state(
|
|||||||
|
|
||||||
create_h264_encoder(config, input_format, &codec_name)?
|
create_h264_encoder(config, input_format, &codec_name)?
|
||||||
}
|
}
|
||||||
|
}
|
||||||
VideoEncoderType::H265 => {
|
VideoEncoderType::H265 => {
|
||||||
let codec_name = selected_codec_name.clone();
|
let codec_name = selected_codec_name.clone();
|
||||||
let direct_input_format = h265_direct_input_format(&codec_name, pipeline_input_format);
|
if codec_name == crate::video::codec::amlenc::AMLENC_H265_CODEC_NAME {
|
||||||
|
create_amlenc_encoder(config, AmlencCodec::H265)?
|
||||||
|
} else {
|
||||||
|
let direct_input_format =
|
||||||
|
h265_direct_input_format(&codec_name, pipeline_input_format);
|
||||||
let input_format = direct_input_format.unwrap_or_else(|| {
|
let input_format = direct_input_format.unwrap_or_else(|| {
|
||||||
if codec_name.contains("libx265") {
|
if codec_name.contains("libx265") {
|
||||||
H265InputFormat::Yuv420p
|
H265InputFormat::Yuv420p
|
||||||
@@ -413,6 +489,7 @@ pub(super) fn build_encoder_state(
|
|||||||
info!("Created H265 encoder: {}", encoder.codec_name());
|
info!("Created H265 encoder: {}", encoder.codec_name());
|
||||||
Box::new(H265EncoderWrapper(encoder))
|
Box::new(H265EncoderWrapper(encoder))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
VideoEncoderType::VP8 => {
|
VideoEncoderType::VP8 => {
|
||||||
let codec_name = selected_codec_name.clone();
|
let codec_name = selected_codec_name.clone();
|
||||||
if let Some(ref backend) = config.encoder_backend {
|
if let Some(ref backend) = config.encoder_backend {
|
||||||
@@ -446,7 +523,9 @@ pub(super) fn build_encoder_state(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let codec_name = encoder.codec_name();
|
let codec_name = encoder.codec_name();
|
||||||
let use_direct_input = if codec_name.contains("rkmpp") {
|
let use_direct_input = if codec_name.contains("amlenc") {
|
||||||
|
pipeline_input_format == PixelFormat::Nv12
|
||||||
|
} else if codec_name.contains("rkmpp") {
|
||||||
matches!(
|
matches!(
|
||||||
pipeline_input_format,
|
pipeline_input_format,
|
||||||
PixelFormat::Yuyv
|
PixelFormat::Yuyv
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ mod encoder_state;
|
|||||||
mod shared;
|
mod shared;
|
||||||
|
|
||||||
pub use shared::{
|
pub use shared::{
|
||||||
EncodedVideoFrame, PipelineStateNotification, SharedVideoPipeline, SharedVideoPipelineConfig,
|
EncodedVideoFrame, PipelineAppliedConfig, PipelineLifecycle, PipelineStateNotification,
|
||||||
SharedVideoPipelineStats,
|
SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
134
src/video/recovery.rs
Normal file
134
src/video/recovery.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
//! Shared capture recovery policy.
|
||||||
|
//!
|
||||||
|
//! Device discovery decides whether an input follows an external source. The
|
||||||
|
//! capture layers consume that decision; they must not infer it again from a
|
||||||
|
//! driver name because doing so makes MJPEG and WebRTC recover differently.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::capture::BridgeContext;
|
||||||
|
use super::device::VideoControlMode;
|
||||||
|
|
||||||
|
const SOURCE_FOLLOWING_RETRY_DELAYS: [Duration; 3] = [
|
||||||
|
Duration::from_millis(500),
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::from_secs(2),
|
||||||
|
];
|
||||||
|
const CONFIGURABLE_RETRY_DELAY: Duration = Duration::from_millis(500);
|
||||||
|
const CONFIGURABLE_RETRY_LIMIT: u32 = 60;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct CaptureRecoveryPolicy {
|
||||||
|
control_mode: VideoControlMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for a source-change edge, falling back to the policy delay when the
|
||||||
|
/// driver does not expose events. The short slices keep shutdown responsive.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn wait_for_source_change(
|
||||||
|
bridge: &BridgeContext,
|
||||||
|
delay: Duration,
|
||||||
|
should_continue: impl Fn() -> bool,
|
||||||
|
) -> bool {
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use super::device::bridge;
|
||||||
|
|
||||||
|
let Some(path) = bridge.subdev_path.as_ref() else {
|
||||||
|
return interruptible_sleep(delay, should_continue);
|
||||||
|
};
|
||||||
|
let Ok(fd) = bridge::open_subdev(path) else {
|
||||||
|
return interruptible_sleep(delay, should_continue);
|
||||||
|
};
|
||||||
|
if bridge::subscribe_source_change(&fd).is_err() {
|
||||||
|
return interruptible_sleep(delay, should_continue);
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = Instant::now() + delay;
|
||||||
|
while should_continue() && Instant::now() < deadline {
|
||||||
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||||
|
match bridge::wait_source_change(&fd, remaining.min(Duration::from_millis(250))) {
|
||||||
|
Ok(true) => return true,
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(_) => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn wait_for_source_change(
|
||||||
|
_bridge: &BridgeContext,
|
||||||
|
delay: Duration,
|
||||||
|
should_continue: impl Fn() -> bool,
|
||||||
|
) -> bool {
|
||||||
|
interruptible_sleep(delay, should_continue)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interruptible_sleep(delay: Duration, should_continue: impl Fn() -> bool) -> bool {
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
let deadline = Instant::now() + delay;
|
||||||
|
while should_continue() && Instant::now() < deadline {
|
||||||
|
std::thread::sleep(
|
||||||
|
deadline
|
||||||
|
.saturating_duration_since(Instant::now())
|
||||||
|
.min(Duration::from_millis(100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureRecoveryPolicy {
|
||||||
|
pub const fn new(control_mode: VideoControlMode) -> Self {
|
||||||
|
Self { control_mode }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn control_mode(self) -> VideoControlMode {
|
||||||
|
self.control_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delay after `failed_attempts` consecutive attempts (one-based).
|
||||||
|
pub fn retry_delay(self, failed_attempts: u32) -> Duration {
|
||||||
|
match self.control_mode {
|
||||||
|
VideoControlMode::SourceFollowing => {
|
||||||
|
let index = failed_attempts.saturating_sub(1).min(2) as usize;
|
||||||
|
SOURCE_FOLLOWING_RETRY_DELAYS[index]
|
||||||
|
}
|
||||||
|
VideoControlMode::Configurable => CONFIGURABLE_RETRY_DELAY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source-following inputs keep probing for as long as they have a
|
||||||
|
/// consumer. Configurable/UVC inputs retain the pre-existing finite policy.
|
||||||
|
pub const fn should_retry(self, failed_attempts: u32) -> bool {
|
||||||
|
match self.control_mode {
|
||||||
|
VideoControlMode::SourceFollowing => true,
|
||||||
|
VideoControlMode::Configurable => failed_attempts < CONFIGURABLE_RETRY_LIMIT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_following_uses_capped_backoff_and_never_expires() {
|
||||||
|
let policy = CaptureRecoveryPolicy::new(VideoControlMode::SourceFollowing);
|
||||||
|
assert_eq!(policy.retry_delay(1), Duration::from_millis(500));
|
||||||
|
assert_eq!(policy.retry_delay(2), Duration::from_secs(1));
|
||||||
|
assert_eq!(policy.retry_delay(3), Duration::from_secs(2));
|
||||||
|
assert_eq!(policy.retry_delay(10_000), Duration::from_secs(2));
|
||||||
|
assert!(policy.should_retry(61));
|
||||||
|
assert!(policy.should_retry(9_000)); // five hours at the capped delay
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configurable_inputs_keep_the_finite_retry_policy() {
|
||||||
|
let policy = CaptureRecoveryPolicy::new(VideoControlMode::Configurable);
|
||||||
|
assert_eq!(policy.retry_delay(1), Duration::from_millis(500));
|
||||||
|
assert!(policy.should_retry(59));
|
||||||
|
assert!(!policy.should_retry(60));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -358,29 +358,9 @@ impl VideoStreamManager {
|
|||||||
.update_video_config(resolution, format, fps)
|
.update_video_config(resolution, format, fps)
|
||||||
.await;
|
.await;
|
||||||
if let Some(device_path) = device_path {
|
if let Some(device_path) = device_path {
|
||||||
// Resolve the paired subdev so the WebRTC pipeline can run the
|
let device_info = self.streamer.current_device().await;
|
||||||
// RK628 STREAMON gate + SOURCE_CHANGE polling identically to the
|
|
||||||
// MJPEG path. See `csi_bridge::discover_subdev_for_video`.
|
|
||||||
let (subdev_path, bridge_kind, v4l2_driver) = self
|
|
||||||
.streamer
|
|
||||||
.current_device()
|
|
||||||
.await
|
|
||||||
.map(|d| {
|
|
||||||
(
|
|
||||||
d.subdev_path.clone(),
|
|
||||||
d.bridge_kind.clone(),
|
|
||||||
Some(d.driver.clone()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or((None, None, None));
|
|
||||||
self.webrtc_streamer
|
self.webrtc_streamer
|
||||||
.set_capture_device(
|
.set_capture_device(device_path, jpeg_quality, device_info)
|
||||||
device_path,
|
|
||||||
jpeg_quality,
|
|
||||||
subdev_path,
|
|
||||||
bridge_kind,
|
|
||||||
v4l2_driver,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
warn!("No capture device configured while syncing WebRTC capture source");
|
warn!("No capture device configured while syncing WebRTC capture source");
|
||||||
@@ -434,7 +414,7 @@ impl VideoStreamManager {
|
|||||||
let closed = self
|
let closed = self
|
||||||
.webrtc_streamer
|
.webrtc_streamer
|
||||||
.close_all_sessions_and_release_device()
|
.close_all_sessions_and_release_device()
|
||||||
.await;
|
.await?;
|
||||||
if closed > 0 {
|
if closed > 0 {
|
||||||
info!("Closed {} WebRTC sessions", closed);
|
info!("Closed {} WebRTC sessions", closed);
|
||||||
}
|
}
|
||||||
@@ -549,26 +529,9 @@ impl VideoStreamManager {
|
|||||||
}
|
}
|
||||||
if let Some(device_path) = device_path {
|
if let Some(device_path) = device_path {
|
||||||
info!("Configuring direct capture for WebRTC after config change");
|
info!("Configuring direct capture for WebRTC after config change");
|
||||||
let (subdev_path, bridge_kind, v4l2_driver) = self
|
let device_info = self.streamer.current_device().await;
|
||||||
.streamer
|
|
||||||
.current_device()
|
|
||||||
.await
|
|
||||||
.map(|d| {
|
|
||||||
(
|
|
||||||
d.subdev_path.clone(),
|
|
||||||
d.bridge_kind.clone(),
|
|
||||||
Some(d.driver.clone()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or((None, None, None));
|
|
||||||
self.webrtc_streamer
|
self.webrtc_streamer
|
||||||
.set_capture_device(
|
.set_capture_device(device_path, jpeg_quality, device_info)
|
||||||
device_path,
|
|
||||||
jpeg_quality,
|
|
||||||
subdev_path,
|
|
||||||
bridge_kind,
|
|
||||||
v4l2_driver,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
warn!("No capture device configured for WebRTC after config change");
|
warn!("No capture device configured for WebRTC after config change");
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ use tracing::{debug, error, info, trace, warn};
|
|||||||
|
|
||||||
use super::device::{
|
use super::device::{
|
||||||
bridge as csi_bridge, enumerate_devices, find_best_device, is_csi_hdmi_bridge,
|
bridge as csi_bridge, enumerate_devices, find_best_device, is_csi_hdmi_bridge,
|
||||||
parse_bridge_kind, select_recovery_device, VideoDevice, VideoDeviceInfo,
|
parse_bridge_kind, resolve_video_input_config, select_recovery_device, VideoControlMode,
|
||||||
VideoDeviceRecoveryHint,
|
VideoDevice, VideoDeviceInfo, VideoDeviceRecoveryHint,
|
||||||
};
|
};
|
||||||
use super::format::{PixelFormat, Resolution};
|
use super::format::{PixelFormat, Resolution};
|
||||||
use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
|
use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
|
use crate::events::{EventBus, StreamKind, SystemEvent};
|
||||||
use crate::stream::MjpegStreamHandler;
|
use crate::stream::MjpegStreamHandler;
|
||||||
use crate::utils::LogThrottler;
|
use crate::utils::LogThrottler;
|
||||||
use crate::video::capture::runtime::open_capture_stream;
|
use crate::video::capture::runtime::open_capture_stream;
|
||||||
@@ -28,8 +28,9 @@ use crate::video::capture::status::{
|
|||||||
CaptureIoErrorKind,
|
CaptureIoErrorKind,
|
||||||
};
|
};
|
||||||
use crate::video::capture::{
|
use crate::video::capture::{
|
||||||
is_source_changed_error, BridgeContext, CaptureStream, DEFAULT_CAPTURE_BUFFER_COUNT,
|
BridgeContext, CaptureReadError, CaptureStream, DEFAULT_CAPTURE_BUFFER_COUNT,
|
||||||
};
|
};
|
||||||
|
use crate::video::recovery::{wait_for_source_change, CaptureRecoveryPolicy};
|
||||||
|
|
||||||
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
|
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
|
||||||
|
|
||||||
@@ -251,6 +252,7 @@ impl Streamer {
|
|||||||
let next = self.next_retry_ms.load(Ordering::Relaxed);
|
let next = self.next_retry_ms.load(Ordering::Relaxed);
|
||||||
|
|
||||||
SystemEvent::StreamStateChanged {
|
SystemEvent::StreamStateChanged {
|
||||||
|
kind: StreamKind::Video,
|
||||||
state: external.to_string(),
|
state: external.to_string(),
|
||||||
device,
|
device,
|
||||||
reason: reason.map(|s| s.to_string()),
|
reason: reason.map(|s| s.to_string()),
|
||||||
@@ -373,7 +375,10 @@ impl Streamer {
|
|||||||
.ok_or_else(|| AppError::VideoError("Video device not found".to_string()))?
|
.ok_or_else(|| AppError::VideoError("Video device not found".to_string()))?
|
||||||
};
|
};
|
||||||
|
|
||||||
let (format, resolution) = self.resolve_capture_config(&device, format, resolution)?;
|
let resolved = self.resolve_capture_config(&device, format, resolution, fps)?;
|
||||||
|
let format = resolved.format;
|
||||||
|
let resolution = resolved.resolution;
|
||||||
|
let fps = resolved.fps;
|
||||||
|
|
||||||
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
|
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
|
||||||
// This prevents race conditions where clients try to reconnect and reopen the device
|
// This prevents race conditions where clients try to reconnect and reopen the device
|
||||||
@@ -442,19 +447,18 @@ impl Streamer {
|
|||||||
device.path.display()
|
device.path.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine best format for this device
|
|
||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
let format = self.select_format(&device, config.format)?;
|
let resolved =
|
||||||
let resolution = self.select_resolution(&device, &format, config.resolution)?;
|
self.resolve_capture_config(&device, config.format, config.resolution, config.fps)?;
|
||||||
|
|
||||||
drop(config);
|
drop(config);
|
||||||
|
|
||||||
// Update config with actual values
|
// Update config with actual values
|
||||||
{
|
{
|
||||||
let mut config = self.config.write().await;
|
let mut config = self.config.write().await;
|
||||||
config.device_path = Some(device.path.clone());
|
config.device_path = Some(device.path.clone());
|
||||||
config.format = format;
|
config.format = resolved.format;
|
||||||
config.resolution = resolution;
|
config.resolution = resolved.resolution;
|
||||||
|
config.fps = resolved.fps;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store device info
|
// Store device info
|
||||||
@@ -462,7 +466,10 @@ impl Streamer {
|
|||||||
|
|
||||||
*self.state.write().await = StreamerState::Ready;
|
*self.state.write().await = StreamerState::Ready;
|
||||||
|
|
||||||
info!("Streamer initialized: {} @ {}", format, resolution);
|
info!(
|
||||||
|
"Streamer initialized: {} @ {} {} fps",
|
||||||
|
resolved.format, resolved.resolution, resolved.fps
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,10 +581,20 @@ impl Streamer {
|
|||||||
device: &VideoDeviceInfo,
|
device: &VideoDeviceInfo,
|
||||||
requested_format: PixelFormat,
|
requested_format: PixelFormat,
|
||||||
requested_resolution: Resolution,
|
requested_resolution: Resolution,
|
||||||
) -> Result<(PixelFormat, Resolution)> {
|
requested_fps: u32,
|
||||||
let format = self.select_format(device, requested_format)?;
|
) -> Result<super::device::ResolvedVideoInputConfig> {
|
||||||
let resolution = self.select_resolution(device, &format, requested_resolution)?;
|
let mut resolved = resolve_video_input_config(
|
||||||
Ok((format, resolution))
|
device,
|
||||||
|
requested_format,
|
||||||
|
requested_resolution,
|
||||||
|
requested_fps,
|
||||||
|
);
|
||||||
|
if device.control_mode == VideoControlMode::Configurable {
|
||||||
|
resolved.format = self.select_format(device, resolved.format)?;
|
||||||
|
resolved.resolution =
|
||||||
|
self.select_resolution(device, &resolved.format, resolved.resolution)?;
|
||||||
|
}
|
||||||
|
Ok(resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restart capture for recovery (direct capture path)
|
/// Restart capture for recovery (direct capture path)
|
||||||
@@ -616,6 +633,17 @@ impl Streamer {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A no-signal/source-change recovery keeps the existing capture thread
|
||||||
|
// alive while it closes and re-opens the V4L2 stream. HTTP clients may
|
||||||
|
// reconnect while that thread is still probing. Do not spawn a second
|
||||||
|
// capture thread here: it would contend for the same video node and
|
||||||
|
// overwrite `direct_handle`, making the original thread impossible to
|
||||||
|
// join from `stop()`.
|
||||||
|
if self.direct_active.load(Ordering::SeqCst) {
|
||||||
|
debug!("Capture thread is already active; waiting for its recovery loop");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if state == StreamerState::Uninitialized {
|
if state == StreamerState::Uninitialized {
|
||||||
// Auto-initialize if not done
|
// Auto-initialize if not done
|
||||||
self.init_auto().await?;
|
self.init_auto().await?;
|
||||||
@@ -772,26 +800,9 @@ impl Streamer {
|
|||||||
const RETRY_DELAY_MS: u64 = 200;
|
const RETRY_DELAY_MS: u64 = 200;
|
||||||
const IDLE_STOP_DELAY_SECS: u64 = 5;
|
const IDLE_STOP_DELAY_SECS: u64 = 5;
|
||||||
const BUFFER_COUNT: u32 = DEFAULT_CAPTURE_BUFFER_COUNT;
|
const BUFFER_COUNT: u32 = DEFAULT_CAPTURE_BUFFER_COUNT;
|
||||||
/// Initial back-off after signal loss before the first soft restart.
|
|
||||||
///
|
|
||||||
/// PiKVM/ustreamer drops to sub-second recovery because it subscribes to
|
|
||||||
/// `V4L2_EVENT_SOURCE_CHANGE`; lacking that (for now), we bound how long
|
|
||||||
/// the user has to stare at a placeholder after a source-side resolution
|
|
||||||
/// change by driving a soft-restart at 1 s, then 2 s, 4 s, …, 8 s.
|
|
||||||
const NOSIGNAL_SOFT_RESTART_INITIAL_SECS: u64 = 1;
|
|
||||||
const NOSIGNAL_SOFT_RESTART_MAX_SECS: u64 = 8;
|
|
||||||
|
|
||||||
let handle = tokio::runtime::Handle::current();
|
let handle = tokio::runtime::Handle::current();
|
||||||
let mut last_state = StreamerState::Streaming;
|
let mut last_state = StreamerState::Streaming;
|
||||||
|
|
||||||
// Compute the current soft-restart back-off window (in seconds)
|
|
||||||
// for the exponential ladder 1 s → 2 s → 4 s → 8 s (capped).
|
|
||||||
let backoff_secs = |count: u32| -> u64 {
|
|
||||||
NOSIGNAL_SOFT_RESTART_INITIAL_SECS
|
|
||||||
.saturating_mul(2u64.pow(count.min(3)))
|
|
||||||
.min(NOSIGNAL_SOFT_RESTART_MAX_SECS)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut set_state = |new_state: StreamerState| {
|
let mut set_state = |new_state: StreamerState| {
|
||||||
if new_state != last_state {
|
if new_state != last_state {
|
||||||
handle.block_on(async {
|
handle.block_on(async {
|
||||||
@@ -818,20 +829,23 @@ impl Streamer {
|
|||||||
self.next_retry_ms.store(ms, Ordering::Relaxed);
|
self.next_retry_ms.store(ms, Ordering::Relaxed);
|
||||||
};
|
};
|
||||||
|
|
||||||
// How many soft-restart cycles have been attempted (for exponential back-off).
|
// Consecutive recovery attempts, shared with the common retry policy.
|
||||||
let mut no_signal_restart_count: u32 = 0;
|
let mut no_signal_restart_count: u32 = 0;
|
||||||
|
let mut no_consumers_since: Option<std::time::Instant> = None;
|
||||||
// Last (resolution, format, fps) combination for which we emitted a
|
|
||||||
// `StreamConfigApplied` event. Used to de-duplicate the event across
|
|
||||||
// soft-restarts that produce the exact same geometry (e.g. a spurious
|
|
||||||
// single-frame timeout on a stable source) — the frontend would
|
|
||||||
// otherwise re-layout the `<img>` on every glitch.
|
|
||||||
let mut last_applied: Option<(u32, u32, PixelFormat, u32)> = None;
|
|
||||||
|
|
||||||
'session: loop {
|
'session: loop {
|
||||||
if self.direct_stop.load(Ordering::Relaxed) {
|
if self.direct_stop.load(Ordering::Relaxed) {
|
||||||
break 'session;
|
break 'session;
|
||||||
}
|
}
|
||||||
|
if self.mjpeg_handler.client_count() == 0 {
|
||||||
|
let since = no_consumers_since.get_or_insert_with(std::time::Instant::now);
|
||||||
|
if since.elapsed() >= Duration::from_secs(IDLE_STOP_DELAY_SECS) {
|
||||||
|
info!("No MJPEG consumers during recovery; stopping capture");
|
||||||
|
break 'session;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
no_consumers_since = None;
|
||||||
|
}
|
||||||
|
|
||||||
// Re-read config at the start of each session so that a re_init_device()
|
// Re-read config at the start of each session so that a re_init_device()
|
||||||
// call (from a previous soft-restart or recovery) is reflected here.
|
// call (from a previous soft-restart or recovery) is reflected here.
|
||||||
@@ -844,53 +858,45 @@ impl Streamer {
|
|||||||
// `VideoDeviceInfo` during enumeration; we re-read it here
|
// `VideoDeviceInfo` during enumeration; we re-read it here
|
||||||
// rather than caching on Streamer so a hot-plug recovery picks
|
// rather than caching on Streamer so a hot-plug recovery picks
|
||||||
// up a possibly-different subdev path.
|
// up a possibly-different subdev path.
|
||||||
let bridge_ctx = handle.block_on(async {
|
let (bridge_ctx, control_mode) = handle.block_on(async {
|
||||||
self.current_device
|
self.current_device
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|info| {
|
.map(|info| {
|
||||||
|
(
|
||||||
BridgeContext::from_parts(
|
BridgeContext::from_parts(
|
||||||
info.subdev_path.clone(),
|
info.subdev_path.clone(),
|
||||||
parse_bridge_kind(info.bridge_kind.as_deref()),
|
parse_bridge_kind(info.bridge_kind.as_deref()),
|
||||||
|
),
|
||||||
|
info.control_mode,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or((BridgeContext::default(), VideoControlMode::Configurable))
|
||||||
});
|
});
|
||||||
|
let recovery_policy = CaptureRecoveryPolicy::new(control_mode);
|
||||||
|
|
||||||
// ── STREAMON gate: for CSI bridges with a subdev, refuse to
|
// ── STREAMON gate: for CSI bridges with a subdev, refuse to
|
||||||
// open the video node when the subdev reports no signal.
|
// open the video node when the subdev reports no signal.
|
||||||
// On RK628 this prevents a kernel null-pointer deref.
|
// On RK628 this prevents a kernel null-pointer deref.
|
||||||
if let Some(subdev_path) = bridge_ctx.subdev_path.as_ref() {
|
if let Some(subdev_path) = bridge_ctx.subdev_path.as_ref() {
|
||||||
match probe_subdev_signal(subdev_path, bridge_ctx.kind) {
|
if let Some(status) = probe_subdev_signal(subdev_path, bridge_ctx.kind) {
|
||||||
Some(crate::video::signal::SignalStatus::NoCable)
|
let delay =
|
||||||
| Some(crate::video::signal::SignalStatus::NoSync)
|
recovery_policy.retry_delay(no_signal_restart_count.saturating_add(1));
|
||||||
| Some(crate::video::signal::SignalStatus::NoSignal)
|
|
||||||
| Some(crate::video::signal::SignalStatus::OutOfRange) => {
|
|
||||||
let status = probe_subdev_signal(subdev_path, bridge_ctx.kind)
|
|
||||||
.unwrap_or(crate::video::signal::SignalStatus::NoSignal);
|
|
||||||
let wait_secs = backoff_secs(no_signal_restart_count);
|
|
||||||
debug!(
|
debug!(
|
||||||
"Pre-STREAMON gate: subdev {:?} reports {:?} — \
|
"Pre-STREAMON gate: subdev {:?} reports {:?} — \
|
||||||
waiting for SOURCE_CHANGE (<= {}s) before opening {:?}",
|
waiting for SOURCE_CHANGE (<= {:?}) before opening {:?}",
|
||||||
subdev_path, status, wait_secs, device_path
|
subdev_path, status, delay, device_path
|
||||||
);
|
);
|
||||||
set_retry(wait_secs.saturating_mul(1000));
|
set_retry(delay.as_millis() as u64);
|
||||||
go_offline();
|
go_offline();
|
||||||
set_state(status.into());
|
set_state(status.into());
|
||||||
// Wait for SOURCE_CHANGE or timeout before retrying.
|
wait_for_source_change(&bridge_ctx, delay, || {
|
||||||
// Opens the subdev just for the poll — cheap and
|
!self.direct_stop.load(Ordering::Relaxed)
|
||||||
// does NOT touch the video node.
|
});
|
||||||
wait_subdev_for_source_change(
|
|
||||||
subdev_path,
|
|
||||||
&self.direct_stop,
|
|
||||||
Duration::from_secs(wait_secs),
|
|
||||||
);
|
|
||||||
no_signal_restart_count = no_signal_restart_count.saturating_add(1);
|
no_signal_restart_count = no_signal_restart_count.saturating_add(1);
|
||||||
continue 'session;
|
continue 'session;
|
||||||
}
|
}
|
||||||
_ => {} // Locked (None from as_status) or unknown — proceed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Open the capture stream ─────────────────────────────────────────
|
// ── Open the capture stream ─────────────────────────────────────────
|
||||||
@@ -911,6 +917,7 @@ impl Streamer {
|
|||||||
BUFFER_COUNT,
|
BUFFER_COUNT,
|
||||||
Duration::from_secs(2),
|
Duration::from_secs(2),
|
||||||
bridge_ctx.clone(),
|
bridge_ctx.clone(),
|
||||||
|
control_mode,
|
||||||
) {
|
) {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
stream_opt = Some(stream);
|
stream_opt = Some(stream);
|
||||||
@@ -927,7 +934,9 @@ impl Streamer {
|
|||||||
"CSI open probe reports no signal ({:?}), will soft-restart",
|
"CSI open probe reports no signal ({:?}), will soft-restart",
|
||||||
status
|
status
|
||||||
);
|
);
|
||||||
set_retry(backoff_secs(no_signal_restart_count).saturating_mul(1000));
|
let delay =
|
||||||
|
recovery_policy.retry_delay(no_signal_restart_count.saturating_add(1));
|
||||||
|
set_retry(delay.as_millis() as u64);
|
||||||
go_offline();
|
go_offline();
|
||||||
set_state(status.into());
|
set_state(status.into());
|
||||||
last_error = Some(format!("CaptureNoSignal({})", kind));
|
last_error = Some(format!("CaptureNoSignal({})", kind));
|
||||||
@@ -976,9 +985,16 @@ impl Streamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
debug!("Open failed in NoSignal-like state, backing off before soft-restart");
|
debug!("Open failed in NoSignal-like state, backing off before soft-restart");
|
||||||
let wait = backoff_secs(no_signal_restart_count);
|
if !recovery_policy.should_retry(no_signal_restart_count.saturating_add(1)) {
|
||||||
set_retry(wait.saturating_mul(1000));
|
set_state(StreamerState::Error);
|
||||||
std::thread::sleep(Duration::from_secs(wait));
|
break 'session;
|
||||||
|
}
|
||||||
|
let delay =
|
||||||
|
recovery_policy.retry_delay(no_signal_restart_count.saturating_add(1));
|
||||||
|
set_retry(delay.as_millis() as u64);
|
||||||
|
wait_for_source_change(&bridge_ctx, delay, || {
|
||||||
|
!self.direct_stop.load(Ordering::Relaxed)
|
||||||
|
});
|
||||||
no_signal_restart_count = no_signal_restart_count.saturating_add(1);
|
no_signal_restart_count = no_signal_restart_count.saturating_add(1);
|
||||||
continue 'session;
|
continue 'session;
|
||||||
}
|
}
|
||||||
@@ -986,15 +1002,34 @@ impl Streamer {
|
|||||||
|
|
||||||
let resolution = stream.resolution();
|
let resolution = stream.resolution();
|
||||||
let pixel_format = stream.format();
|
let pixel_format = stream.format();
|
||||||
|
let source_fps = stream
|
||||||
|
.source_fps()
|
||||||
|
.map(|fps| fps.round().clamp(1.0, 120.0) as u32)
|
||||||
|
.unwrap_or(config.fps);
|
||||||
let stride = stream.stride();
|
let stride = stream.stride();
|
||||||
|
|
||||||
|
if control_mode == VideoControlMode::SourceFollowing {
|
||||||
|
handle.block_on(async {
|
||||||
|
let mut current = self.config.write().await;
|
||||||
|
current.resolution = resolution;
|
||||||
|
current.format = pixel_format;
|
||||||
|
current.fps = source_fps;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Capture format: {}x{} {:?} stride={}",
|
"Capture format: {}x{} {:?} stride={}",
|
||||||
resolution.width, resolution.height, pixel_format, stride
|
resolution.width, resolution.height, pixel_format, stride
|
||||||
);
|
);
|
||||||
|
|
||||||
let buffer_pool = Arc::new(FrameBufferPool::new(BUFFER_COUNT.max(4) as usize));
|
let buffer_pool = Arc::new(FrameBufferPool::new(BUFFER_COUNT.max(4) as usize));
|
||||||
let mut signal_present = true;
|
// Preserve the no-signal state across an outer-loop re-open. This
|
||||||
|
// makes the first recovered frame transition the handler back
|
||||||
|
// online and publish Streaming instead of silently inheriting the
|
||||||
|
// previous offline state.
|
||||||
|
let mut signal_present = !handle
|
||||||
|
.block_on(async { self.state().await })
|
||||||
|
.is_no_signal_like();
|
||||||
let mut idle_since: Option<std::time::Instant> = None;
|
let mut idle_since: Option<std::time::Instant> = None;
|
||||||
|
|
||||||
let mut fps_frame_count: u64 = 0;
|
let mut fps_frame_count: u64 = 0;
|
||||||
@@ -1033,20 +1068,26 @@ impl Streamer {
|
|||||||
let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE);
|
let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE);
|
||||||
let meta = match stream.next_into(&mut owned) {
|
let meta = match stream.next_into(&mut owned) {
|
||||||
Ok(meta) => meta,
|
Ok(meta) => meta,
|
||||||
Err(e) => {
|
Err(CaptureReadError::SourceChanged) => {
|
||||||
if is_source_changed_error(&e) {
|
|
||||||
info!("Capture SOURCE_CHANGE — soft-restart for DV re-probe");
|
info!("Capture SOURCE_CHANGE — soft-restart for DV re-probe");
|
||||||
set_retry(backoff_secs(no_signal_restart_count).saturating_mul(1000));
|
let delay =
|
||||||
|
recovery_policy.retry_delay(no_signal_restart_count.saturating_add(1));
|
||||||
|
set_retry(delay.as_millis() as u64);
|
||||||
go_offline();
|
go_offline();
|
||||||
set_state(StreamerState::NoSignal);
|
set_state(StreamerState::NoSignal);
|
||||||
need_soft_restart = true;
|
need_soft_restart = true;
|
||||||
break 'capture;
|
break 'capture;
|
||||||
}
|
}
|
||||||
|
Err(CaptureReadError::Io(e)) => {
|
||||||
|
if e.kind() == std::io::ErrorKind::WouldBlock {
|
||||||
|
continue 'capture;
|
||||||
|
}
|
||||||
if e.kind() == std::io::ErrorKind::TimedOut {
|
if e.kind() == std::io::ErrorKind::TimedOut {
|
||||||
if signal_present {
|
if signal_present {
|
||||||
signal_present = false;
|
signal_present = false;
|
||||||
let wait = backoff_secs(no_signal_restart_count);
|
let delay = recovery_policy
|
||||||
set_retry(wait.saturating_mul(1000));
|
.retry_delay(no_signal_restart_count.saturating_add(1));
|
||||||
|
set_retry(delay.as_millis() as u64);
|
||||||
go_offline();
|
go_offline();
|
||||||
set_state(StreamerState::NoSignal);
|
set_state(StreamerState::NoSignal);
|
||||||
no_signal_since = Some(std::time::Instant::now());
|
no_signal_since = Some(std::time::Instant::now());
|
||||||
@@ -1054,11 +1095,12 @@ impl Streamer {
|
|||||||
fps_frame_count = 0;
|
fps_frame_count = 0;
|
||||||
last_fps_time = std::time::Instant::now();
|
last_fps_time = std::time::Instant::now();
|
||||||
} else if let Some(since) = no_signal_since {
|
} else if let Some(since) = no_signal_since {
|
||||||
let wait = backoff_secs(no_signal_restart_count);
|
let delay = recovery_policy
|
||||||
if since.elapsed().as_secs() >= wait {
|
.retry_delay(no_signal_restart_count.saturating_add(1));
|
||||||
|
if since.elapsed() >= delay {
|
||||||
info!(
|
info!(
|
||||||
"NoSignal for {}s, attempting soft restart (attempt {})",
|
"NoSignal for {:?}, attempting soft restart (attempt {})",
|
||||||
wait,
|
delay,
|
||||||
no_signal_restart_count + 1
|
no_signal_restart_count + 1
|
||||||
);
|
);
|
||||||
need_soft_restart = true;
|
need_soft_restart = true;
|
||||||
@@ -1105,12 +1147,7 @@ impl Streamer {
|
|||||||
"Capture transient error (EPROTO/-71, often UVC USB): {}",
|
"Capture transient error (EPROTO/-71, often UVC USB): {}",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
let is_uvc = handle.block_on(async {
|
if control_mode == VideoControlMode::Configurable {
|
||||||
self.current_device.read().await.as_ref().is_some_and(|d| {
|
|
||||||
d.driver.eq_ignore_ascii_case("uvcvideo")
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if is_uvc {
|
|
||||||
go_offline();
|
go_offline();
|
||||||
set_state(StreamerState::UvcUsbError);
|
set_state(StreamerState::UvcUsbError);
|
||||||
need_soft_restart = true;
|
need_soft_restart = true;
|
||||||
@@ -1122,9 +1159,9 @@ impl Streamer {
|
|||||||
e
|
e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
set_retry(
|
let delay = recovery_policy
|
||||||
backoff_secs(no_signal_restart_count).saturating_mul(1000),
|
.retry_delay(no_signal_restart_count.saturating_add(1));
|
||||||
);
|
set_retry(delay.as_millis() as u64);
|
||||||
go_offline();
|
go_offline();
|
||||||
set_state(StreamerState::NoSignal);
|
set_state(StreamerState::NoSignal);
|
||||||
need_soft_restart = true;
|
need_soft_restart = true;
|
||||||
@@ -1168,14 +1205,23 @@ impl Streamer {
|
|||||||
no_signal_since = None;
|
no_signal_since = None;
|
||||||
no_signal_restart_count = 0;
|
no_signal_restart_count = 0;
|
||||||
set_retry(0);
|
set_retry(0);
|
||||||
|
// Signal-loss handling marks the MJPEG handler offline so
|
||||||
|
// stale HTTP responses close cleanly. Re-enable it on the
|
||||||
|
// first recovered frame so a reconnect can remain attached
|
||||||
|
// to this (still single) capture thread.
|
||||||
|
self.mjpeg_handler.set_online();
|
||||||
set_state(StreamerState::Streaming);
|
set_state(StreamerState::Streaming);
|
||||||
|
|
||||||
let fps_val = config.fps;
|
let fps_val = source_fps;
|
||||||
let current = (resolution.width, resolution.height, pixel_format, fps_val);
|
let recovered_device = device_path.display().to_string();
|
||||||
if last_applied != Some(current) {
|
handle.block_on(async {
|
||||||
last_applied = Some(current);
|
self.publish_event(SystemEvent::StreamRecovered {
|
||||||
|
device: recovered_device,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
});
|
||||||
let dp = device_path.display().to_string();
|
let dp = device_path.display().to_string();
|
||||||
let fmt = format!("{:?}", pixel_format);
|
let fmt = pixel_format.to_string();
|
||||||
let w = resolution.width;
|
let w = resolution.width;
|
||||||
let h = resolution.height;
|
let h = resolution.height;
|
||||||
handle.block_on(async {
|
handle.block_on(async {
|
||||||
@@ -1189,7 +1235,6 @@ impl Streamer {
|
|||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
self.mjpeg_handler.update_frame(frame);
|
self.mjpeg_handler.update_frame(frame);
|
||||||
|
|
||||||
@@ -1218,65 +1263,8 @@ impl Streamer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
no_signal_restart_count = no_signal_restart_count.saturating_add(1);
|
no_signal_restart_count = no_signal_restart_count.saturating_add(1);
|
||||||
|
// Continue 'session: the single open path performs QUERY_DV_TIMINGS,
|
||||||
match VideoDevice::open_readonly(&device_path).and_then(|d| d.info()) {
|
// applies the source mode, and owns the retry delay.
|
||||||
Ok(device_info) => {
|
|
||||||
// Skip re-open while rkcif still reports placeholder (≤64²) geometry.
|
|
||||||
let probed_res = device_info
|
|
||||||
.formats
|
|
||||||
.first()
|
|
||||||
.and_then(|f| f.resolutions.first())
|
|
||||||
.map(|r| (r.width, r.height));
|
|
||||||
|
|
||||||
if matches!(probed_res, Some((w, h)) if w <= 64 || h <= 64)
|
|
||||||
|| probed_res.is_none()
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
"Soft restart: probed resolution too small ({:?}), still no signal",
|
|
||||||
probed_res
|
|
||||||
);
|
|
||||||
set_retry(2_000);
|
|
||||||
go_offline();
|
|
||||||
std::thread::sleep(Duration::from_secs(2));
|
|
||||||
continue 'session;
|
|
||||||
}
|
|
||||||
|
|
||||||
handle.block_on(async {
|
|
||||||
let fmt;
|
|
||||||
let res;
|
|
||||||
{
|
|
||||||
let cfg = self.config.read().await;
|
|
||||||
fmt = self
|
|
||||||
.select_format(&device_info, cfg.format)
|
|
||||||
.unwrap_or(cfg.format);
|
|
||||||
res = self
|
|
||||||
.select_resolution(&device_info, &fmt, cfg.resolution)
|
|
||||||
.unwrap_or(cfg.resolution);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut cfg = self.config.write().await;
|
|
||||||
cfg.format = fmt;
|
|
||||||
cfg.resolution = res;
|
|
||||||
}
|
|
||||||
*self.current_device.write().await = Some(device_info);
|
|
||||||
info!(
|
|
||||||
"Soft restart: re-probed device → {}x{} {:?}",
|
|
||||||
res.width, res.height, fmt
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Soft restart: failed to re-probe device: {}", e);
|
|
||||||
// Brief wait before retrying to avoid spinning.
|
|
||||||
let wait = 2u64.pow(no_signal_restart_count.min(3));
|
|
||||||
std::thread::sleep(Duration::from_secs(wait));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset no_signal_since so the back-off timer is fresh for the new session.
|
|
||||||
// no_signal_since will be re-set if the new session immediately times out.
|
|
||||||
|
|
||||||
// Continue 'session → re-open CaptureStream with updated config.
|
|
||||||
} // 'session
|
} // 'session
|
||||||
|
|
||||||
self.direct_active.store(false, Ordering::SeqCst);
|
self.direct_active.store(false, Ordering::SeqCst);
|
||||||
@@ -1294,28 +1282,28 @@ impl Streamer {
|
|||||||
.map_err(|e| AppError::VideoError(format!("Cannot open device for re-init: {}", e)))?;
|
.map_err(|e| AppError::VideoError(format!("Cannot open device for re-init: {}", e)))?;
|
||||||
let device_info = device.info()?;
|
let device_info = device.info()?;
|
||||||
|
|
||||||
let (format, resolution) = {
|
let resolved = {
|
||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
let fmt = self
|
self.resolve_capture_config(&device_info, config.format, config.resolution, config.fps)
|
||||||
.select_format(&device_info, config.format)
|
.unwrap_or(super::device::ResolvedVideoInputConfig {
|
||||||
.unwrap_or(config.format);
|
format: config.format,
|
||||||
let res = self
|
resolution: config.resolution,
|
||||||
.select_resolution(&device_info, &fmt, config.resolution)
|
fps: config.fps,
|
||||||
.unwrap_or(config.resolution);
|
})
|
||||||
(fmt, res)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut cfg = self.config.write().await;
|
let mut cfg = self.config.write().await;
|
||||||
cfg.device_path = Some(device_info.path.clone());
|
cfg.device_path = Some(device_info.path.clone());
|
||||||
cfg.format = format;
|
cfg.format = resolved.format;
|
||||||
cfg.resolution = resolution;
|
cfg.resolution = resolved.resolution;
|
||||||
|
cfg.fps = resolved.fps;
|
||||||
}
|
}
|
||||||
*self.current_device.write().await = Some(device_info);
|
*self.current_device.write().await = Some(device_info);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Device re-initialized: {}x{} {:?}",
|
"Device re-initialized: {}x{} {:?}",
|
||||||
resolution.width, resolution.height, format
|
resolved.resolution.width, resolved.resolution.height, resolved.format
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1324,9 +1312,11 @@ impl Streamer {
|
|||||||
pub async fn stats(&self) -> StreamerStats {
|
pub async fn stats(&self) -> StreamerStats {
|
||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
let fps = self.current_fps.load(Ordering::Relaxed) as f32 / 100.0;
|
let fps = self.current_fps.load(Ordering::Relaxed) as f32 / 100.0;
|
||||||
|
let (state, reason) = self.state().await.external_state();
|
||||||
|
|
||||||
StreamerStats {
|
StreamerStats {
|
||||||
state: self.state().await,
|
state: state.to_string(),
|
||||||
|
reason: reason.map(str::to_string),
|
||||||
device: self.current_device().await.map(|d| d.name),
|
device: self.current_device().await.map(|d| d.name),
|
||||||
format: Some(config.format.to_string()),
|
format: Some(config.format.to_string()),
|
||||||
resolution: Some((config.resolution.width, config.resolution.height)),
|
resolution: Some((config.resolution.width, config.resolution.height)),
|
||||||
@@ -1418,7 +1408,7 @@ impl Streamer {
|
|||||||
|
|
||||||
// Publish device lost event
|
// Publish device lost event
|
||||||
self.publish_event(SystemEvent::StreamDeviceLost {
|
self.publish_event(SystemEvent::StreamDeviceLost {
|
||||||
kind: StreamDeviceLostKind::Video,
|
kind: StreamKind::Video,
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
reason: reason.clone(),
|
reason: reason.clone(),
|
||||||
})
|
})
|
||||||
@@ -1567,7 +1557,9 @@ impl Default for Streamer {
|
|||||||
/// Streamer statistics
|
/// Streamer statistics
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct StreamerStats {
|
pub struct StreamerStats {
|
||||||
pub state: StreamerState,
|
pub state: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reason: Option<String>,
|
||||||
pub device: Option<String>,
|
pub device: Option<String>,
|
||||||
pub format: Option<String>,
|
pub format: Option<String>,
|
||||||
pub resolution: Option<(u32, u32)>,
|
pub resolution: Option<(u32, u32)>,
|
||||||
@@ -1597,50 +1589,6 @@ fn probe_subdev_signal(
|
|||||||
probe.as_status()
|
probe.as_status()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wait_subdev_for_source_change(
|
|
||||||
subdev_path: &std::path::Path,
|
|
||||||
direct_stop: &AtomicBool,
|
|
||||||
max_wait: Duration,
|
|
||||||
) {
|
|
||||||
let fd = match csi_bridge::open_subdev(subdev_path) {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) => {
|
|
||||||
debug!(
|
|
||||||
"wait_subdev_for_source_change: failed to open {:?}: {}",
|
|
||||||
subdev_path, e
|
|
||||||
);
|
|
||||||
std::thread::sleep(max_wait.min(Duration::from_secs(1)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) = csi_bridge::subscribe_source_change(&fd) {
|
|
||||||
debug!(
|
|
||||||
"wait_subdev_for_source_change: subscribe failed on {:?}: {}",
|
|
||||||
subdev_path, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let slice = Duration::from_millis(250);
|
|
||||||
let deadline = std::time::Instant::now() + max_wait;
|
|
||||||
while std::time::Instant::now() < deadline {
|
|
||||||
if direct_stop.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
|
||||||
let wait = remaining.min(slice);
|
|
||||||
match csi_bridge::wait_source_change(&fd, wait) {
|
|
||||||
Ok(true) => {
|
|
||||||
info!("Subdev SOURCE_CHANGE during no-signal wait, retrying open immediately");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Ok(false) => continue,
|
|
||||||
Err(e) => {
|
|
||||||
debug!("wait_source_change error on {:?}: {}", subdev_path, e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl serde::Serialize for StreamerState {
|
impl serde::Serialize for StreamerState {
|
||||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use super::types::{
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::events::EventBus;
|
use crate::events::EventBus;
|
||||||
use crate::hid::HidController;
|
use crate::hid::HidController;
|
||||||
|
use crate::video::device::VideoDeviceInfo;
|
||||||
|
|
||||||
/// Trait for video output consumers that receive encoded video frames.
|
/// Trait for video output consumers that receive encoded video frames.
|
||||||
///
|
///
|
||||||
@@ -24,14 +25,12 @@ pub trait VideoOutput: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
device_path: PathBuf,
|
device_path: PathBuf,
|
||||||
jpeg_quality: u8,
|
jpeg_quality: u8,
|
||||||
subdev_path: Option<PathBuf>,
|
device_info: Option<VideoDeviceInfo>,
|
||||||
bridge_kind: Option<String>,
|
|
||||||
v4l2_driver: Option<String>,
|
|
||||||
);
|
);
|
||||||
async fn current_video_codec(&self) -> VideoCodecType;
|
async fn current_video_codec(&self) -> VideoCodecType;
|
||||||
async fn is_hardware_encoding(&self) -> bool;
|
async fn is_hardware_encoding(&self) -> bool;
|
||||||
async fn close_all_sessions(&self);
|
async fn close_all_sessions(&self);
|
||||||
async fn close_all_sessions_and_release_device(&self) -> usize;
|
async fn close_all_sessions_and_release_device(&self) -> Result<usize>;
|
||||||
async fn session_count(&self) -> usize;
|
async fn session_count(&self) -> usize;
|
||||||
async fn set_hid_controller(&self, hid: Arc<HidController>);
|
async fn set_hid_controller(&self, hid: Arc<HidController>);
|
||||||
async fn set_audio_enabled(&self, enabled: bool) -> Result<()>;
|
async fn set_audio_enabled(&self, enabled: bool) -> Result<()>;
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ pub use super::codec::registry::{EncoderBackend, VideoEncoderType};
|
|||||||
|
|
||||||
// From video::pipeline
|
// From video::pipeline
|
||||||
pub use super::pipeline::{
|
pub use super::pipeline::{
|
||||||
EncodedVideoFrame, PipelineStateNotification, SharedVideoPipeline, SharedVideoPipelineConfig,
|
EncodedVideoFrame, PipelineAppliedConfig, PipelineLifecycle, PipelineStateNotification,
|
||||||
SharedVideoPipelineStats,
|
SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
|
||||||
};
|
};
|
||||||
|
|||||||
116
src/web/error.rs
116
src/web/error.rs
@@ -9,6 +9,8 @@ use serde::Serialize;
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub code: Option<&'static str>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +19,8 @@ impl IntoResponse for AppError {
|
|||||||
let status = status_code(&self);
|
let status = status_code(&self);
|
||||||
let body = ErrorResponse {
|
let body = ErrorResponse {
|
||||||
success: false,
|
success: false,
|
||||||
message: self.to_string(),
|
code: error_code(&self),
|
||||||
|
message: public_message(&self),
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -38,10 +41,53 @@ fn status_code(error: &AppError) -> StatusCode {
|
|||||||
AppError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,
|
AppError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||||
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
AppError::Msd(error) => msd_status_code(error.code()),
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn error_code(error: &AppError) -> Option<&'static str> {
|
||||||
|
match error {
|
||||||
|
AppError::Msd(error) => Some(error.code().as_str()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public_message(error: &AppError) -> String {
|
||||||
|
match error {
|
||||||
|
AppError::Msd(error) => error.code().message().to_string(),
|
||||||
|
_ => error.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn msd_status_code(code: crate::error::MsdErrorCode) -> StatusCode {
|
||||||
|
use crate::error::MsdErrorCode::*;
|
||||||
|
match code {
|
||||||
|
MsdUnavailable => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
MsdResourceNotFound | MsdDriveNotInitialized => StatusCode::NOT_FOUND,
|
||||||
|
MsdOperationInProgress
|
||||||
|
| MsdResourceAlreadyExists
|
||||||
|
| MsdMediaSlotsFull
|
||||||
|
| MsdMediaAlreadyMounted
|
||||||
|
| MsdMediaInUse
|
||||||
|
| MsdDriveConnected
|
||||||
|
| MsdMediumRemovalPrevented => StatusCode::CONFLICT,
|
||||||
|
MsdInvalidRequest
|
||||||
|
| MsdImageTooLarge
|
||||||
|
| MsdInvalidUrl
|
||||||
|
| MsdDriveFilesystemUnsupported
|
||||||
|
| MsdDriveSizeInvalid
|
||||||
|
| MsdStorageSpaceUnavailable
|
||||||
|
| MsdStorageFull
|
||||||
|
| MsdStorageReadOnly
|
||||||
|
| MsdStoragePermissionDenied => StatusCode::BAD_REQUEST,
|
||||||
|
MsdOperationFailed
|
||||||
|
| MsdRemoteDownloadFailed
|
||||||
|
| MsdDownloadIncomplete
|
||||||
|
| MsdDisconnectFailed => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -72,6 +118,18 @@ mod tests {
|
|||||||
status_code(&AppError::RateLimited("limited".to_string())),
|
status_code(&AppError::RateLimited("limited".to_string())),
|
||||||
StatusCode::TOO_MANY_REQUESTS
|
StatusCode::TOO_MANY_REQUESTS
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status_code(&AppError::from(
|
||||||
|
crate::error::MsdErrorCode::MsdMediumRemovalPrevented
|
||||||
|
)),
|
||||||
|
StatusCode::CONFLICT
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error_code(&AppError::from(
|
||||||
|
crate::error::MsdErrorCode::MsdMediumRemovalPrevented
|
||||||
|
)),
|
||||||
|
Some("MSD_MEDIUM_REMOVAL_PREVENTED")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -81,4 +139,60 @@ mod tests {
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_msd_error_has_a_stable_code_message_and_status() {
|
||||||
|
use crate::error::MsdErrorCode::*;
|
||||||
|
let cases = [
|
||||||
|
(MsdUnavailable, StatusCode::SERVICE_UNAVAILABLE),
|
||||||
|
(MsdOperationInProgress, StatusCode::CONFLICT),
|
||||||
|
(MsdOperationFailed, StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
(MsdInvalidRequest, StatusCode::BAD_REQUEST),
|
||||||
|
(MsdResourceNotFound, StatusCode::NOT_FOUND),
|
||||||
|
(MsdResourceAlreadyExists, StatusCode::CONFLICT),
|
||||||
|
(MsdMediaSlotsFull, StatusCode::CONFLICT),
|
||||||
|
(MsdMediaAlreadyMounted, StatusCode::CONFLICT),
|
||||||
|
(MsdMediaInUse, StatusCode::CONFLICT),
|
||||||
|
(MsdImageTooLarge, StatusCode::BAD_REQUEST),
|
||||||
|
(MsdInvalidUrl, StatusCode::BAD_REQUEST),
|
||||||
|
(MsdRemoteDownloadFailed, StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
(MsdDownloadIncomplete, StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
(MsdDriveNotInitialized, StatusCode::NOT_FOUND),
|
||||||
|
(MsdDriveConnected, StatusCode::CONFLICT),
|
||||||
|
(MsdDriveFilesystemUnsupported, StatusCode::BAD_REQUEST),
|
||||||
|
(MsdDriveSizeInvalid, StatusCode::BAD_REQUEST),
|
||||||
|
(MsdStorageSpaceUnavailable, StatusCode::BAD_REQUEST),
|
||||||
|
(MsdStorageFull, StatusCode::BAD_REQUEST),
|
||||||
|
(MsdStorageReadOnly, StatusCode::BAD_REQUEST),
|
||||||
|
(MsdStoragePermissionDenied, StatusCode::BAD_REQUEST),
|
||||||
|
(MsdMediumRemovalPrevented, StatusCode::CONFLICT),
|
||||||
|
(MsdDisconnectFailed, StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
];
|
||||||
|
assert_eq!(cases.len(), crate::error::MsdErrorCode::ALL.len());
|
||||||
|
for (code, expected_status) in cases {
|
||||||
|
let error = AppError::from(code);
|
||||||
|
assert_eq!(error_code(&error), Some(code.as_str()));
|
||||||
|
assert_eq!(public_message(&error), code.message());
|
||||||
|
assert!(!code.message().contains('/'));
|
||||||
|
assert_eq!(msd_status_code(code), expected_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn msd_response_contains_only_the_public_error_contract() {
|
||||||
|
let response =
|
||||||
|
AppError::from(crate::error::MsdErrorCode::MsdOperationFailed).into_response();
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json["success"], false);
|
||||||
|
assert_eq!(json["code"], "MSD_OPERATION_FAILED");
|
||||||
|
assert_eq!(
|
||||||
|
json["message"],
|
||||||
|
crate::error::MsdErrorCode::MsdOperationFailed.message()
|
||||||
|
);
|
||||||
|
assert_eq!(json.as_object().unwrap().len(), 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,17 +76,18 @@ async fn reconcile_otg_config(
|
|||||||
hid: &HidConfig,
|
hid: &HidConfig,
|
||||||
msd: &MsdConfig,
|
msd: &MsdConfig,
|
||||||
network: &OtgNetworkConfig,
|
network: &OtgNetworkConfig,
|
||||||
|
uac: &UacConfig,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
{
|
{
|
||||||
let _ = (state, hid, msd, network);
|
let _ = (state, hid, msd, network, uac);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
state
|
state
|
||||||
.otg_service
|
.otg_service
|
||||||
.apply_config(hid, msd, network)
|
.apply_config(hid, msd, network, uac)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
|
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
|
||||||
}
|
}
|
||||||
@@ -194,6 +195,7 @@ pub async fn apply_hid_config(
|
|||||||
new_config: &HidConfig,
|
new_config: &HidConfig,
|
||||||
msd_config: &MsdConfig,
|
msd_config: &MsdConfig,
|
||||||
network_config: &OtgNetworkConfig,
|
network_config: &OtgNetworkConfig,
|
||||||
|
uac_config: &UacConfig,
|
||||||
options: ConfigApplyOptions,
|
options: ConfigApplyOptions,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
new_config.validate_otg_functions()?;
|
new_config.validate_otg_functions()?;
|
||||||
@@ -236,7 +238,7 @@ pub async fn apply_hid_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if otg_config_changed {
|
if otg_config_changed {
|
||||||
reconcile_otg_config(state, new_config, msd_config, network_config).await?;
|
reconcile_otg_config(state, new_config, msd_config, network_config, uac_config).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !transitioning_away_from_otg {
|
if !transitioning_away_from_otg {
|
||||||
@@ -262,6 +264,7 @@ pub async fn apply_msd_config(
|
|||||||
new_config: &MsdConfig,
|
new_config: &MsdConfig,
|
||||||
hid_config: &HidConfig,
|
hid_config: &HidConfig,
|
||||||
network_config: &OtgNetworkConfig,
|
network_config: &OtgNetworkConfig,
|
||||||
|
uac_config: &UacConfig,
|
||||||
options: ConfigApplyOptions,
|
options: ConfigApplyOptions,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let hid_backend_is_otg = hid_config.backend == HidBackend::Otg;
|
let hid_backend_is_otg = hid_config.backend == HidBackend::Otg;
|
||||||
@@ -274,6 +277,9 @@ pub async fn apply_msd_config(
|
|||||||
let old_msd_enabled = old_config.enabled;
|
let old_msd_enabled = old_config.enabled;
|
||||||
let new_msd_enabled = effective_new_msd_enabled;
|
let new_msd_enabled = effective_new_msd_enabled;
|
||||||
let msd_dir_changed = old_config.msd_dir != new_config.msd_dir;
|
let msd_dir_changed = old_config.msd_dir != new_config.msd_dir;
|
||||||
|
let inquiry_strings_changed = old_config.flash_inquiry_string
|
||||||
|
!= new_config.flash_inquiry_string
|
||||||
|
|| old_config.cdrom_inquiry_string != new_config.cdrom_inquiry_string;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"MSD enabled: old={}, new={}",
|
"MSD enabled: old={}, new={}",
|
||||||
@@ -283,6 +289,9 @@ pub async fn apply_msd_config(
|
|||||||
if msd_dir_changed {
|
if msd_dir_changed {
|
||||||
tracing::info!("MSD directory changed: {}", new_config.msd_dir);
|
tracing::info!("MSD directory changed: {}", new_config.msd_dir);
|
||||||
}
|
}
|
||||||
|
if inquiry_strings_changed {
|
||||||
|
tracing::info!("MSD inquiry strings changed");
|
||||||
|
}
|
||||||
|
|
||||||
let msd_dir = new_config.msd_dir_path();
|
let msd_dir = new_config.msd_dir_path();
|
||||||
if let Err(e) = std::fs::create_dir_all(msd_dir.join("images")) {
|
if let Err(e) = std::fs::create_dir_all(msd_dir.join("images")) {
|
||||||
@@ -292,19 +301,19 @@ pub async fn apply_msd_config(
|
|||||||
tracing::warn!("Failed to create MSD ventoy directory: {}", e);
|
tracing::warn!("Failed to create MSD ventoy directory: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
let needs_reload = options.force || old_msd_enabled != new_msd_enabled || msd_dir_changed;
|
let needs_reload = options.force
|
||||||
|
|| old_msd_enabled != new_msd_enabled
|
||||||
|
|| msd_dir_changed
|
||||||
|
|| inquiry_strings_changed;
|
||||||
if !needs_reload {
|
if !needs_reload {
|
||||||
tracing::info!(
|
tracing::info!("MSD configuration unchanged, no reload needed");
|
||||||
"MSD enabled state unchanged ({}) and directory unchanged, no reload needed",
|
|
||||||
new_msd_enabled
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if new_msd_enabled {
|
if new_msd_enabled {
|
||||||
tracing::info!("(Re)initializing MSD...");
|
tracing::info!("(Re)initializing MSD...");
|
||||||
|
|
||||||
reconcile_otg_config(state, hid_config, new_config, network_config).await?;
|
reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?;
|
||||||
|
|
||||||
let mut msd_guard = state.msd.write().await;
|
let mut msd_guard = state.msd.write().await;
|
||||||
if let Some(msd) = msd_guard.as_mut() {
|
if let Some(msd) = msd_guard.as_mut() {
|
||||||
@@ -339,7 +348,7 @@ pub async fn apply_msd_config(
|
|||||||
*msd_guard = None;
|
*msd_guard = None;
|
||||||
tracing::info!("MSD shutdown complete");
|
tracing::info!("MSD shutdown complete");
|
||||||
|
|
||||||
reconcile_otg_config(state, hid_config, new_config, network_config).await?;
|
reconcile_otg_config(state, hid_config, new_config, network_config, uac_config).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if hid_config.backend == HidBackend::Otg
|
if hid_config.backend == HidBackend::Otg
|
||||||
@@ -365,6 +374,31 @@ pub async fn apply_usb_config(
|
|||||||
let transitioning_away_from_otg =
|
let transitioning_away_from_otg =
|
||||||
old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg;
|
old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg;
|
||||||
|
|
||||||
|
let hid_unchanged = old_config.hid == new_config.hid;
|
||||||
|
let otg_gadget_rebuilt = old_config.msd != new_config.msd
|
||||||
|
|| old_config.otg_network != new_config.otg_network
|
||||||
|
|| old_config.uac != new_config.uac
|
||||||
|
|| old_config.hid.otg_udc != new_config.hid.otg_udc
|
||||||
|
|| old_config.hid.otg_descriptor != new_config.hid.otg_descriptor
|
||||||
|
|| old_config.hid.backend != new_config.hid.backend
|
||||||
|
|| old_config.hid.constrained_otg_functions()
|
||||||
|
!= new_config.hid.constrained_otg_functions()
|
||||||
|
|| old_config.hid.effective_otg_keyboard_leds()
|
||||||
|
!= new_config.hid.effective_otg_keyboard_leds();
|
||||||
|
let restart_uac_playback =
|
||||||
|
old_config.uac != new_config.uac || (new_config.uac.enabled && otg_gadget_rebuilt);
|
||||||
|
|
||||||
|
// A bound ALSA handle refers to the old configfs function. Stop it
|
||||||
|
// before any gadget teardown so the worker cannot write through a
|
||||||
|
// disappearing PCM node. It is restarted only after every reconcile.
|
||||||
|
if restart_uac_playback {
|
||||||
|
let playback = state.uac_playback.write().await.take();
|
||||||
|
if let Some(playback) = playback {
|
||||||
|
playback.stop();
|
||||||
|
tracing::info!("UAC playback writer stopped before OTG reconcile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if transitioning_away_from_otg {
|
if transitioning_away_from_otg {
|
||||||
apply_hid_config(
|
apply_hid_config(
|
||||||
state,
|
state,
|
||||||
@@ -372,6 +406,7 @@ pub async fn apply_usb_config(
|
|||||||
&new_config.hid,
|
&new_config.hid,
|
||||||
&new_config.msd,
|
&new_config.msd,
|
||||||
&new_config.otg_network,
|
&new_config.otg_network,
|
||||||
|
&new_config.uac,
|
||||||
ConfigApplyOptions::default(),
|
ConfigApplyOptions::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -381,6 +416,7 @@ pub async fn apply_usb_config(
|
|||||||
&new_config.hid,
|
&new_config.hid,
|
||||||
&new_config.msd,
|
&new_config.msd,
|
||||||
&new_config.otg_network,
|
&new_config.otg_network,
|
||||||
|
&new_config.uac,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
apply_hid_config(
|
apply_hid_config(
|
||||||
@@ -389,20 +425,52 @@ pub async fn apply_usb_config(
|
|||||||
&new_config.hid,
|
&new_config.hid,
|
||||||
&new_config.msd,
|
&new_config.msd,
|
||||||
&new_config.otg_network,
|
&new_config.otg_network,
|
||||||
|
&new_config.uac,
|
||||||
ConfigApplyOptions::default(),
|
ConfigApplyOptions::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the OTG gadget was rebuilt due to MSD or network config changes
|
||||||
|
// while HID config stayed the same, the /dev/hidg* devices are new and
|
||||||
|
// the HID backend must be reloaded to reopen them.
|
||||||
|
if hid_unchanged && otg_gadget_rebuilt && new_config.hid.backend == HidBackend::Otg {
|
||||||
|
tracing::info!("OTG gadget rebuilt, reloading HID backend for new devices");
|
||||||
|
let hid_backend = hid_backend_type(&new_config.hid);
|
||||||
|
state.hid.reload(hid_backend).await.map_err(|e| {
|
||||||
|
AppError::Config(format!("HID reload after gadget rebuild failed: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
apply_msd_config(
|
apply_msd_config(
|
||||||
state,
|
state,
|
||||||
&old_config.msd,
|
&old_config.msd,
|
||||||
&new_config.msd,
|
&new_config.msd,
|
||||||
&new_config.hid,
|
&new_config.hid,
|
||||||
&new_config.otg_network,
|
&new_config.otg_network,
|
||||||
|
&new_config.uac,
|
||||||
ConfigApplyOptions::default(),
|
ConfigApplyOptions::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
// apply_msd_config may perform a second gadget reconcile. Resolve the
|
||||||
|
// new ALSA card only after that final rebuild, then publish the worker.
|
||||||
|
if restart_uac_playback && new_config.uac.enabled {
|
||||||
|
let config = crate::audio::uac::UacPlaybackConfig {
|
||||||
|
sample_rate: new_config.uac.sample_rate,
|
||||||
|
channels: new_config.uac.channels as u16,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let writer = crate::audio::uac::UacPlayback::start(config).map_err(|error| {
|
||||||
|
AppError::Config(format!("Failed to start UAC playback: {error}"))
|
||||||
|
})?;
|
||||||
|
*state.uac_playback.write().await = Some(writer);
|
||||||
|
tracing::info!("UAC playback writer started after OTG reconcile");
|
||||||
|
} else if restart_uac_playback {
|
||||||
|
tracing::info!("UAC playback remains disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
@@ -413,6 +481,7 @@ pub async fn apply_usb_config(
|
|||||||
&new_config.hid,
|
&new_config.hid,
|
||||||
&new_config.msd,
|
&new_config.msd,
|
||||||
&new_config.otg_network,
|
&new_config.otg_network,
|
||||||
|
&new_config.uac,
|
||||||
ConfigApplyOptions::default(),
|
ConfigApplyOptions::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ mod redfish;
|
|||||||
mod rtsp;
|
mod rtsp;
|
||||||
mod rustdesk;
|
mod rustdesk;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod uac;
|
||||||
mod usb_update;
|
mod usb_update;
|
||||||
pub(crate) mod video;
|
pub(crate) mod video;
|
||||||
mod vnc;
|
mod vnc;
|
||||||
@@ -41,6 +43,8 @@ pub use rustdesk::{
|
|||||||
update_rustdesk_config,
|
update_rustdesk_config,
|
||||||
};
|
};
|
||||||
pub use stream::{get_stream_config, update_stream_config};
|
pub use stream::{get_stream_config, update_stream_config};
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub use uac::{get_uac_config, update_uac_config};
|
||||||
pub use video::{get_video_config, update_video_config};
|
pub use video::{get_video_config, update_video_config};
|
||||||
pub use vnc::{
|
pub use vnc::{
|
||||||
get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config,
|
get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config,
|
||||||
|
|||||||
@@ -54,6 +54,22 @@ pub struct VideoConfigUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VideoConfigUpdate {
|
impl VideoConfigUpdate {
|
||||||
|
pub fn ignore_source_following_parameters(&mut self) {
|
||||||
|
if self.format.is_some()
|
||||||
|
|| self.width.is_some()
|
||||||
|
|| self.height.is_some()
|
||||||
|
|| self.fps.is_some()
|
||||||
|
{
|
||||||
|
tracing::debug!(
|
||||||
|
"Ignoring client-supplied format, resolution, and FPS for source-following video input"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.format = None;
|
||||||
|
self.width = None;
|
||||||
|
self.height = None;
|
||||||
|
self.fps = None;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn validate(&self) -> crate::error::Result<()> {
|
pub fn validate(&self) -> crate::error::Result<()> {
|
||||||
if let Some(width) = self.width {
|
if let Some(width) = self.width {
|
||||||
if !(320..=7680).contains(&width) {
|
if !(320..=7680).contains(&width) {
|
||||||
@@ -106,6 +122,32 @@ impl VideoConfigUpdate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod video_config_update_tests {
|
||||||
|
use super::VideoConfigUpdate;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_following_parameters_are_silently_discarded() {
|
||||||
|
let mut update = VideoConfigUpdate {
|
||||||
|
device: Some("/dev/video0".to_string()),
|
||||||
|
format: Some("MJPEG".to_string()),
|
||||||
|
width: Some(7680),
|
||||||
|
height: Some(4320),
|
||||||
|
fps: Some(120),
|
||||||
|
quality: Some(90),
|
||||||
|
};
|
||||||
|
update.ignore_source_following_parameters();
|
||||||
|
|
||||||
|
assert_eq!(update.device.as_deref(), Some("/dev/video0"));
|
||||||
|
assert!(update.format.is_none());
|
||||||
|
assert!(update.width.is_none());
|
||||||
|
assert!(update.height.is_none());
|
||||||
|
assert!(update.fps.is_none());
|
||||||
|
assert_eq!(update.quality, Some(90));
|
||||||
|
assert!(update.validate().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stream configuration response
|
/// Stream configuration response
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -476,6 +518,8 @@ impl OtgNetworkConfigUpdate {
|
|||||||
pub struct MsdConfigUpdate {
|
pub struct MsdConfigUpdate {
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
pub msd_dir: Option<String>,
|
pub msd_dir: Option<String>,
|
||||||
|
pub flash_inquiry_string: Option<String>,
|
||||||
|
pub cdrom_inquiry_string: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -492,6 +536,12 @@ impl MsdConfigUpdate {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(ref value) = self.flash_inquiry_string {
|
||||||
|
MsdConfig::validate_inquiry_string("Flash", value)?;
|
||||||
|
}
|
||||||
|
if let Some(ref value) = self.cdrom_inquiry_string {
|
||||||
|
MsdConfig::validate_inquiry_string("CD-ROM", value)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,6 +552,12 @@ impl MsdConfigUpdate {
|
|||||||
if let Some(ref dir) = self.msd_dir {
|
if let Some(ref dir) = self.msd_dir {
|
||||||
config.msd_dir = dir.trim().to_string();
|
config.msd_dir = dir.trim().to_string();
|
||||||
}
|
}
|
||||||
|
if let Some(ref value) = self.flash_inquiry_string {
|
||||||
|
config.flash_inquiry_string = value.trim().to_string();
|
||||||
|
}
|
||||||
|
if let Some(ref value) = self.cdrom_inquiry_string {
|
||||||
|
config.cdrom_inquiry_string = value.trim().to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
src/web/handlers/config/uac.rs
Normal file
26
src/web/handlers/config/uac.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
|
||||||
|
use crate::config::UacConfig;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::usb_update::update_usb_config;
|
||||||
|
|
||||||
|
pub async fn get_uac_config(State(state): State<Arc<AppState>>) -> Json<UacConfig> {
|
||||||
|
Json(state.config.get().uac.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_uac_config(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<UacConfig>,
|
||||||
|
) -> Result<Json<UacConfig>> {
|
||||||
|
request.validate()?;
|
||||||
|
let config = update_usb_config(&state, move |staged| {
|
||||||
|
staged.uac = request;
|
||||||
|
Ok(None)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(Json(config.uac))
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ where
|
|||||||
staged_config.otg_network.host_mac = host_mac;
|
staged_config.otg_network.host_mac = host_mac;
|
||||||
}
|
}
|
||||||
staged_config.otg_network.validate()?;
|
staged_config.otg_network.validate()?;
|
||||||
|
staged_config.uac.validate()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) = apply_usb_config(state, &old_config, &staged_config).await {
|
if let Err(error) = apply_usb_config(state, &old_config, &staged_config).await {
|
||||||
@@ -92,6 +93,7 @@ where
|
|||||||
config.hid = staged_config.hid.clone();
|
config.hid = staged_config.hid.clone();
|
||||||
config.msd = staged_config.msd.clone();
|
config.msd = staged_config.msd.clone();
|
||||||
config.otg_network = staged_config.otg_network.clone();
|
config.otg_network = staged_config.otg_network.clone();
|
||||||
|
config.uac = staged_config.uac.clone();
|
||||||
config.enforce_invariants();
|
config.enforce_invariants();
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -14,8 +14,30 @@ pub async fn get_video_config(State(state): State<Arc<AppState>>) -> Json<VideoC
|
|||||||
|
|
||||||
pub async fn update_video_config(
|
pub async fn update_video_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<VideoConfigUpdate>,
|
Json(mut req): Json<VideoConfigUpdate>,
|
||||||
) -> Result<Json<VideoConfig>> {
|
) -> Result<Json<VideoConfig>> {
|
||||||
|
let selected_path = req
|
||||||
|
.device
|
||||||
|
.clone()
|
||||||
|
.or_else(|| state.config.get().video.device.clone());
|
||||||
|
if let Some(path) = selected_path {
|
||||||
|
let source_following = state
|
||||||
|
.stream_manager
|
||||||
|
.list_devices()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|devices| {
|
||||||
|
devices
|
||||||
|
.into_iter()
|
||||||
|
.find(|device| device.path.to_string_lossy() == path)
|
||||||
|
})
|
||||||
|
.is_some_and(|device| {
|
||||||
|
device.control_mode == crate::video::device::VideoControlMode::SourceFollowing
|
||||||
|
});
|
||||||
|
if source_following {
|
||||||
|
req.ignore_source_following_parameters();
|
||||||
|
}
|
||||||
|
}
|
||||||
req.validate()?;
|
req.validate()?;
|
||||||
|
|
||||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.video, "video")?;
|
let _apply_guard = try_apply_lock(&state.config_apply_locks.video, "video")?;
|
||||||
|
|||||||
@@ -4,76 +4,19 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use toml_edit::DocumentMut;
|
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::extensions::{
|
use crate::extensions::{
|
||||||
EasytierConfig, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus,
|
validate_easytier_config, validate_extension_config, validate_frpc_config,
|
||||||
FrpProxyType, FrpcConfig, FrpcConfigMode, FrpcInfo, GostcConfig, GostcInfo, TtydConfig,
|
validate_gostc_config, EasytierConfig, EasytierConfigMode, EasytierInfo, ExtensionId,
|
||||||
TtydInfo,
|
ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType, FrpcConfig, FrpcConfigMode,
|
||||||
|
FrpcInfo, GostcConfig, GostcInfo, TtydConfig, TtydInfo,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
fn validate_gostc_enabled(config: &GostcConfig) -> Result<()> {
|
fn bad_request(validation: std::result::Result<(), String>) -> Result<()> {
|
||||||
if config.addr.trim().is_empty() {
|
validation.map_err(AppError::BadRequest)
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"GOSTC server address is required".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if config.key.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("GOSTC client key is required".into()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_easytier_enabled(config: &EasytierConfig) -> Result<()> {
|
|
||||||
if config.network_name.trim().is_empty() {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"EasyTier network name is required".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_frpc_enabled(config: &FrpcConfig) -> Result<()> {
|
|
||||||
match config.config_mode {
|
|
||||||
FrpcConfigMode::Quick => {
|
|
||||||
if config.proxy_name.trim().is_empty() {
|
|
||||||
return Err(AppError::BadRequest("FRPC proxy name is required".into()));
|
|
||||||
}
|
|
||||||
if config.server_addr.trim().is_empty() {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"FRPC server address is required".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if config.token.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("FRPC token is required".into()));
|
|
||||||
}
|
|
||||||
if config.local_ip.trim().is_empty() {
|
|
||||||
return Err(AppError::BadRequest("FRPC local IP is required".into()));
|
|
||||||
}
|
|
||||||
if matches!(config.proxy_type, FrpProxyType::Tcp | FrpProxyType::Udp)
|
|
||||||
&& config.remote_port.is_none()
|
|
||||||
{
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"FRPC remote port is required for TCP/UDP proxies".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FrpcConfigMode::Full => {
|
|
||||||
let toml = config.custom_toml.trim();
|
|
||||||
if toml.is_empty() {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"FRPC full configuration is required".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
toml.parse::<DocumentMut>().map_err(|e| {
|
|
||||||
AppError::BadRequest(format!("FRPC full configuration is not valid TOML: {}", e))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<ExtensionsStatus> {
|
pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<ExtensionsStatus> {
|
||||||
@@ -131,6 +74,8 @@ pub async fn start_extension(
|
|||||||
let config = state.config.get();
|
let config = state.config.get();
|
||||||
let mgr = &state.extensions;
|
let mgr = &state.extensions;
|
||||||
|
|
||||||
|
bad_request(validate_extension_config(ext_id, &config.extensions))?;
|
||||||
|
|
||||||
mgr.start(ext_id, &config.extensions)
|
mgr.start(ext_id, &config.extensions)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Internal)?;
|
.map_err(AppError::Internal)?;
|
||||||
@@ -200,10 +145,12 @@ pub struct GostcConfigUpdate {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct EasytierConfigUpdate {
|
pub struct EasytierConfigUpdate {
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
|
pub config_mode: Option<EasytierConfigMode>,
|
||||||
pub network_name: Option<String>,
|
pub network_name: Option<String>,
|
||||||
pub network_secret: Option<String>,
|
pub network_secret: Option<String>,
|
||||||
pub peer_urls: Option<Vec<String>>,
|
pub peer_urls: Option<Vec<String>>,
|
||||||
pub virtual_ip: Option<String>,
|
pub virtual_ip: Option<String>,
|
||||||
|
pub custom_toml: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
@@ -284,7 +231,7 @@ pub async fn update_gostc_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if next_gostc.enabled {
|
if next_gostc.enabled {
|
||||||
validate_gostc_enabled(&next_gostc)?;
|
bad_request(validate_gostc_config(&next_gostc))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
state
|
state
|
||||||
@@ -321,6 +268,9 @@ pub async fn update_easytier_config(
|
|||||||
if let Some(enabled) = req.enabled {
|
if let Some(enabled) = req.enabled {
|
||||||
next_easytier.enabled = enabled;
|
next_easytier.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
if let Some(config_mode) = req.config_mode {
|
||||||
|
next_easytier.config_mode = config_mode;
|
||||||
|
}
|
||||||
if let Some(ref name) = req.network_name {
|
if let Some(ref name) = req.network_name {
|
||||||
next_easytier.network_name = name.clone();
|
next_easytier.network_name = name.clone();
|
||||||
}
|
}
|
||||||
@@ -333,9 +283,12 @@ pub async fn update_easytier_config(
|
|||||||
if req.virtual_ip.is_some() {
|
if req.virtual_ip.is_some() {
|
||||||
next_easytier.virtual_ip = req.virtual_ip.clone();
|
next_easytier.virtual_ip = req.virtual_ip.clone();
|
||||||
}
|
}
|
||||||
|
if let Some(ref custom_toml) = req.custom_toml {
|
||||||
|
next_easytier.custom_toml = custom_toml.clone();
|
||||||
|
}
|
||||||
|
|
||||||
if next_easytier.enabled {
|
if next_easytier.enabled || matches!(next_easytier.config_mode, EasytierConfigMode::Full) {
|
||||||
validate_easytier_enabled(&next_easytier)?;
|
bad_request(validate_easytier_config(&next_easytier))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
state
|
state
|
||||||
@@ -414,7 +367,7 @@ pub async fn update_frpc_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if next_frpc.enabled || matches!(next_frpc.config_mode, FrpcConfigMode::Full) {
|
if next_frpc.enabled || matches!(next_frpc.config_mode, FrpcConfigMode::Full) {
|
||||||
validate_frpc_enabled(&next_frpc)?;
|
bad_request(validate_frpc_config(&next_frpc))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
state
|
state
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ pub struct VideoDevice {
|
|||||||
pub formats: Vec<VideoFormat>,
|
pub formats: Vec<VideoFormat>,
|
||||||
pub usb_bus: Option<String>,
|
pub usb_bus: Option<String>,
|
||||||
pub has_signal: bool,
|
pub has_signal: bool,
|
||||||
|
pub control_mode: crate::video::device::VideoControlMode,
|
||||||
|
pub input_status: crate::video::device::VideoInputStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct VideoInputStatusQuery {
|
||||||
|
pub device: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -121,6 +128,8 @@ pub async fn list_devices(State(state): State<Arc<AppState>>) -> Json<DeviceList
|
|||||||
.collect(),
|
.collect(),
|
||||||
usb_bus,
|
usb_bus,
|
||||||
has_signal: d.has_signal,
|
has_signal: d.has_signal,
|
||||||
|
control_mode: d.control_mode,
|
||||||
|
input_status: d.input_status,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -180,3 +189,71 @@ pub async fn list_devices(State(state): State<Arc<AppState>>) -> Json<DeviceList
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn validated_video_node(path: &str, sysfs_root: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||||
|
let path = std::path::Path::new(path);
|
||||||
|
let name = path.file_name()?.to_str()?;
|
||||||
|
if path.parent() != Some(std::path::Path::new("/dev"))
|
||||||
|
|| !name.starts_with("video")
|
||||||
|
|| name.len() == "video".len()
|
||||||
|
|| !name["video".len()..].chars().all(|c| c.is_ascii_digit())
|
||||||
|
|| !sysfs_root.join(name).exists()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn video_input_status(
|
||||||
|
Query(query): Query<VideoInputStatusQuery>,
|
||||||
|
) -> Result<Json<crate::video::device::VideoInputStatus>> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
let path = validated_video_node(
|
||||||
|
&query.device,
|
||||||
|
std::path::Path::new("/sys/class/video4linux"),
|
||||||
|
)
|
||||||
|
.ok_or_else(|| AppError::BadRequest("Invalid video device".to_string()))?;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
let path = crate::video::device::enumerate_devices()?
|
||||||
|
.into_iter()
|
||||||
|
.find(|device| device.path.to_string_lossy() == query.device)
|
||||||
|
.map(|device| device.path)
|
||||||
|
.ok_or_else(|| AppError::BadRequest("Invalid video device".to_string()))?;
|
||||||
|
|
||||||
|
let probe_path = path.clone();
|
||||||
|
let status = tokio::task::spawn_blocking(move || {
|
||||||
|
crate::video::device::VideoDevice::open_readonly(&probe_path)
|
||||||
|
.and_then(|device| device.input_status())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|result| result.ok())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
debug!(device = %path.display(), "Unable to read video input status");
|
||||||
|
crate::video::device::VideoInputStatus::unavailable()
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
|
mod tests {
|
||||||
|
use super::validated_video_node;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_accepts_dev_video_nodes_present_in_sysfs() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir(root.path().join("video7")).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
validated_video_node("/dev/video7", root.path()).unwrap(),
|
||||||
|
std::path::PathBuf::from("/dev/video7")
|
||||||
|
);
|
||||||
|
assert!(validated_video_node("/dev/video8", root.path()).is_none());
|
||||||
|
assert!(validated_video_node("/tmp/video7", root.path()).is_none());
|
||||||
|
assert!(validated_video_node("/dev/video7/../mem", root.path()).is_none());
|
||||||
|
assert!(validated_video_node("/dev/video", root.path()).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ use axum::{
|
|||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use self::config::apply::ConfigApplyOptions;
|
use self::config::apply::ConfigApplyOptions;
|
||||||
use crate::auth::{Session, SESSION_COOKIE};
|
use crate::auth::{Session, SESSION_COOKIE};
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ use super::*;
|
|||||||
|
|
||||||
use crate::msd::{
|
use crate::msd::{
|
||||||
DiskModeRequest, DownloadProgress, DriveFile, DriveInfo, DriveInitRequest,
|
DiskModeRequest, DownloadProgress, DriveFile, DriveInfo, DriveInitRequest,
|
||||||
ImageDownloadRequest, ImageInfo, ImageManager, ImageMountRequest, MsdState, MsdStateResponse,
|
ImageDownloadRequest, ImageInfo, ImageManager, ImageMountRequest, MsdErrorCode, MsdState,
|
||||||
VentoyDrive, MIN_DRIVE_SIZE_MB,
|
MsdStateResponse, VentoyDrive, MIN_DRIVE_SIZE_MB,
|
||||||
};
|
};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
use axum::extract::{multipart::MultipartRejection, rejection::JsonRejection};
|
||||||
|
#[cfg(unix)]
|
||||||
use axum::extract::{Multipart, Path as AxumPath};
|
use axum::extract::{Multipart, Path as AxumPath};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
@@ -29,10 +31,7 @@ async fn assert_drive_not_connected(state: &Arc<AppState>) -> Result<()> {
|
|||||||
let msd_guard = state.msd.read().await;
|
let msd_guard = state.msd.read().await;
|
||||||
if let Some(controller) = msd_guard.as_ref() {
|
if let Some(controller) = msd_guard.as_ref() {
|
||||||
if controller.is_drive_connected().await {
|
if controller.is_drive_connected().await {
|
||||||
return Err(AppError::BadRequest(
|
return Err(MsdErrorCode::MsdDriveConnected.into());
|
||||||
"Virtual drive is connected to the USB host; disconnect it before modifying files"
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -42,35 +41,61 @@ async fn assert_drive_not_connected(state: &Arc<AppState>) -> Result<()> {
|
|||||||
fn validate_drive_init_size(size_mb: u32, available_bytes: u64) -> Result<()> {
|
fn validate_drive_init_size(size_mb: u32, available_bytes: u64) -> Result<()> {
|
||||||
let requested_bytes = size_mb as u64 * MIB;
|
let requested_bytes = size_mb as u64 * MIB;
|
||||||
if size_mb < MIN_DRIVE_SIZE_MB {
|
if size_mb < MIN_DRIVE_SIZE_MB {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(MsdErrorCode::MsdDriveSizeInvalid.into());
|
||||||
"Virtual drive size must be at least {} MB",
|
|
||||||
MIN_DRIVE_SIZE_MB
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
if requested_bytes > available_bytes {
|
if requested_bytes > available_bytes {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(MsdErrorCode::MsdStorageFull.into());
|
||||||
"Virtual drive size cannot exceed available space on the MSD directory filesystem (available {} MB, requested {} MB)",
|
|
||||||
available_bytes / MIB,
|
|
||||||
size_mb
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn is_unsupported_drive_filesystem(error: &str) -> bool {
|
fn msd_controller<'a>(
|
||||||
error.contains("Filesystem error")
|
guard: &'a tokio::sync::RwLockReadGuard<'_, Option<crate::msd::MsdController>>,
|
||||||
|| error.contains("Image error")
|
) -> Result<&'a crate::msd::MsdController> {
|
||||||
|| error.contains("Partition error")
|
guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| MsdErrorCode::MsdUnavailable.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn unsupported_drive_filesystem_error(error: &str) -> AppError {
|
fn classify_storage_error(operation: &'static str, error: std::io::Error) -> AppError {
|
||||||
tracing::warn!(
|
tracing::warn!(operation, %error, "MSD storage operation failed");
|
||||||
error = %error,
|
match error.raw_os_error() {
|
||||||
"Virtual drive filesystem is not supported"
|
Some(libc::ENOSPC) => MsdErrorCode::MsdStorageFull.into(),
|
||||||
);
|
Some(libc::EROFS) => MsdErrorCode::MsdStorageReadOnly.into(),
|
||||||
AppError::BadRequest("Unsupported drive filesystem".to_string())
|
Some(libc::EACCES | libc::EPERM) => MsdErrorCode::MsdStoragePermissionDenied.into(),
|
||||||
|
_ => MsdErrorCode::MsdOperationFailed.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn operation_failed(operation: &'static str, error: AppError) -> AppError {
|
||||||
|
match error {
|
||||||
|
AppError::Msd(error) => AppError::Msd(error),
|
||||||
|
error => {
|
||||||
|
tracing::warn!(operation, %error, "Unclassified MSD operation failed");
|
||||||
|
MsdErrorCode::MsdOperationFailed.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn parse_msd_json<T>(payload: std::result::Result<Json<T>, JsonRejection>) -> Result<T> {
|
||||||
|
payload.map(|Json(value)| value).map_err(|error| {
|
||||||
|
tracing::warn!(%error, "Failed to parse MSD JSON request");
|
||||||
|
MsdErrorCode::MsdInvalidRequest.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn parse_msd_multipart(
|
||||||
|
payload: std::result::Result<Multipart, MultipartRejection>,
|
||||||
|
) -> Result<Multipart> {
|
||||||
|
payload.map_err(|error| {
|
||||||
|
tracing::warn!(%error, "Failed to parse MSD multipart request");
|
||||||
|
MsdErrorCode::MsdInvalidRequest.into()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MSD status response
|
/// MSD status response
|
||||||
@@ -115,22 +140,22 @@ pub async fn msd_images_list(State(state): State<Arc<AppState>>) -> Result<Json<
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub async fn msd_image_upload(
|
pub async fn msd_image_upload(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
mut multipart: Multipart,
|
multipart: std::result::Result<Multipart, MultipartRejection>,
|
||||||
) -> Result<Json<ImageInfo>> {
|
) -> Result<Json<ImageInfo>> {
|
||||||
|
let mut multipart = parse_msd_multipart(multipart)?;
|
||||||
let config = state.config.get();
|
let config = state.config.get();
|
||||||
let images_path = config.msd.images_dir();
|
let images_path = config.msd.images_dir();
|
||||||
let manager = ImageManager::new(images_path);
|
let manager = ImageManager::new(images_path);
|
||||||
|
|
||||||
while let Some(field) = multipart
|
while let Some(field) = multipart.next_field().await.map_err(|error| {
|
||||||
.next_field()
|
tracing::warn!(%error, "Failed to parse MSD image upload");
|
||||||
.await
|
AppError::from(MsdErrorCode::MsdInvalidRequest)
|
||||||
.map_err(|e| AppError::Internal(format!("Multipart error: {}", e)))?
|
})? {
|
||||||
{
|
|
||||||
let name = field.name().unwrap_or("file").to_string();
|
let name = field.name().unwrap_or("file").to_string();
|
||||||
if name == "file" {
|
if name == "file" {
|
||||||
let filename = field
|
let filename = field
|
||||||
.file_name()
|
.file_name()
|
||||||
.ok_or_else(|| AppError::BadRequest("Missing filename".to_string()))?
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdInvalidRequest))?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Use streaming upload - chunks are written directly to disk
|
// Use streaming upload - chunks are written directly to disk
|
||||||
@@ -142,7 +167,7 @@ pub async fn msd_image_upload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(AppError::BadRequest("No file provided".to_string()))
|
Err(MsdErrorCode::MsdInvalidRequest.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get image by ID
|
/// Get image by ID
|
||||||
@@ -166,10 +191,11 @@ pub async fn msd_image_delete(
|
|||||||
AxumPath(id): AxumPath<String>,
|
AxumPath(id): AxumPath<String>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
let msd_guard = state.msd.read().await;
|
let msd_guard = state.msd.read().await;
|
||||||
let controller = msd_guard
|
let controller = msd_controller(&msd_guard)?;
|
||||||
.as_ref()
|
controller
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
.delete_image(&id)
|
||||||
controller.delete_image(&id).await?;
|
.await
|
||||||
|
.map_err(|error| operation_failed("delete image", error))?;
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: Some("Image deleted".to_string()),
|
message: Some("Image deleted".to_string()),
|
||||||
@@ -180,14 +206,16 @@ pub async fn msd_image_delete(
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub async fn msd_image_download(
|
pub async fn msd_image_download(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<ImageDownloadRequest>,
|
payload: std::result::Result<Json<ImageDownloadRequest>, JsonRejection>,
|
||||||
) -> Result<Json<DownloadProgress>> {
|
) -> Result<Json<DownloadProgress>> {
|
||||||
|
let req = parse_msd_json(payload)?;
|
||||||
let msd_guard = state.msd.read().await;
|
let msd_guard = state.msd.read().await;
|
||||||
let controller = msd_guard
|
let controller = msd_controller(&msd_guard)?;
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
|
||||||
|
|
||||||
let progress = controller.download_image(req.url, req.filename).await?;
|
let progress = controller
|
||||||
|
.download_image(req.url, req.filename)
|
||||||
|
.await
|
||||||
|
.map_err(|error| operation_failed("start image download", error))?;
|
||||||
|
|
||||||
Ok(Json(progress))
|
Ok(Json(progress))
|
||||||
}
|
}
|
||||||
@@ -202,14 +230,16 @@ pub struct CancelDownloadRequest {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub async fn msd_image_download_cancel(
|
pub async fn msd_image_download_cancel(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<CancelDownloadRequest>,
|
payload: std::result::Result<Json<CancelDownloadRequest>, JsonRejection>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
|
let req = parse_msd_json(payload)?;
|
||||||
let msd_guard = state.msd.read().await;
|
let msd_guard = state.msd.read().await;
|
||||||
let controller = msd_guard
|
let controller = msd_controller(&msd_guard)?;
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
|
||||||
|
|
||||||
controller.cancel_download(&req.download_id).await?;
|
controller
|
||||||
|
.cancel_download(&req.download_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| operation_failed("cancel image download", error))?;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -221,14 +251,16 @@ pub async fn msd_image_download_cancel(
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub async fn msd_disk_mode_put(
|
pub async fn msd_disk_mode_put(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<DiskModeRequest>,
|
payload: std::result::Result<Json<DiskModeRequest>, JsonRejection>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
let _otg_guard = try_apply_lock(&state.config_apply_locks.otg, "OTG")?;
|
let req = parse_msd_json(payload)?;
|
||||||
|
let _otg_guard = try_apply_lock(&state.config_apply_locks.otg, "OTG").map_err(|error| {
|
||||||
|
tracing::warn!(%error, "MSD disk mode change is blocked by another OTG operation");
|
||||||
|
AppError::from(MsdErrorCode::MsdOperationInProgress)
|
||||||
|
})?;
|
||||||
let current_mode = {
|
let current_mode = {
|
||||||
let msd_guard = state.msd.read().await;
|
let msd_guard = state.msd.read().await;
|
||||||
let controller = msd_guard
|
let controller = msd_controller(&msd_guard)?;
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
|
||||||
controller.state().await.disk_mode
|
controller.state().await.disk_mode
|
||||||
};
|
};
|
||||||
if current_mode == req.disk_mode {
|
if current_mode == req.disk_mode {
|
||||||
@@ -248,14 +280,14 @@ pub async fn msd_disk_mode_put(
|
|||||||
.hid
|
.hid
|
||||||
.prepare_otg_rebuild()
|
.prepare_otg_rebuild()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Config(format!("Failed to prepare OTG HID for rebuild: {e}")))?;
|
.map_err(|error| operation_failed("prepare HID for disk mode switch", error))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let switch_result = {
|
let switch_result = {
|
||||||
let mut msd_guard = state.msd.write().await;
|
let mut msd_guard = state.msd.write().await;
|
||||||
let controller = msd_guard
|
let controller = msd_guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
|
||||||
controller.set_disk_mode(req.disk_mode).await
|
controller.set_disk_mode(req.disk_mode).await
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,12 +303,18 @@ pub async fn msd_disk_mode_put(
|
|||||||
|
|
||||||
match (switch_result, hid_reload_result) {
|
match (switch_result, hid_reload_result) {
|
||||||
(Err(switch_error), Err(hid_error)) => {
|
(Err(switch_error), Err(hid_error)) => {
|
||||||
return Err(AppError::Internal(format!(
|
tracing::warn!(%switch_error, %hid_error, "MSD mode switch and HID recovery failed");
|
||||||
"MSD disk mode switch failed: {switch_error}; HID recovery failed: {hid_error}"
|
return Err(MsdErrorCode::MsdOperationFailed.into());
|
||||||
)));
|
}
|
||||||
|
(Err(switch_error), Ok(())) => {
|
||||||
|
return Err(operation_failed("switch disk mode", switch_error))
|
||||||
|
}
|
||||||
|
(Ok(_), Err(hid_error)) => {
|
||||||
|
return Err(operation_failed(
|
||||||
|
"recover HID after disk mode switch",
|
||||||
|
hid_error,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
(Err(switch_error), Ok(())) => return Err(switch_error),
|
|
||||||
(Ok(_), Err(hid_error)) => return Err(hid_error),
|
|
||||||
(Ok(_), Ok(())) => {}
|
(Ok(_), Ok(())) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,13 +329,14 @@ pub async fn msd_disk_mode_put(
|
|||||||
pub async fn msd_image_mount(
|
pub async fn msd_image_mount(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
AxumPath(id): AxumPath<String>,
|
AxumPath(id): AxumPath<String>,
|
||||||
Json(req): Json<ImageMountRequest>,
|
payload: std::result::Result<Json<ImageMountRequest>, JsonRejection>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
|
let req = parse_msd_json(payload)?;
|
||||||
let config = state.config.get();
|
let config = state.config.get();
|
||||||
let mut msd_guard = state.msd.write().await;
|
let mut msd_guard = state.msd.write().await;
|
||||||
let controller = msd_guard
|
let controller = msd_guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
|
||||||
|
|
||||||
let images_path = config.msd.images_dir();
|
let images_path = config.msd.images_dir();
|
||||||
let manager = ImageManager::new(images_path);
|
let manager = ImageManager::new(images_path);
|
||||||
@@ -305,7 +344,8 @@ pub async fn msd_image_mount(
|
|||||||
|
|
||||||
controller
|
controller
|
||||||
.mount_image(&image, req.cdrom, req.read_only)
|
.mount_image(&image, req.cdrom, req.read_only)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(|error| operation_failed("mount image", error))?;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -322,9 +362,12 @@ pub async fn msd_image_unmount(
|
|||||||
let mut msd_guard = state.msd.write().await;
|
let mut msd_guard = state.msd.write().await;
|
||||||
let controller = msd_guard
|
let controller = msd_guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
|
||||||
|
|
||||||
controller.unmount_image(&id).await?;
|
controller
|
||||||
|
.unmount_image(&id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| operation_failed("unmount image", error))?;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -338,9 +381,12 @@ pub async fn msd_drive_mount(State(state): State<Arc<AppState>>) -> Result<Json<
|
|||||||
let mut msd_guard = state.msd.write().await;
|
let mut msd_guard = state.msd.write().await;
|
||||||
let controller = msd_guard
|
let controller = msd_guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
|
||||||
|
|
||||||
controller.mount_drive().await?;
|
controller
|
||||||
|
.mount_drive()
|
||||||
|
.await
|
||||||
|
.map_err(|error| operation_failed("mount virtual drive", error))?;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -354,9 +400,12 @@ pub async fn msd_drive_unmount(State(state): State<Arc<AppState>>) -> Result<Jso
|
|||||||
let mut msd_guard = state.msd.write().await;
|
let mut msd_guard = state.msd.write().await;
|
||||||
let controller = msd_guard
|
let controller = msd_guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| AppError::Internal("MSD not initialized".to_string()))?;
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
|
||||||
|
|
||||||
controller.unmount_drive().await?;
|
controller
|
||||||
|
.unmount_drive()
|
||||||
|
.await
|
||||||
|
.map_err(|error| operation_failed("unmount virtual drive", error))?;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -373,46 +422,40 @@ pub async fn msd_drive_info(State(state): State<Arc<AppState>>) -> Result<Json<D
|
|||||||
|
|
||||||
if !drive.exists() {
|
if !drive.exists() {
|
||||||
// 404: drive image file does not exist at all — truly not initialized
|
// 404: drive image file does not exist at all — truly not initialized
|
||||||
return Err(AppError::NotFound("Drive not initialized".to_string()));
|
return Err(MsdErrorCode::MsdDriveNotInitialized.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
match drive.info().await {
|
drive
|
||||||
Ok(info) => Ok(Json(info)),
|
.info()
|
||||||
Err(e) => {
|
.await
|
||||||
let msg = e.to_string();
|
.map(Json)
|
||||||
// Detect filesystem-level failures (unrecognized format, bad partition table, etc.)
|
.map_err(|error| operation_failed("read virtual drive info", error))
|
||||||
// These mean the drive FILE exists but was formatted to an unsupported type
|
|
||||||
// (e.g. the controlled machine reformatted it as NTFS/exFAT).
|
|
||||||
// Return 400 so the frontend can distinguish this from 404 (file missing).
|
|
||||||
if is_unsupported_drive_filesystem(&msg) {
|
|
||||||
return Err(unsupported_drive_filesystem_error(&msg));
|
|
||||||
}
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize Ventoy drive
|
/// Initialize Ventoy drive
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub async fn msd_drive_init(
|
pub async fn msd_drive_init(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<DriveInitRequest>,
|
payload: std::result::Result<Json<DriveInitRequest>, JsonRejection>,
|
||||||
) -> Result<Json<DriveInfo>> {
|
) -> Result<Json<DriveInfo>> {
|
||||||
|
let req = parse_msd_json(payload)?;
|
||||||
|
assert_drive_not_connected(&state).await?;
|
||||||
let config = state.config.get();
|
let config = state.config.get();
|
||||||
let msd_dir = config.msd.msd_dir_path();
|
let msd_dir = config.msd.msd_dir_path();
|
||||||
|
|
||||||
let disk_space = get_disk_space(&msd_dir).map_err(|e| {
|
let disk_space = get_disk_space(&msd_dir).map_err(|error| {
|
||||||
AppError::BadRequest(format!(
|
tracing::warn!(%error, "Failed to read MSD storage space");
|
||||||
"Failed to read available space for the MSD directory filesystem: {}",
|
AppError::from(MsdErrorCode::MsdStorageSpaceUnavailable)
|
||||||
e
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
validate_drive_init_size(req.size_mb, disk_space.available)?;
|
validate_drive_init_size(req.size_mb, disk_space.available)?;
|
||||||
|
|
||||||
let drive_path = config.msd.drive_path();
|
let drive_path = config.msd.drive_path();
|
||||||
let drive = VentoyDrive::new(drive_path);
|
let drive = VentoyDrive::new(drive_path);
|
||||||
|
|
||||||
let info = drive.init(req.size_mb).await?;
|
let info = drive
|
||||||
|
.init(req.size_mb)
|
||||||
|
.await
|
||||||
|
.map_err(|error| operation_failed("initialize virtual drive", error))?;
|
||||||
Ok(Json(info))
|
Ok(Json(info))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,9 +468,7 @@ pub async fn msd_drive_delete(State(state): State<Arc<AppState>>) -> Result<Json
|
|||||||
let msd_guard = state.msd.write().await;
|
let msd_guard = state.msd.write().await;
|
||||||
if let Some(controller) = msd_guard.as_ref() {
|
if let Some(controller) = msd_guard.as_ref() {
|
||||||
if controller.is_drive_connected().await {
|
if controller.is_drive_connected().await {
|
||||||
return Err(AppError::BadRequest(
|
return Err(MsdErrorCode::MsdDriveConnected.into());
|
||||||
"Cannot delete drive while connected. Disconnect first.".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(msd_guard);
|
drop(msd_guard);
|
||||||
@@ -436,7 +477,7 @@ pub async fn msd_drive_delete(State(state): State<Arc<AppState>>) -> Result<Json
|
|||||||
let drive_path = config.msd.drive_path();
|
let drive_path = config.msd.drive_path();
|
||||||
if drive_path.exists() {
|
if drive_path.exists() {
|
||||||
std::fs::remove_file(&drive_path)
|
std::fs::remove_file(&drive_path)
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to delete drive file: {}", e)))?;
|
.map_err(|error| classify_storage_error("delete virtual drive", error))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
@@ -459,16 +500,10 @@ pub async fn msd_drive_files(
|
|||||||
let drive = VentoyDrive::new(drive_path);
|
let drive = VentoyDrive::new(drive_path);
|
||||||
|
|
||||||
let dir_path = params.get("path").map(|s| s.as_str()).unwrap_or("/");
|
let dir_path = params.get("path").map(|s| s.as_str()).unwrap_or("/");
|
||||||
let files = drive.list_files(dir_path).await.map_err(|e| {
|
let files = drive
|
||||||
// Provide a friendly message when the filesystem format is unrecognized
|
.list_files(dir_path)
|
||||||
// (e.g. user formatted it as NTFS/exFAT from the controlled machine)
|
.await
|
||||||
let msg = e.to_string();
|
.map_err(|error| operation_failed("list virtual drive files", error))?;
|
||||||
if is_unsupported_drive_filesystem(&msg) {
|
|
||||||
unsupported_drive_filesystem_error(&msg)
|
|
||||||
} else {
|
|
||||||
e
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(Json(files))
|
Ok(Json(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,8 +512,9 @@ pub async fn msd_drive_files(
|
|||||||
pub async fn msd_drive_upload(
|
pub async fn msd_drive_upload(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(params): Query<HashMap<String, String>>,
|
Query(params): Query<HashMap<String, String>>,
|
||||||
mut multipart: Multipart,
|
multipart: std::result::Result<Multipart, MultipartRejection>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
|
let mut multipart = parse_msd_multipart(multipart)?;
|
||||||
// Block when connected: writing to image while USB host has it mounted
|
// Block when connected: writing to image while USB host has it mounted
|
||||||
// causes filesystem corruption (Windows error 0x80070570)
|
// causes filesystem corruption (Windows error 0x80070570)
|
||||||
assert_drive_not_connected(&state).await?;
|
assert_drive_not_connected(&state).await?;
|
||||||
@@ -489,16 +525,15 @@ pub async fn msd_drive_upload(
|
|||||||
|
|
||||||
let target_dir = params.get("path").map(|s| s.as_str()).unwrap_or("/");
|
let target_dir = params.get("path").map(|s| s.as_str()).unwrap_or("/");
|
||||||
|
|
||||||
while let Some(field) = multipart
|
while let Some(field) = multipart.next_field().await.map_err(|error| {
|
||||||
.next_field()
|
tracing::warn!(%error, "Failed to parse virtual drive file upload");
|
||||||
.await
|
AppError::from(MsdErrorCode::MsdInvalidRequest)
|
||||||
.map_err(|e| AppError::Internal(format!("Multipart error: {}", e)))?
|
})? {
|
||||||
{
|
|
||||||
let name = field.name().unwrap_or("file").to_string();
|
let name = field.name().unwrap_or("file").to_string();
|
||||||
if name == "file" {
|
if name == "file" {
|
||||||
let filename = field
|
let filename = field
|
||||||
.file_name()
|
.file_name()
|
||||||
.ok_or_else(|| AppError::BadRequest("Missing filename".to_string()))?
|
.ok_or_else(|| AppError::from(MsdErrorCode::MsdInvalidRequest))?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let file_path = if target_dir == "/" {
|
let file_path = if target_dir == "/" {
|
||||||
@@ -511,7 +546,8 @@ pub async fn msd_drive_upload(
|
|||||||
// This avoids loading the entire file into memory
|
// This avoids loading the entire file into memory
|
||||||
drive
|
drive
|
||||||
.write_file_from_multipart_field(&file_path, field)
|
.write_file_from_multipart_field(&file_path, field)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(|error| operation_failed("upload virtual drive file", error))?;
|
||||||
|
|
||||||
return Ok(Json(LoginResponse {
|
return Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -520,7 +556,7 @@ pub async fn msd_drive_upload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(AppError::BadRequest("No file provided".to_string()))
|
Err(MsdErrorCode::MsdInvalidRequest.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download file from drive (streaming for large files)
|
/// Download file from drive (streaming for large files)
|
||||||
@@ -538,7 +574,10 @@ pub async fn msd_drive_download(
|
|||||||
let drive = VentoyDrive::new(drive_path);
|
let drive = VentoyDrive::new(drive_path);
|
||||||
|
|
||||||
// Get file stream (returns file size and channel receiver)
|
// Get file stream (returns file size and channel receiver)
|
||||||
let (file_size, mut rx) = drive.read_file_stream(&file_path).await?;
|
let (file_size, mut rx) = drive
|
||||||
|
.read_file_stream(&file_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| operation_failed("download virtual drive file", error))?;
|
||||||
|
|
||||||
// Extract filename for Content-Disposition
|
// Extract filename for Content-Disposition
|
||||||
let filename = file_path.split('/').next_back().unwrap_or("download");
|
let filename = file_path.split('/').next_back().unwrap_or("download");
|
||||||
@@ -576,7 +615,10 @@ pub async fn msd_drive_file_delete(
|
|||||||
let drive_path = config.msd.drive_path();
|
let drive_path = config.msd.drive_path();
|
||||||
let drive = VentoyDrive::new(drive_path);
|
let drive = VentoyDrive::new(drive_path);
|
||||||
|
|
||||||
drive.delete(&file_path).await?;
|
drive
|
||||||
|
.delete(&file_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| operation_failed("delete virtual drive file", error))?;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -598,7 +640,10 @@ pub async fn msd_drive_mkdir(
|
|||||||
let drive_path = config.msd.drive_path();
|
let drive_path = config.msd.drive_path();
|
||||||
let drive = VentoyDrive::new(drive_path);
|
let drive = VentoyDrive::new(drive_path);
|
||||||
|
|
||||||
drive.mkdir(&dir_path).await?;
|
drive
|
||||||
|
.mkdir(&dir_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| operation_failed("create virtual drive directory", error))?;
|
||||||
|
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -618,25 +663,24 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validate_drive_init_size_rejects_below_64mb() {
|
fn validate_drive_init_size_rejects_below_64mb() {
|
||||||
let err = validate_drive_init_size(MIN_DRIVE_SIZE_MB - 1, 1024 * MIB).unwrap_err();
|
let err = validate_drive_init_size(MIN_DRIVE_SIZE_MB - 1, 1024 * MIB).unwrap_err();
|
||||||
assert!(err.to_string().contains("at least 64 MB"));
|
assert!(
|
||||||
|
matches!(err, AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_drive_init_size_rejects_available_space_overflow() {
|
fn validate_drive_init_size_rejects_available_space_overflow() {
|
||||||
let err = validate_drive_init_size(65, 64 * MIB).unwrap_err();
|
let err = validate_drive_init_size(65, 64 * MIB).unwrap_err();
|
||||||
assert!(err.to_string().contains("cannot exceed available space"));
|
assert!(
|
||||||
|
matches!(err, AppError::Msd(error) if error.code() == MsdErrorCode::MsdStorageFull)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detects_unsupported_drive_filesystem_errors() {
|
fn classifies_storage_permissions_without_exposing_the_io_error() {
|
||||||
assert!(is_unsupported_drive_filesystem(
|
let error = classify_storage_error("test", std::io::Error::from_raw_os_error(libc::EACCES));
|
||||||
"Internal error: Filesystem error: Invalid exFAT signature"
|
assert!(
|
||||||
));
|
matches!(error, AppError::Msd(error) if error.code() == MsdErrorCode::MsdStoragePermissionDenied)
|
||||||
assert!(is_unsupported_drive_filesystem(
|
);
|
||||||
"Internal error: Partition error: invalid partition table"
|
|
||||||
));
|
|
||||||
assert!(!is_unsupported_drive_filesystem(
|
|
||||||
"IO error: permission denied"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ pub struct SetupRequest {
|
|||||||
|
|
||||||
pub async fn setup_init(
|
pub async fn setup_init(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<SetupRequest>,
|
Json(mut req): Json<SetupRequest>,
|
||||||
) -> Result<Json<LoginResponse>> {
|
) -> Result<Json<LoginResponse>> {
|
||||||
// Check if already initialized
|
// Check if already initialized
|
||||||
if state.config.is_initialized() {
|
if state.config.is_initialized() {
|
||||||
@@ -65,6 +65,39 @@ pub async fn setup_init(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(path) = req.video_device.as_deref() {
|
||||||
|
let source_following = state
|
||||||
|
.stream_manager
|
||||||
|
.list_devices()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|devices| {
|
||||||
|
devices
|
||||||
|
.into_iter()
|
||||||
|
.find(|device| device.path.to_string_lossy() == path)
|
||||||
|
})
|
||||||
|
.is_some_and(|device| {
|
||||||
|
device.control_mode == crate::video::device::VideoControlMode::SourceFollowing
|
||||||
|
});
|
||||||
|
if source_following {
|
||||||
|
if req.video_format.is_some()
|
||||||
|
|| req.video_width.is_some()
|
||||||
|
|| req.video_height.is_some()
|
||||||
|
|| req.video_fps.is_some()
|
||||||
|
{
|
||||||
|
tracing::debug!(
|
||||||
|
"Ignoring setup-supplied format, resolution, and FPS for source-following video input"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
req.video_format = None;
|
||||||
|
req.video_width = None;
|
||||||
|
req.video_height = None;
|
||||||
|
req.video_fps = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_config = state.config.get();
|
||||||
|
|
||||||
// Create single system user
|
// Create single system user
|
||||||
state
|
state
|
||||||
.users
|
.users
|
||||||
@@ -140,18 +173,10 @@ pub async fn setup_init(
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Get updated config for HID reload
|
// Apply the complete USB runtime configuration, including the MSD controller.
|
||||||
let new_config = state.config.get();
|
let new_config = state.config.get();
|
||||||
|
if let Err(e) = config::apply::apply_usb_config(&state, &old_config, &new_config).await {
|
||||||
#[cfg(unix)]
|
tracing::warn!("Failed to apply USB config during setup: {}", e);
|
||||||
{
|
|
||||||
if let Err(e) = state
|
|
||||||
.otg_service
|
|
||||||
.apply_config(&new_config.hid, &new_config.msd, &new_config.otg_network)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!("Failed to apply OTG config during setup: {}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -160,25 +185,6 @@ pub async fn setup_init(
|
|||||||
new_config.rustdesk.enabled
|
new_config.rustdesk.enabled
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize HID backend with new config
|
|
||||||
let new_hid_backend = match new_config.hid.backend {
|
|
||||||
crate::config::HidBackend::Otg => crate::hid::HidBackendType::Otg,
|
|
||||||
crate::config::HidBackend::Ch9329 => crate::hid::HidBackendType::Ch9329 {
|
|
||||||
port: new_config.hid.ch9329_port.clone(),
|
|
||||||
baud_rate: new_config.hid.ch9329_baudrate,
|
|
||||||
hybrid_mouse: new_config.hid.ch9329_hybrid_mouse,
|
|
||||||
},
|
|
||||||
crate::config::HidBackend::None => crate::hid::HidBackendType::None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reload HID backend
|
|
||||||
if let Err(e) = state.hid.reload(new_hid_backend).await {
|
|
||||||
tracing::warn!("Failed to initialize HID backend during setup: {}", e);
|
|
||||||
// Don't fail setup, just warn
|
|
||||||
} else {
|
|
||||||
tracing::info!("HID backend initialized: {:?}", new_config.hid.backend);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start extensions if enabled
|
// Start extensions if enabled
|
||||||
if new_config.extensions.ttyd.enabled {
|
if new_config.extensions.ttyd.enabled {
|
||||||
if let Err(e) = state
|
if let Err(e) = state
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use crate::events::SystemEvent;
|
||||||
use crate::video::streamer::StreamerStats;
|
use crate::video::streamer::StreamerStats;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -16,7 +17,17 @@ fn stream_mode_label(mode: StreamMode, codec: crate::video::codec::VideoCodecTyp
|
|||||||
|
|
||||||
/// Get stream state
|
/// Get stream state
|
||||||
pub async fn stream_state(State(state): State<Arc<AppState>>) -> Json<StreamerStats> {
|
pub async fn stream_state(State(state): State<Arc<AppState>>) -> Json<StreamerStats> {
|
||||||
Json(state.stream_manager.stats().await)
|
let mut stats = state.stream_manager.stats().await;
|
||||||
|
if let Some(SystemEvent::StreamStateChanged {
|
||||||
|
state: event_state,
|
||||||
|
reason,
|
||||||
|
..
|
||||||
|
}) = state.events.latest_video_stream_state()
|
||||||
|
{
|
||||||
|
stats.state = event_state;
|
||||||
|
stats.reason = reason;
|
||||||
|
}
|
||||||
|
Json(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start streaming
|
/// Start streaming
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ mod error;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod static_files;
|
mod static_files;
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod uac_ws;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
pub use audio_ws::audio_ws_handler;
|
pub use audio_ws::audio_ws_handler;
|
||||||
@@ -10,4 +12,6 @@ pub use error::ErrorResponse;
|
|||||||
pub use routes::create_router;
|
pub use routes::create_router;
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
pub use static_files::StaticAssets;
|
pub use static_files::StaticAssets;
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub use uac_ws::uac_audio_ws_handler;
|
||||||
pub use ws::ws_handler;
|
pub use ws::ws_handler;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use axum::{extract::DefaultBodyLimit, routing::delete};
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
routing::{delete, put},
|
||||||
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
middleware,
|
middleware,
|
||||||
routing::{any, get, patch, post, put},
|
routing::{any, get, patch, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -13,6 +16,8 @@ use tower_http::{
|
|||||||
|
|
||||||
use super::audio_ws::audio_ws_handler;
|
use super::audio_ws::audio_ws_handler;
|
||||||
use super::handlers;
|
use super::handlers;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use super::uac_ws::uac_audio_ws_handler;
|
||||||
use super::ws::ws_handler;
|
use super::ws::ws_handler;
|
||||||
use crate::auth::auth_middleware;
|
use crate::auth::auth_middleware;
|
||||||
use crate::hid::websocket::ws_hid_handler;
|
use crate::hid::websocket::ws_hid_handler;
|
||||||
@@ -60,6 +65,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
)
|
)
|
||||||
.route("/auth/totp/disable", post(handlers::disable_totp))
|
.route("/auth/totp/disable", post(handlers::disable_totp))
|
||||||
.route("/devices", get(handlers::list_devices))
|
.route("/devices", get(handlers::list_devices))
|
||||||
|
.route("/video/input-status", get(handlers::video_input_status))
|
||||||
// WebSocket endpoint for real-time events
|
// WebSocket endpoint for real-time events
|
||||||
.route("/ws", any(ws_handler))
|
.route("/ws", any(ws_handler))
|
||||||
// Stream control endpoints
|
// Stream control endpoints
|
||||||
@@ -70,6 +76,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/stream/mode", post(handlers::stream_mode_set))
|
.route("/stream/mode", post(handlers::stream_mode_set))
|
||||||
.route("/stream/bitrate", post(handlers::stream_set_bitrate))
|
.route("/stream/bitrate", post(handlers::stream_set_bitrate))
|
||||||
.route("/stream/codecs", get(handlers::stream_codecs_list))
|
.route("/stream/codecs", get(handlers::stream_codecs_list))
|
||||||
|
.route("/video/codecs", get(handlers::stream_codecs_list))
|
||||||
.route("/stream/constraints", get(handlers::stream_constraints_get))
|
.route("/stream/constraints", get(handlers::stream_constraints_get))
|
||||||
.route(
|
.route(
|
||||||
"/video/encoder/self-check",
|
"/video/encoder/self-check",
|
||||||
@@ -262,6 +269,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
let user_routes = {
|
let user_routes = {
|
||||||
user_routes
|
user_routes
|
||||||
|
.route("/ws/uac-audio", any(uac_audio_ws_handler))
|
||||||
.route("/hid/otg/self-check", get(handlers::hid_otg_self_check))
|
.route("/hid/otg/self-check", get(handlers::hid_otg_self_check))
|
||||||
.route("/config/msd", get(handlers::config::get_msd_config))
|
.route("/config/msd", get(handlers::config::get_msd_config))
|
||||||
.route("/config/msd", patch(handlers::config::update_msd_config))
|
.route("/config/msd", patch(handlers::config::update_msd_config))
|
||||||
@@ -278,6 +286,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
"/otg/network/status",
|
"/otg/network/status",
|
||||||
get(handlers::config::get_otg_network_status),
|
get(handlers::config::get_otg_network_status),
|
||||||
)
|
)
|
||||||
|
.route("/config/uac", get(handlers::config::get_uac_config))
|
||||||
|
.route("/config/uac", patch(handlers::config::update_uac_config))
|
||||||
.route("/msd/status", get(handlers::msd_status))
|
.route("/msd/status", get(handlers::msd_status))
|
||||||
.route("/msd/images", get(handlers::msd_images_list))
|
.route("/msd/images", get(handlers::msd_images_list))
|
||||||
.route("/msd/images/download", post(handlers::msd_image_download))
|
.route("/msd/images/download", post(handlers::msd_image_download))
|
||||||
|
|||||||
135
src/web/uac_ws.rs
Normal file
135
src/web/uac_ws.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::audio::uac::{
|
||||||
|
parse_audio_packet, UacAudioPacket, UacOpusDecoder, UacPlaybackState, UacSession,
|
||||||
|
};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub async fn uac_audio_ws_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let session = {
|
||||||
|
let playback = state.uac_playback.read().await;
|
||||||
|
let Some(playback) = playback.as_ref() else {
|
||||||
|
return (StatusCode::SERVICE_UNAVAILABLE, "UAC playback is disabled").into_response();
|
||||||
|
};
|
||||||
|
match playback.acquire_session() {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(error) => {
|
||||||
|
return (StatusCode::CONFLICT, error.to_string()).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.on_upgrade(move |socket| handle_uac_audio(socket, session))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_uac_audio(mut socket: WebSocket, session: UacSession) {
|
||||||
|
let mut decoder = match UacOpusDecoder::new() {
|
||||||
|
Ok(decoder) => decoder,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Unable to initialize UAC Opus decoder: {error}");
|
||||||
|
let _ = socket.send(Message::Close(None)).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut dropped_frames = 0u64;
|
||||||
|
info!("UAC microphone WebSocket connected");
|
||||||
|
|
||||||
|
let mut playback_state = session.state();
|
||||||
|
if socket
|
||||||
|
.send(playback_state_message(playback_state))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(message) = socket.recv().await {
|
||||||
|
let message = match message {
|
||||||
|
Ok(message) => message,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("UAC microphone WebSocket failed: {error}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match message {
|
||||||
|
Message::Binary(data) => {
|
||||||
|
let packet = match parse_audio_packet(&data) {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Rejected UAC audio packet: {error}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pcm: Cow<'_, [i16]> = match packet {
|
||||||
|
UacAudioPacket::Opus(payload) => match decoder.decode(payload) {
|
||||||
|
Ok(pcm) => Cow::Borrowed(pcm),
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Rejected UAC Opus packet: {error}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
packet @ UacAudioPacket::Pcm(_) => match packet.pcm_samples() {
|
||||||
|
Ok(pcm) => Cow::Owned(pcm),
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Rejected UAC PCM packet: {error}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let (accepted, current_state) = match session.try_write(pcm.as_ref()) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("UAC playback stopped: {error}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !accepted {
|
||||||
|
dropped_frames += 1;
|
||||||
|
if dropped_frames == 1 || dropped_frames.is_multiple_of(250) {
|
||||||
|
debug!(
|
||||||
|
"Dropped {dropped_frames} UAC audio frames while the target was unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current_state != playback_state
|
||||||
|
&& socket
|
||||||
|
.send(playback_state_message(current_state))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
playback_state = current_state;
|
||||||
|
}
|
||||||
|
Message::Close(_) => break,
|
||||||
|
Message::Ping(_) | Message::Pong(_) => {}
|
||||||
|
Message::Text(_) => debug!("Ignoring text message on UAC audio WebSocket"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("UAC microphone WebSocket disconnected; dropped_frames={dropped_frames}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playback_state_message(state: UacPlaybackState) -> Message {
|
||||||
|
Message::Text(
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "uac_status",
|
||||||
|
"state": state.as_str(),
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -49,9 +49,14 @@ fn is_device_info_topic(topic: &str) -> bool {
|
|||||||
matches!(topic, "*" | "system.*" | "system.device_info")
|
matches!(topic, "*" | "system.*" | "system.device_info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_stream_state_topic(topic: &str) -> bool {
|
||||||
|
matches!(topic, "*" | "stream.*" | "stream.state_changed")
|
||||||
|
}
|
||||||
|
|
||||||
fn rebuild_event_tasks(
|
fn rebuild_event_tasks(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
topics: &[String],
|
topics: &[String],
|
||||||
|
replay_stream_state: bool,
|
||||||
event_tx: &mpsc::UnboundedSender<BusMessage>,
|
event_tx: &mpsc::UnboundedSender<BusMessage>,
|
||||||
event_tasks: &mut Vec<JoinHandle<()>>,
|
event_tasks: &mut Vec<JoinHandle<()>>,
|
||||||
) {
|
) {
|
||||||
@@ -61,7 +66,17 @@ fn rebuild_event_tasks(
|
|||||||
|
|
||||||
let topics = normalize_topics(topics);
|
let topics = normalize_topics(topics);
|
||||||
let mut device_info_task_added = false;
|
let mut device_info_task_added = false;
|
||||||
|
let mut stream_state_snapshot_added = false;
|
||||||
for topic in topics {
|
for topic in topics {
|
||||||
|
if replay_stream_state && is_stream_state_topic(&topic) && !stream_state_snapshot_added {
|
||||||
|
if let Some(snapshot) = state.events.latest_video_stream_state() {
|
||||||
|
if event_tx.send(BusMessage::Event(snapshot)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream_state_snapshot_added = true;
|
||||||
|
}
|
||||||
|
|
||||||
if is_device_info_topic(&topic) && !device_info_task_added {
|
if is_device_info_topic(&topic) && !device_info_task_added {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let mut rx = state.subscribe_device_info();
|
let mut rx = state.subscribe_device_info();
|
||||||
@@ -157,12 +172,19 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
|||||||
msg = receiver.next() => {
|
msg = receiver.next() => {
|
||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
let had_stream_state = normalize_topics(&subscribed_topics)
|
||||||
|
.iter()
|
||||||
|
.any(|topic| is_stream_state_topic(topic));
|
||||||
if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await {
|
if let Err(e) = handle_client_message(&text, &mut subscribed_topics).await {
|
||||||
warn!("Failed to handle client message: {}", e);
|
warn!("Failed to handle client message: {}", e);
|
||||||
} else {
|
} else {
|
||||||
|
let has_stream_state = normalize_topics(&subscribed_topics)
|
||||||
|
.iter()
|
||||||
|
.any(|topic| is_stream_state_topic(topic));
|
||||||
rebuild_event_tasks(
|
rebuild_event_tasks(
|
||||||
&state,
|
&state,
|
||||||
&subscribed_topics,
|
&subscribed_topics,
|
||||||
|
!had_stream_state && has_stream_state,
|
||||||
&event_tx,
|
&event_tx,
|
||||||
&mut event_tasks,
|
&mut event_tasks,
|
||||||
);
|
);
|
||||||
@@ -308,4 +330,13 @@ mod tests {
|
|||||||
assert!(is_device_info_topic("*"));
|
assert!(is_device_info_topic("*"));
|
||||||
assert!(!is_device_info_topic("stream.*"));
|
assert!(!is_device_info_topic("stream.*"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_stream_state_topic_matches_stateful_subscriptions() {
|
||||||
|
assert!(is_stream_state_topic("*"));
|
||||||
|
assert!(is_stream_state_topic("stream.*"));
|
||||||
|
assert!(is_stream_state_topic("stream.state_changed"));
|
||||||
|
assert!(!is_stream_state_topic("stream.stats_update"));
|
||||||
|
assert!(!is_stream_state_topic("system.device_info"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user