mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
Compare commits
3 Commits
v260802
...
fix/uac-na
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f34b4109b | ||
|
|
dcfa3eadaf | ||
|
|
1647e70243 |
@@ -9,9 +9,11 @@ use tracing::{info, warn};
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
const RETRY_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const PERIOD_FRAMES: Frames = 960;
|
||||
const BUFFER_FRAMES: Frames = 4_800;
|
||||
const START_THRESHOLD_PERIODS: Frames = 4;
|
||||
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)]
|
||||
@@ -58,9 +60,17 @@ struct PlaybackInner {
|
||||
}
|
||||
|
||||
enum SessionSink {
|
||||
Closed { retry_at: Option<Instant> },
|
||||
Probing { pcm: PCM, stalled: bool },
|
||||
Active { pcm: PCM, last_progress: Instant },
|
||||
Closed {
|
||||
retry_at: Option<Instant>,
|
||||
},
|
||||
Probing {
|
||||
pcm: PlaybackPcm,
|
||||
stalled: bool,
|
||||
},
|
||||
Active {
|
||||
pcm: PlaybackPcm,
|
||||
last_progress: Instant,
|
||||
},
|
||||
}
|
||||
|
||||
impl SessionSink {
|
||||
@@ -77,12 +87,14 @@ impl SessionSink {
|
||||
|
||||
struct SessionRuntime {
|
||||
sink: SessionSink,
|
||||
last_frame: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SessionRuntime {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sink: SessionSink::Closed { retry_at: None },
|
||||
last_frame: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,12 +104,22 @@ impl SessionRuntime {
|
||||
|
||||
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;
|
||||
@@ -222,8 +244,8 @@ fn drive_sink(
|
||||
return (SessionSink::Closed { retry_at }, false);
|
||||
}
|
||||
|
||||
match open_pcm(config).and_then(|pcm| {
|
||||
prime_pcm_with_silence(&pcm, config.channels as usize)?;
|
||||
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),
|
||||
@@ -246,64 +268,71 @@ fn drive_sink(
|
||||
}
|
||||
|
||||
fn drive_probe(
|
||||
pcm: PCM,
|
||||
mut pcm: PlaybackPcm,
|
||||
stalled: bool,
|
||||
config: &UacPlaybackConfig,
|
||||
samples: &[i16],
|
||||
) -> (SessionSink, bool) {
|
||||
match sink_is_consuming(&pcm) {
|
||||
match pcm.consumption_progress() {
|
||||
Ok(false) => (SessionSink::Probing { pcm, stalled }, false),
|
||||
Ok(true) => {
|
||||
if let Err(error) = reset_pcm_buffer(&pcm) {
|
||||
warn!("Failed to activate UAC playback; retrying later: {error}");
|
||||
return retry_later();
|
||||
}
|
||||
// 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) => {
|
||||
warn!("Failed to probe UAC playback; retrying later: {error}");
|
||||
retry_later()
|
||||
}
|
||||
Err(error) => recover_sink(pcm, config, error),
|
||||
}
|
||||
}
|
||||
|
||||
fn drive_active(
|
||||
pcm: PCM,
|
||||
mut pcm: PlaybackPcm,
|
||||
last_progress: Instant,
|
||||
config: &UacPlaybackConfig,
|
||||
samples: &[i16],
|
||||
) -> (SessionSink, bool) {
|
||||
match write_pcm_nonblocking(&pcm, samples, config.channels as usize) {
|
||||
Ok(WriteOutcome::Progress) => (
|
||||
SessionSink::Active {
|
||||
pcm,
|
||||
last_progress: Instant::now(),
|
||||
},
|
||||
true,
|
||||
),
|
||||
Ok(WriteOutcome::Recovered) => (
|
||||
SessionSink::Active {
|
||||
pcm,
|
||||
last_progress: Instant::now(),
|
||||
},
|
||||
false,
|
||||
),
|
||||
Ok(WriteOutcome::Blocked) if last_progress.elapsed() < SINK_STALL_TIMEOUT => {
|
||||
(SessionSink::Active { pcm, last_progress }, false)
|
||||
}
|
||||
Ok(WriteOutcome::Blocked) => {
|
||||
if let Err(error) = reset_pcm_buffer(&pcm)
|
||||
.and_then(|_| prime_pcm_with_silence(&pcm, config.channels as usize))
|
||||
{
|
||||
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");
|
||||
(SessionSink::Probing { pcm, stalled: true }, false)
|
||||
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!("UAC playback write failed; retrying later: {error}");
|
||||
warn!("Failed to recover UAC playback: {error}");
|
||||
retry_later()
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("UAC playback failed; reopening later: {error}");
|
||||
retry_later()
|
||||
}
|
||||
}
|
||||
@@ -318,7 +347,7 @@ fn retry_later() -> (SessionSink, bool) {
|
||||
)
|
||||
}
|
||||
|
||||
fn open_pcm(config: &UacPlaybackConfig) -> Result<PCM> {
|
||||
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}",
|
||||
@@ -348,10 +377,9 @@ fn open_pcm(config: &UacPlaybackConfig) -> Result<PCM> {
|
||||
let params = pcm.sw_params_current().map_err(|error| {
|
||||
AppError::AudioError(format!("Failed to read UAC SwParams: {error}"))
|
||||
})?;
|
||||
let start_threshold =
|
||||
(period_frames as Frames * START_THRESHOLD_PERIODS).min(buffer_frames as Frames);
|
||||
params
|
||||
.set_start_threshold(start_threshold)
|
||||
.set_start_threshold(buffer_frames as Frames)
|
||||
.and_then(|_| params.set_stop_threshold(buffer_frames as Frames))
|
||||
.and_then(|_| params.set_avail_min(period_frames as Frames))
|
||||
.and_then(|_| pcm.sw_params(¶ms))
|
||||
.map_err(|error| {
|
||||
@@ -365,101 +393,219 @@ fn open_pcm(config: &UacPlaybackConfig) -> Result<PCM> {
|
||||
"UAC playback opened on {} (buffer={} frames, period={} frames)",
|
||||
config.device_name, buffer_frames, period_frames
|
||||
);
|
||||
Ok(pcm)
|
||||
Ok(PlaybackPcm {
|
||||
pcm,
|
||||
buffer_frames: buffer_frames as Frames,
|
||||
period_frames: period_frames as Frames,
|
||||
submitted_frames: 0,
|
||||
consumed_frames: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WriteOutcome {
|
||||
Progress,
|
||||
Blocked,
|
||||
Recovered,
|
||||
struct PlaybackPcm {
|
||||
pcm: PCM,
|
||||
buffer_frames: Frames,
|
||||
period_frames: Frames,
|
||||
submitted_frames: u64,
|
||||
consumed_frames: u64,
|
||||
}
|
||||
|
||||
fn write_pcm_nonblocking(pcm: &PCM, samples: &[i16], channels: usize) -> Result<WriteOutcome> {
|
||||
let total_frames = samples.len() / channels;
|
||||
match pcm.avail() {
|
||||
Ok(available) if available < total_frames as Frames => return Ok(WriteOutcome::Blocked),
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
recover_pcm(pcm, error)?;
|
||||
return Ok(WriteOutcome::Recovered);
|
||||
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)
|
||||
}
|
||||
|
||||
let io = pcm
|
||||
.io_i16()
|
||||
.map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?;
|
||||
match io.writei(samples) {
|
||||
Ok(0) => Ok(WriteOutcome::Blocked),
|
||||
Ok(_) => Ok(WriteOutcome::Progress),
|
||||
Err(error) if error.errno() == libc::EAGAIN => Ok(WriteOutcome::Blocked),
|
||||
Err(error) => {
|
||||
recover_pcm(pcm, error)?;
|
||||
Ok(WriteOutcome::Recovered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a full playback buffer gains at least one period of free space, the
|
||||
/// USB host has enabled the UAC streaming interface and is consuming samples.
|
||||
fn sink_is_consuming(pcm: &PCM) -> Result<bool> {
|
||||
if pcm.state() == State::XRun {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
match pcm.avail() {
|
||||
Ok(available) => Ok(available >= PERIOD_FRAMES),
|
||||
Err(error) if error.errno() == libc::EPIPE => Ok(true),
|
||||
Err(error) => Err(AppError::AudioError(format!(
|
||||
"Failed to query UAC playback availability: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_pcm(pcm: &PCM, error: alsa::Error) -> Result<()> {
|
||||
let errno = error.errno();
|
||||
pcm.try_recover(error, true).map_err(|recover_error| {
|
||||
AppError::AudioError(format!("Failed to recover UAC playback: {recover_error}"))
|
||||
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}"))
|
||||
})?;
|
||||
if matches!(errno, libc::EPIPE | libc::ESTRPIPE) {
|
||||
warn!("Recovered UAC playback after ALSA error {errno}");
|
||||
}
|
||||
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 reset_pcm_buffer(pcm: &PCM) -> Result<()> {
|
||||
pcm.drop()
|
||||
.and_then(|_| pcm.prepare())
|
||||
.map_err(|error| AppError::AudioError(format!("Failed to reset UAC PCM: {error}")))
|
||||
fn consumed_frames(submitted: u64, buffer: Frames, available: Frames) -> u64 {
|
||||
let queued = (buffer - available.clamp(0, buffer)) as u64;
|
||||
submitted.saturating_sub(queued)
|
||||
}
|
||||
|
||||
/// Prime the non-blocking ALSA buffer with silence. Subsequent WebSocket
|
||||
/// frames inspect buffer progress to detect when the USB host starts reading.
|
||||
fn prime_pcm_with_silence(pcm: &PCM, channels: usize) -> Result<()> {
|
||||
let silence = vec![0i16; BUFFER_FRAMES as usize * channels];
|
||||
let io = pcm
|
||||
.io_i16()
|
||||
.map_err(|error| AppError::AudioError(format!("UAC PCM I/O failed: {error}")))?;
|
||||
let mut frame_offset = 0usize;
|
||||
while frame_offset < BUFFER_FRAMES as usize {
|
||||
match io.writei(&silence[frame_offset * channels..]) {
|
||||
/// 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) => frame_offset += written,
|
||||
Err(error) if error.errno() == libc::EAGAIN => break,
|
||||
Err(error) => {
|
||||
return Err(AppError::AudioError(format!(
|
||||
"Failed to prime UAC PCM with silence: {error}"
|
||||
)));
|
||||
Ok(written) => offset += written,
|
||||
Err(error) if matches!(error.errno(), libc::EAGAIN | libc::EINTR) => break,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
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();
|
||||
|
||||
@@ -3,9 +3,9 @@ use std::path::PathBuf;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::configfs::{
|
||||
configfs_path, create_dir, create_symlink, find_udc, is_configfs_available, remove_dir,
|
||||
remove_file, write_file, write_file_if_exists, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE,
|
||||
DEFAULT_USB_PRODUCT_ID, DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
|
||||
configfs_path, create_dir, find_udc, is_configfs_available, remove_dir, write_file,
|
||||
write_file_if_exists, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
|
||||
DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
|
||||
};
|
||||
use super::function::GadgetFunction;
|
||||
use super::hid::HidFunction;
|
||||
@@ -221,9 +221,7 @@ impl OtgGadgetManager {
|
||||
}
|
||||
|
||||
pub fn bind(&mut self, udc: &str) -> Result<()> {
|
||||
if let Err(e) = self.recreate_config_links() {
|
||||
warn!("Failed to recreate gadget config links before bind: {}", e);
|
||||
}
|
||||
self.recreate_config_links()?;
|
||||
|
||||
debug!("Binding gadget to UDC: {}", udc);
|
||||
write_file(&self.gadget_path.join("UDC"), &udc)?;
|
||||
@@ -385,39 +383,22 @@ impl OtgGadgetManager {
|
||||
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!(
|
||||
"Failed to read functions directory {}: {}",
|
||||
functions_path.display(),
|
||||
e
|
||||
"Failed to remove config link {}: {error}",
|
||||
dest.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
create_symlink(&src, &dest)?;
|
||||
for func in &self.functions {
|
||||
func.link(&self.config_path, &self.gadget_path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -470,6 +451,81 @@ pub async fn wait_for_hid_devices(device_paths: &[PathBuf], timeout_ms: u64) ->
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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]
|
||||
fn test_manager_creation() {
|
||||
|
||||
@@ -1,9 +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).
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::os::fd::{AsFd, AsRawFd, FromRawFd};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
@@ -97,43 +98,391 @@ impl std::fmt::Debug for DvTimingsMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic: scan `/sys/class/video4linux/v4l-subdev*` names for rk628 / hdmirx / tc358743.
|
||||
pub fn discover_subdev_for_video(video_path: &Path) -> Option<(PathBuf, CsiBridgeKind)> {
|
||||
let sysfs_base = Path::new("/sys/class/video4linux");
|
||||
let entries = std::fs::read_dir(sysfs_base).ok()?;
|
||||
const SYSFS_VIDEO4LINUX: &str = "/sys/class/video4linux";
|
||||
const DEV_ROOT: &str = "/dev";
|
||||
const MEDIA_ENT_ID_FLAG_NEXT: u32 = 1 << 31;
|
||||
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() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if !name_str.starts_with("v4l-subdev") {
|
||||
continue;
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct MediaEntityDesc {
|
||||
id: u32,
|
||||
name: [u8; 32],
|
||||
type_: u32,
|
||||
revision: u32,
|
||||
flags: u32,
|
||||
group_id: u32,
|
||||
pads: u16,
|
||||
links: u16,
|
||||
reserved: [u32; 4],
|
||||
info: MediaEntityInfo,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct MediaDeviceNode {
|
||||
major: u32,
|
||||
minor: u32,
|
||||
}
|
||||
|
||||
#[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() }
|
||||
}
|
||||
let Some(kind) = read_sysfs_name(&entry.path())
|
||||
.as_deref()
|
||||
.and_then(CsiBridgeKind::from_subdev_name)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let dev_path = PathBuf::from("/dev").join(&*name_str);
|
||||
if dev_path.exists() {
|
||||
}
|
||||
|
||||
#[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!(
|
||||
"Discovered CSI bridge subdev for {:?}: {:?} ({:?})",
|
||||
video_path, dev_path, kind
|
||||
"Discovered CSI bridge subdev for {:?}: {:?} ({:?}) via {:?}",
|
||||
video_path, path, kind, media_path
|
||||
);
|
||||
return Some((dev_path, kind));
|
||||
}
|
||||
Some((path, kind))
|
||||
}
|
||||
Ok(None) => {
|
||||
debug!(
|
||||
"No CSI bridge subdev found in /sys/class/video4linux for {:?}",
|
||||
"No connected CSI bridge subdev found in media topology for {:?}",
|
||||
video_path
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
"Failed to inspect media topology for {:?}: {}",
|
||||
video_path, error
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_sysfs_name(subdev_sysfs: &Path) -> Option<String> {
|
||||
std::fs::read_to_string(subdev_sysfs.join("name"))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
fn discover_subdev_for_video_inner(
|
||||
video_path: &Path,
|
||||
) -> io::Result<Option<(PathBuf, CsiBridgeKind, PathBuf)>> {
|
||||
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> {
|
||||
@@ -339,6 +688,53 @@ pub fn wait_source_change(subdev_fd: &File, timeout: Duration) -> io::Result<boo
|
||||
mod tests {
|
||||
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();
|
||||
@@ -348,6 +744,88 @@ mod tests {
|
||||
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]
|
||||
fn rk628_fingerprint_matches_vga() {
|
||||
let mut bt: v4l2_bt_timings = unsafe { std::mem::zeroed() };
|
||||
|
||||
@@ -125,6 +125,12 @@ pub fn resolve_video_input_config(
|
||||
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,
|
||||
@@ -135,26 +141,39 @@ pub fn resolve_video_input_config(
|
||||
} = &device.input_status
|
||||
{
|
||||
if let Ok(format) = format.parse::<PixelFormat>() {
|
||||
return ResolvedVideoInputConfig {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
ResolvedVideoInputConfig {
|
||||
format: requested_format,
|
||||
resolution: requested_resolution,
|
||||
fps: requested_fps,
|
||||
}
|
||||
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 {
|
||||
@@ -175,6 +194,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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"));
|
||||
@@ -246,6 +274,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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 [
|
||||
|
||||
Reference in New Issue
Block a user