lesavka: harden gadget audio and uvc startup
This commit is contained in:
parent
fc38925f0d
commit
50b5f54d27
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.21"
|
||||
version = "0.11.22"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.21"
|
||||
version = "0.11.22"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -17,6 +17,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn banner_includes_version() {
|
||||
assert_eq!(banner("0.11.21"), "lesavka-common CLI (v0.11.21)");
|
||||
assert_eq!(banner("0.11.22"), "lesavka-common CLI (v0.11.22)");
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.21"
|
||||
version = "0.11.22"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -151,18 +151,41 @@ fn main() -> Result<()> {
|
||||
let mut dq_err_last: Option<i32> = None;
|
||||
|
||||
loop {
|
||||
let file = open_with_retry(&dev)?;
|
||||
let file = match open_with_retry(&dev) {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
eprintln!("[lesavka-uvc] open failed: {err:#}");
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let fd = file.as_raw_fd();
|
||||
let vidioc_subscribe = ioctl_write::<V4l2EventSubscription>(b'V', 90);
|
||||
let vidioc_dqevent = ioctl_read::<V4l2Event>(b'V', 89);
|
||||
let uvc_send_response = ioctl_write::<UvcRequestData>(b'U', 1);
|
||||
|
||||
subscribe_event(fd, vidioc_subscribe, UVC_EVENT_SETUP)?;
|
||||
subscribe_event(fd, vidioc_subscribe, UVC_EVENT_DATA)?;
|
||||
let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_CONNECT);
|
||||
let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_DISCONNECT);
|
||||
let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_STREAMON);
|
||||
let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_STREAMOFF);
|
||||
if let Err(err) = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_SETUP)
|
||||
.and_then(|_| subscribe_event(fd, vidioc_subscribe, UVC_EVENT_DATA))
|
||||
{
|
||||
if should_retry_uvc_error(&err) {
|
||||
eprintln!("[lesavka-uvc] subscribe failed: {err:#}");
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
continue;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_CONNECT).map_err(|err| {
|
||||
eprintln!("[lesavka-uvc] connect-subscribe failed: {err:#}");
|
||||
});
|
||||
let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_DISCONNECT).map_err(|err| {
|
||||
eprintln!("[lesavka-uvc] disconnect-subscribe failed: {err:#}");
|
||||
});
|
||||
let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_STREAMON).map_err(|err| {
|
||||
eprintln!("[lesavka-uvc] streamon-subscribe failed: {err:#}");
|
||||
});
|
||||
let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_STREAMOFF).map_err(|err| {
|
||||
eprintln!("[lesavka-uvc] streamoff-subscribe failed: {err:#}");
|
||||
});
|
||||
|
||||
let mut state = UvcState::new(cfg);
|
||||
let mut pending: Option<PendingRequest> = None;
|
||||
@ -254,6 +277,21 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn should_retry_uvc_error(err: &anyhow::Error) -> bool {
|
||||
err.chain()
|
||||
.find_map(|cause| {
|
||||
cause
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.and_then(|error| error.raw_os_error())
|
||||
})
|
||||
.is_some_and(|code| {
|
||||
matches!(
|
||||
code,
|
||||
libc::ENOENT | libc::ENODEV | libc::EIO | libc::EBADF | libc::EAGAIN
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<(String, UvcConfig)> {
|
||||
let mut args = env::args().skip(1);
|
||||
let mut dev: Option<String> = None;
|
||||
@ -261,7 +299,8 @@ fn parse_args() -> Result<(String, UvcConfig)> {
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--device" | "-d" => dev = args.next(),
|
||||
_ => dev = Some(arg),
|
||||
_ if arg.starts_with('/') => dev = Some(arg),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -154,7 +154,9 @@ fn parse_args() -> Result<(String, UvcConfig)> {
|
||||
.or_else(|| {
|
||||
args.iter()
|
||||
.rev()
|
||||
.find(|arg| arg.as_str() != "--device" && arg.as_str() != "-d")
|
||||
.find(|arg| {
|
||||
arg.as_str() != "--device" && arg.as_str() != "-d" && arg.starts_with('/')
|
||||
})
|
||||
.cloned()
|
||||
});
|
||||
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
#[allow(clippy::useless_attribute)]
|
||||
#[forbid(unsafe_code)]
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc, time::Duration};
|
||||
@ -22,9 +22,9 @@ use lesavka_common::lesavka::{
|
||||
};
|
||||
|
||||
use lesavka_server::{
|
||||
audio, camera, camera_runtime::CameraRuntime, capture_power::CapturePowerManager,
|
||||
gadget::UsbGadget, handshake::HandshakeSvc, paste, runtime_support,
|
||||
runtime_support::init_tracing, uvc_runtime, video,
|
||||
camera, camera_runtime::CameraRuntime, capture_power::CapturePowerManager, gadget::UsbGadget,
|
||||
handshake::HandshakeSvc, paste, runtime_support, runtime_support::init_tracing, uvc_runtime,
|
||||
video,
|
||||
};
|
||||
|
||||
/*──────────────── constants ────────────────*/
|
||||
@ -152,17 +152,16 @@ impl Handler {
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter_map(|(key, hub)| {
|
||||
hub.running
|
||||
.load(Ordering::Relaxed)
|
||||
.then_some(key.source_id)
|
||||
})
|
||||
.filter_map(|(key, hub)| hub.running.load(Ordering::Relaxed).then_some(key.source_id))
|
||||
.collect::<HashSet<_>>()
|
||||
.len()
|
||||
.min(2) as u32
|
||||
}
|
||||
|
||||
async fn with_detected_capture_devices(&self, mut state: CapturePowerState) -> CapturePowerState {
|
||||
async fn with_detected_capture_devices(
|
||||
&self,
|
||||
mut state: CapturePowerState,
|
||||
) -> CapturePowerState {
|
||||
state.detected_devices = Self::detected_capture_devices_from_udev()
|
||||
.max(Self::detected_capture_devices_from_symlinks())
|
||||
.max(self.active_eye_source_count().await);
|
||||
@ -345,7 +344,9 @@ impl Handler {
|
||||
.snapshot()
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("{e:#}")))?;
|
||||
Ok(Response::new(self.with_detected_capture_devices(state).await))
|
||||
Ok(Response::new(
|
||||
self.with_detected_capture_devices(state).await,
|
||||
))
|
||||
}
|
||||
|
||||
async fn set_capture_power_reply(
|
||||
@ -362,7 +363,9 @@ impl Handler {
|
||||
CapturePowerCommand::Unspecified => self.capture_power.set_manual(req.enabled).await,
|
||||
};
|
||||
let state = result.map_err(|e| Status::internal(format!("{e:#}")))?;
|
||||
Ok(Response::new(self.with_detected_capture_devices(state).await))
|
||||
Ok(Response::new(
|
||||
self.with_detected_capture_devices(state).await,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@ -637,7 +640,7 @@ impl Relay for Handler {
|
||||
let dev = std::env::var("LESAVKA_ALSA_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into());
|
||||
info!(rpc_id, %dev, "🔊 capture_audio opened");
|
||||
|
||||
let s = audio::ear(&dev, 0)
|
||||
let s = runtime_support::open_ear_with_retry(&dev, 0)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("{e:#}")))?;
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ use anyhow::Context as _;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::{collections::BTreeSet, fs};
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::Mutex;
|
||||
@ -226,36 +227,172 @@ pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result<audio::Voice
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result<audio::Voice> {
|
||||
let attempts = std::env::var("LESAVKA_MIC_INIT_ATTEMPTS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(5)
|
||||
.max(1);
|
||||
let delay_ms = std::env::var("LESAVKA_MIC_INIT_DELAY_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(250);
|
||||
let candidates = preferred_uac_device_candidates(uac_dev);
|
||||
let (attempts, delay_ms) = audio_init_retry_policy();
|
||||
let mut last_error: Option<anyhow::Error> = None;
|
||||
|
||||
for attempt in 1..=attempts {
|
||||
match audio::Voice::new(uac_dev).await {
|
||||
Ok(voice) => {
|
||||
if attempt > 1 {
|
||||
info!(%uac_dev, attempt, "🎤 microphone sink recovered");
|
||||
for candidate in &candidates {
|
||||
match audio::Voice::new(candidate).await {
|
||||
Ok(voice) => {
|
||||
if attempt > 1 || candidate != uac_dev {
|
||||
info!(
|
||||
requested = %uac_dev,
|
||||
resolved = %candidate,
|
||||
attempt,
|
||||
"🎤 microphone sink recovered"
|
||||
);
|
||||
}
|
||||
return Ok(voice);
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
requested = %uac_dev,
|
||||
candidate = %candidate,
|
||||
attempt,
|
||||
"⚠️ microphone sink init failed: {error:#}"
|
||||
);
|
||||
last_error = Some(error);
|
||||
}
|
||||
return Ok(voice);
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%uac_dev, attempt, "⚠️ microphone sink init failed: {error:#}");
|
||||
last_error = Some(error);
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("microphone sink init failed")))
|
||||
}
|
||||
|
||||
/// Open the UAC capture source with retry logic.
|
||||
///
|
||||
/// Inputs: the preferred ALSA device string plus the logical stream id.
|
||||
/// Outputs: a ready-to-stream AAC capture pipeline.
|
||||
/// Why: the USB gadget card name is not always stable, so the server should
|
||||
/// retry both the preferred name and any discovered aliases before failing.
|
||||
#[cfg(coverage)]
|
||||
pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result<audio::AudioStream> {
|
||||
audio::ear(alsa_dev, id).await
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result<audio::AudioStream> {
|
||||
let candidates = preferred_uac_device_candidates(alsa_dev);
|
||||
let (attempts, delay_ms) = audio_init_retry_policy();
|
||||
let mut last_error: Option<anyhow::Error> = None;
|
||||
|
||||
for attempt in 1..=attempts {
|
||||
for candidate in &candidates {
|
||||
match audio::ear(candidate, id).await {
|
||||
Ok(stream) => {
|
||||
if attempt > 1 || candidate != alsa_dev {
|
||||
info!(
|
||||
requested = %alsa_dev,
|
||||
resolved = %candidate,
|
||||
attempt,
|
||||
"🔊 audio source recovered"
|
||||
);
|
||||
}
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
requested = %alsa_dev,
|
||||
candidate = %candidate,
|
||||
attempt,
|
||||
"⚠️ audio source init failed: {error:#}"
|
||||
);
|
||||
last_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("audio source init failed")))
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn audio_init_retry_policy() -> (u32, u64) {
|
||||
let attempts = std::env::var("LESAVKA_AUDIO_INIT_ATTEMPTS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.or_else(|| {
|
||||
std::env::var("LESAVKA_MIC_INIT_ATTEMPTS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
})
|
||||
.unwrap_or(20)
|
||||
.max(1);
|
||||
let delay_ms = std::env::var("LESAVKA_AUDIO_INIT_DELAY_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.or_else(|| {
|
||||
std::env::var("LESAVKA_MIC_INIT_DELAY_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
})
|
||||
.unwrap_or(250);
|
||||
(attempts, delay_ms)
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let auto_family = [
|
||||
"hw:UAC2Gadget,0",
|
||||
"hw:UAC2_Gadget,0",
|
||||
"hw:Composite,0",
|
||||
"hw:Lesavka,0",
|
||||
];
|
||||
let allow_aliases = auto_family.contains(&preferred);
|
||||
push_audio_candidate(&mut out, &mut seen, preferred);
|
||||
if allow_aliases {
|
||||
for alias in auto_family {
|
||||
push_audio_candidate(&mut out, &mut seen, alias);
|
||||
}
|
||||
for detected in detect_uac_card_candidates() {
|
||||
push_audio_candidate(&mut out, &mut seen, &detected);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn push_audio_candidate(out: &mut Vec<String>, seen: &mut BTreeSet<String>, candidate: &str) {
|
||||
let trimmed = candidate.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
if seen.insert(trimmed.to_string()) {
|
||||
out.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn detect_uac_card_candidates() -> Vec<String> {
|
||||
let Ok(cards) = fs::read_to_string("/proc/asound/cards") else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
cards
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
if !(lower.contains("uac2")
|
||||
|| lower.contains("gadget")
|
||||
|| lower.contains("composite")
|
||||
|| lower.contains("lesavka"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let start = line.find('[')?;
|
||||
let end = line[start + 1..].find(']')?;
|
||||
let card_id = line[start + 1..start + 1 + end].trim();
|
||||
(!card_id.is_empty()).then(|| format!("hw:{card_id},0"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Allocate a stream identifier for logging and correlation.
|
||||
///
|
||||
/// Inputs: none.
|
||||
@ -321,8 +458,8 @@ pub async fn write_hid_report(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
allow_gadget_cycle, next_stream_id, open_with_retry, should_recover_hid_error,
|
||||
write_hid_report,
|
||||
allow_gadget_cycle, detect_uac_card_candidates, next_stream_id, open_with_retry,
|
||||
preferred_uac_device_candidates, should_recover_hid_error, write_hid_report,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::sync::Arc;
|
||||
@ -358,6 +495,26 @@ mod tests {
|
||||
assert!(second > first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_uac_device_candidates_keeps_custom_override_only() {
|
||||
let candidates = preferred_uac_device_candidates("hw:7,0");
|
||||
assert_eq!(candidates, vec!["hw:7,0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_uac_device_candidates_expands_known_aliases() {
|
||||
let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0");
|
||||
assert!(candidates.iter().any(|value| value == "hw:UAC2Gadget,0"));
|
||||
assert!(candidates.iter().any(|value| value == "hw:UAC2_Gadget,0"));
|
||||
assert!(candidates.iter().any(|value| value == "hw:Composite,0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_uac_card_candidates_returns_hw_names_only() {
|
||||
let live = detect_uac_card_candidates();
|
||||
assert!(live.iter().all(|value| value.starts_with("hw:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn open_with_retry_opens_existing_file() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user