Compare commits

..

32 Commits

Author SHA1 Message Date
SilentWind
572bf859ba Merge pull request #309 from mofeng-git/fix/uac-native-alsa
fix: 修正 UAC 链接顺序及原生 ALSA 启动恢复
2026-09-11 08:24:02 +08:00
mofeng-git
9f34b4109b fix: 修正 UAC 链接顺序及原生 ALSA 启动恢复 2026-09-10 22:43:36 +08:00
Carbon
dcfa3eadaf fix: 为 RKCIF/RK628 选择受支持的视频格式 (#292)
RK628 通过 RKCIF 接入时,如果 One-KVM 启动时 HDMI 信号尚未锁定,
SourceFollowing 路径可能回退到默认 MJPEG。由于 RKCIF 不支持 MJPEG,
后续 S_FMT 无法建立有效的采集链路。

解决方案:
在解析 SourceFollowing 配置后,根据设备的 VIDIOC_ENUM_FMT 枚举结果
校验最终格式。如果该格式不受支持且格式列表非空,则选择按优先级排序
的首个可用格式,对于 RKCIF/RK628 通常为 NV12。

该方法只替换 FourCC,保留 HDMI DV timings 提供的分辨率和帧率。
同时覆盖无信号启动和当前格式已失效两种场景。
2026-08-26 16:37:57 +08:00
Carbon
1647e70243 fix: RK3588 原生 HDMIRX 被错误地关联到了 RK628 subdev (#293)
* fix: RK3588 原生 HDMIRX 被错误地关联到了 RK628 subdev

当 RK628 和 RK3588 原生 HDMI IN 同时存在时,页面配置为 rk_hdmirx,
会无法使用,提示无信号。

是因为原来的 subdev 发现逻辑会全局扫描 v4l-subdev,并返回首个匹配节点。
原生 HDMI IN 可能错误使用 RK628 subdev 的信号状态,最终被判断为无信号。

解决方案:
根据 video 节点的设备号找到对应 media device,读取 Media Controller
中的 entity 和已启用链路,只返回与当前 video 节点实际连通的 HDMI
bridge subdev。

* fix: 精确匹配视频设备的 Media 拓扑

---------

Co-authored-by: mofeng-git <mofeng654321@hotmail.com>
2026-08-26 16:32:03 +08:00
SilentWind
a4073d64cb Merge pull request #287 from mofeng-git/dev
Dev
2026-08-02 16:38:35 +08:00
mofeng-git
16400df182 chore: bump version to v0.2.6 2026-08-02 16:37:47 +08:00
mofeng-git
47af17bebc feat: 新增 Amlogic AMLENC 硬件编码支持
- 新增动态加载及 ABI 校验的 AMLENC H.264/H.265 编码器
- 注册 Amlogic 后端并扩展编码器自检
- 并行执行 MJPEG 解码与 AMLENC 编码
- 复用 NV12 缓冲区并移除冗余帧初始化
- 优化低延迟帧分发及 RTCP 关键帧恢复
- 动态调整 AMLENC 码率尚未完成,效果不佳
2026-08-02 16:24:10 +08:00
mofeng-git
814f23a27c ci: 优化 Cargo 构建缓存
- 共享 deb 与 Docker 的 Linux cross 编译缓存
- 为 Windows MSVC 构建添加独立缓存
- 固定 cross 版本以保证缓存一致性
2026-07-31 09:55:14 +08:00
mofeng-git
6bbe137f09 fix: 修复 Windows 音频设备函数可见性 2026-07-31 09:46:32 +08:00
mofeng-git
6fdcf5c7c9 fix: 修复无信号状态同步与采集管线阻塞
- 使用非阻塞 V4L2 句柄并类型化采集错误
- 完善视频管线生命周期,避免复用停止中的管线
- 区分音视频事件并支持 REST/WebSocket 状态快照
- 修复信号丢失后保留最后一帧及刷新后黑屏问题
- 优化无信号提示、状态展示优先级及相关界面细节
- 降低重复 OTG 错误日志级别
2026-07-30 22:43:59 +08:00
mofeng-git
ce1712ff2e feat: 为 EasyTier 添加完整 TOML 配置支持
- 新增快速配置与完整配置模式,兼容旧版配置
 - 统一扩展配置校验及受保护临时文件生命周期
- 支持通过 easytier-core -c 加载 TOML 配置
- 复用前端配置模式编辑组件并更新中英文文案与类型
- 补充配置校验、启动参数、文件权限及清理测试
2026-07-30 21:26:00 +08:00
mofeng-git
8b09824e04 fix: 修正 RustDesk UDP 注册状态判定
- 移除无法证明服务器可达的 connected 状态
- 引入注册跟踪器统一管理响应、超时与退避
- 连续注册超时后正确降级为未连接
 - 分离本地服务状态与 HBBS 注册状态
- 补充注册状态机单元测试
2026-07-30 19:43:28 +08:00
mofeng-git
9fb23476ac fix: 完善 RK3588 HDMI RX 信号检测与自动恢复
- 统一使用 QUERY_DV_TIMINGS 判断 HDMI RX 输入状态
  - 增加 source-following 设备状态 API 与前端只读展示
  - 支持长时间无信号后的 MJPEG/WebRTC 自动恢复
  - 修复模式切换时采集设备尚未释放导致的 EBUSY
  - 取消陈旧 WebRTC 重连并统一采集恢复策略
  - 移除视频输入状态区域的冗余标题
2026-07-30 19:41:44 +08:00
mofeng-git
971c263bf8 feat: 虚拟媒体显示镜像上传进度百分比 2026-07-29 23:25:12 +08:00
mofeng-git
cb3fd3e646 feat: 添加视频窗口等比例缩放按钮 2026-07-29 19:32:46 +08:00
mofeng-git
d414557d74 fix: 修复设置页页眉回弹和状态色辨识度 #281
- 在侧栏布局滚动容器统一阻止纵向 overscroll
  - 拆分状态指示色与成功文本色,提升在线状态辨识度
  - 统一连接、运行及键盘 LED 等状态点样式
2026-07-29 18:50:20 +08:00
mofeng-git
fbcd95b617 fix: 升级目录改为使用 /tmp 目录,避免遗留升级文件 #280 2026-07-29 17:59:28 +08:00
mofeng-git
887f29096f feat: 增加 OTG HID 远程唤醒和 OTG MSD 设备名称配置
- 在补丁内核上启用 HID 写入唤醒及 USB 远程唤醒描述符
  - 支持全局配置 Flash 和 CD-ROM INQUIRY 字符串
  - 兼容普通内核的通用 INQUIRY 属性
  - 调整 OTG 功能布局并更新大容量 DVD 镜像提示
2026-07-29 13:56:52 +08:00
mofeng-git
e0bddc2faa fix: 修复设备识别问题 #286 2026-07-27 15:12:54 +08:00
mofeng-git
4f770a1eea fix: 完善 MSD 错误提示;解决 MSD 文件名称过长问题;优化粘贴文本样式;优化所有下拉框样式;删除“画面静止时会自动降帧”提示 2026-07-26 23:13:47 +08:00
mofeng-git
376dc97134 fix: 修复 MSD ISO/FLASH 挂载识别错误;完善错误提示 2026-07-26 22:59:27 +08:00
mofeng-git
27c8da9a75 fix: 完善 RK3588 HDMI RX 兼容性 2026-07-26 22:27:47 +08:00
mofeng-git
5963dfa01a feat: 完善 OTG UAC 音频支持 2026-07-26 11:59:58 +08:00
mofeng-git
f86cba6ce5 Merge branch 'main' into dev
# Conflicts:
#	src/web/handlers/setup.rs
#	web/src/i18n/en-US.ts
#	web/src/i18n/zh-CN.ts
2026-07-24 11:09:36 +08:00
mofeng-git
e1b82519b9 test: 更新报告格式 2026-07-24 11:03:22 +08:00
mofeng-git
32d8a62fec fix: 初始化时完整应用 USB 运行时配置,避免遗漏 MSD 功能 2026-07-24 11:02:14 +08:00
mofeng-git
4cd622ff07 fix: OTG 网卡桥接改由 NetworkManager 生成桥接配置 UUID;docker 补全 NetworkManager 依赖 2026-07-24 11:01:54 +08:00
SilentWind
6ec4361f43 Merge pull request #282 from arounyf/fix/hid-reload-on-gadget-rebuild
fix: 修复OTG gadget重建后HID后端未重载导致键鼠失效
2026-07-23 15:25:37 +08:00
arounyf
e9bed3688f feat: UAC USB microphone passthrough
- Add UAC1 gadget function (ConfigFS) with optimized endpoint config
- Browser mic capture with Opus encoding via WebCodecs AudioEncoder
- WebSocket audio transport (Opus 64kbps, 100x bandwidth reduction vs raw PCM)
- aplay subprocess for reliable PCM playback to USB gadget
- Settings toggle + ActionBar mic button (hidden when UAC disabled)
- Dynamic PCM device resolution (/proc/asound)
- c_chmask=0 + req_number=4 fixes DWC3 composite isochronous endpoint issue
2026-07-23 06:56:53 +00:00
mofeng-git
8d5366444b fix: 完善测试套件 2026-07-22 22:53:21 +08:00
arounyf
4e32b05124 fix: 修复OTG gadget重建后HID后端未重载导致键鼠失效
当MSD或OTG网络配置变更导致gadget被重建时,新的/dev/hidg*设备
被创建但HID后端因'配置未变'跳过了reload,导致仍持有已删除设备
的旧文件描述符,键鼠操作无效。

修复: 在apply_usb_config中检测gadget是否因MSD/网络变更被重建,
若是且HID后端为OTG,强制reload HID以打开新的/dev/hidg*设备。

同时为MsdConfig添加PartialEq derive以支持变更检测。
2026-07-22 04:54:18 +00:00
mofeng-git
e0055bf491 fix: 删除 nmcli 命令缺失弹窗提示 2026-07-20 21:24:27 +08:00
143 changed files with 12355 additions and 7505 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 \
&& \ && \

View File

@@ -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 \
&& \ && \

View File

@@ -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,

View File

@@ -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(())
}
}

View File

@@ -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(&params))
.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)
} }

View File

@@ -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) {

View File

@@ -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(())

View File

@@ -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;

View File

@@ -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());
} }

View File

@@ -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(

View File

@@ -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
} }

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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"
); );
} }
}

View File

@@ -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
View 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
View 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
View 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(&params))
.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(&params))
.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
View 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]
);
}
}

View File

@@ -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"
) )
} }

View File

@@ -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();
} }

View File

@@ -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
View 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());
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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")
));
}
} }

View File

@@ -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 {

View File

@@ -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
));
}
}

View File

@@ -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,
};

View 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());
}
}

View File

@@ -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");
}
}

View 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());
}
}

View File

@@ -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 {

View File

@@ -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!(

View File

@@ -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]

View File

@@ -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));
}
}
} }

View File

@@ -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};

View File

@@ -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)]

View File

@@ -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(|| {

View File

@@ -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]

View File

@@ -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)))?;

View File

@@ -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(),

View File

@@ -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() {

View File

@@ -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> {

View File

@@ -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(&current_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();

View File

@@ -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
View 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
}

View File

@@ -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

View File

@@ -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(&not_inserted).is_err()); assert!(validate_insert_request(&not_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");
}
} }

View File

@@ -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 {

View File

@@ -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;
} }
} }
} }

View File

@@ -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() {

View File

@@ -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)),
}) })
} }

View File

@@ -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
);
} }
} }

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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)]

View File

@@ -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 }) => {

View File

@@ -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
View 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"));
}
}

View File

@@ -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);
}
} }

View File

@@ -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,
} }
} }

View File

@@ -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,
} }
} }

View File

@@ -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};

View File

@@ -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)
);
}
} }

View File

@@ -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)),

View File

@@ -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
));
}
} }

View File

@@ -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,
} }
} }

View File

@@ -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(

View File

@@ -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)]

View File

@@ -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,
} }

View File

@@ -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;

View File

@@ -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

View File

@@ -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
View 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));
}
}

View File

@@ -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");

View File

@@ -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

View File

@@ -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<()>;

View File

@@ -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,
}; };

View File

@@ -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);
}
} }

View File

@@ -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

View File

@@ -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,

View File

@@ -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();
}
} }
} }

View 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))
}

View File

@@ -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

View File

@@ -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")?;

View File

@@ -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

View File

@@ -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());
}
}

View File

@@ -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};

View File

@@ -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"
));
} }
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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
View 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(),
)
}

View File

@@ -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