fix: honor live UVC gadget profile

This commit is contained in:
Brad Stein 2026-05-03 05:37:16 -03:00
parent f1e3faa404
commit 1673bb8b1b
9 changed files with 177 additions and 20 deletions

View File

@ -1,6 +1,6 @@
# Lesavka Agent Notes # Lesavka Agent Notes
## 0.18.1 Bundled Webcam A/V Migration Checklist ## 0.18.2 Bundled Webcam A/V Migration Checklist
Context: manual Google Meet and mirrored-probe testing showed the split webcam Context: manual Google Meet and mirrored-probe testing showed the split webcam
and microphone uplink design is too fragile under real browser/device pressure. and microphone uplink design is too fragile under real browser/device pressure.
@ -28,6 +28,9 @@ explicit no-camera path.
- [x] Startup video is allowed to prime UVC if a first mixed bundle has bad - [x] Startup video is allowed to prime UVC if a first mixed bundle has bad
audio/video timing; the mismatched audio is dropped, preserving sync while audio/video timing; the mismatched audio is dropped, preserving sync while
avoiding browser `Camera is starting` starvation. avoiding browser `Camera is starting` starvation.
- [x] An already-attached UVC gadget descriptor is the physical browser contract:
if it still advertises an older profile, server handshake/capture sizing
follows that live descriptor until a controlled gadget rebuild is allowed.
### Wire Protocol ### Wire Protocol
- [x] Add `UpstreamMediaBundle` containing one optional video frame plus zero or - [x] Add `UpstreamMediaBundle` containing one optional video frame plus zero or
@ -66,6 +69,10 @@ explicit no-camera path.
- [x] Activate the camera relay before opening the microphone sink so UVC can - [x] Activate the camera relay before opening the microphone sink so UVC can
become ready even if UAC setup is slow. become ready even if UAC setup is slow.
- [x] Log the first bundled video frame handed to the camera sink. - [x] Log the first bundled video frame handed to the camera sink.
- [x] Honor the live configfs UVC descriptor when it differs from configured
defaults, preventing browsers from receiving frames outside negotiated caps.
- [x] Make the UVC control helper answer probe/commit requests from the same
live descriptor so Firefox/Chrome negotiation matches server frame output.
- [x] Continue reporting client timing and sink handoff diagnostics from bundled packets. - [x] Continue reporting client timing and sink handoff diagnostics from bundled packets.
- [ ] Add bundled-mode counters for first bundle, first audio push, first video feed, - [ ] Add bundled-mode counters for first bundle, first audio push, first video feed,
dropped stale bundles, and bundle queue age. dropped stale bundles, and bundle queue age.
@ -75,6 +82,7 @@ explicit no-camera path.
### Validation ### Validation
- [x] `cargo check -p lesavka_common -p lesavka_client -p lesavka_server --bins` - [x] `cargo check -p lesavka_common -p lesavka_client -p lesavka_server --bins`
- [x] Focused handshake and launcher tests. - [x] Focused handshake and launcher tests.
- [x] Focused UVC profile test for stale configured profile vs live attached descriptor.
- [ ] Focused server upstream-media tests including bundled stream acceptance. - [ ] Focused server upstream-media tests including bundled stream acceptance.
- [ ] Install on both ends and verify diagnostics show bundled webcam media. - [ ] Install on both ends and verify diagnostics show bundled webcam media.
- [ ] Manual Google Meet test: camera starts, video is not black/unsupported, - [ ] Manual Google Meet test: camera starts, video is not black/unsupported,

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.18.1" version = "0.18.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.18.1" version = "0.18.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.18.1" version = "0.18.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.18.1" version = "0.18.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -247,6 +247,9 @@ fn print_versions(server_addr: &str, caps: &HandshakeSet) {
println!("server_revision={server_revision}"); println!("server_revision={server_revision}");
println!("server_camera_output={}", caps.camera_output); println!("server_camera_output={}", caps.camera_output);
println!("server_camera_codec={}", caps.camera_codec); println!("server_camera_codec={}", caps.camera_codec);
println!("server_camera_width={}", caps.camera_width);
println!("server_camera_height={}", caps.camera_height);
println!("server_camera_fps={}", caps.camera_fps);
println!("server_bundled_webcam_media={}", caps.bundled_webcam_media); println!("server_bundled_webcam_media={}", caps.bundled_webcam_media);
} }

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.18.1" version = "0.18.2"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.18.1" version = "0.18.2"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -668,10 +668,44 @@ fn parse_args() -> Result<(String, UvcConfig)> {
impl UvcConfig { impl UvcConfig {
fn from_env() -> Self { fn from_env() -> Self {
let width = env_u32("LESAVKA_UVC_WIDTH", 1280); let mut width = env_u32("LESAVKA_UVC_WIDTH", 1280);
let height = env_u32("LESAVKA_UVC_HEIGHT", 720); let mut height = env_u32("LESAVKA_UVC_HEIGHT", 720);
let fps = env_u32("LESAVKA_UVC_FPS", 30).max(1); let mut fps = env_u32("LESAVKA_UVC_FPS", 30).max(1);
let interval = env_u32("LESAVKA_UVC_INTERVAL", 0); let requested_interval = env_u32("LESAVKA_UVC_INTERVAL", 0);
let mut interval = if requested_interval == 0 {
10_000_000 / fps
} else {
requested_interval
};
if let Some(snapshot) = read_configfs_snapshot() {
let live_interval = if snapshot.default_interval == 0 {
snapshot.frame_interval
} else {
snapshot.default_interval
};
if live_interval > 0 {
let live_fps = (10_000_000 / live_interval).max(1);
if (width, height, fps, interval)
!= (snapshot.width, snapshot.height, live_fps, live_interval)
{
eprintln!(
"[lesavka-uvc] live descriptor differs from configured profile; honoring attached gadget {}x{}@{}fps interval={} until rebuild (configured {}x{}@{}fps interval={})",
snapshot.width,
snapshot.height,
live_fps,
live_interval,
width,
height,
fps,
interval
);
}
width = snapshot.width;
height = snapshot.height;
fps = live_fps;
interval = live_interval;
}
}
let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024); let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024);
let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2); let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2);
let bulk = env::var("LESAVKA_UVC_BULK").is_ok(); let bulk = env::var("LESAVKA_UVC_BULK").is_ok();
@ -725,12 +759,6 @@ impl UvcConfig {
max_packet = max_packet.min(1024); max_packet = max_packet.min(1024);
} }
let interval = if interval == 0 {
10_000_000 / fps
} else {
interval
};
Self { Self {
width, width,
height, height,

View File

@ -2,8 +2,24 @@ use super::{CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode, LA
use gstreamer as gst; use gstreamer as gst;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::{Path, PathBuf};
use tracing::{info, warn}; use tracing::{info, warn};
#[cfg(not(coverage))]
const DEFAULT_UVC_CONFIGFS_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0";
#[cfg(not(coverage))]
const UVC_CONFIGFS_BASE_ENV: &str = "LESAVKA_UVC_CONFIGFS_BASE";
#[cfg(not(coverage))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LiveUvcProfile {
width: u32,
height: u32,
fps: u32,
interval_100ns: u32,
}
#[cfg(coverage)] #[cfg(coverage)]
pub(super) fn select_camera_config() -> CameraConfig { pub(super) fn select_camera_config() -> CameraConfig {
let output_override = std::env::var("LESAVKA_CAM_OUTPUT") let output_override = std::env::var("LESAVKA_CAM_OUTPUT")
@ -172,13 +188,13 @@ fn select_uvc_config() -> CameraConfig {
uvc_env = parse_env_file(&text); uvc_env = parse_env_file(&text);
} }
let width = read_u32_from_env("LESAVKA_UVC_WIDTH") let mut width = read_u32_from_env("LESAVKA_UVC_WIDTH")
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_WIDTH")) .or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_WIDTH"))
.unwrap_or(1280); .unwrap_or(1280);
let height = read_u32_from_env("LESAVKA_UVC_HEIGHT") let mut height = read_u32_from_env("LESAVKA_UVC_HEIGHT")
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_HEIGHT")) .or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_HEIGHT"))
.unwrap_or(720); .unwrap_or(720);
let fps = read_u32_from_env("LESAVKA_UVC_FPS") let mut fps = read_u32_from_env("LESAVKA_UVC_FPS")
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_FPS")) .or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_FPS"))
.or_else(|| { .or_else(|| {
read_u32_from_env("LESAVKA_UVC_INTERVAL") read_u32_from_env("LESAVKA_UVC_INTERVAL")
@ -194,6 +210,24 @@ fn select_uvc_config() -> CameraConfig {
.unwrap_or(30); .unwrap_or(30);
let codec = select_uvc_codec(Some(&uvc_env)); let codec = select_uvc_codec(Some(&uvc_env));
if let Some(live) = read_live_uvc_configfs_profile() {
if (width, height, fps) != (live.width, live.height, live.fps) {
warn!(
configured_width = width,
configured_height = height,
configured_fps = fps,
live_width = live.width,
live_height = live.height,
live_fps = live.fps,
live_interval_100ns = live.interval_100ns,
"📷 live UVC descriptor differs from configured profile; honoring attached gadget profile until rebuild"
);
}
width = live.width;
height = live.height;
fps = live.fps.max(1);
}
CameraConfig { CameraConfig {
output: CameraOutput::Uvc, output: CameraOutput::Uvc,
codec, codec,
@ -204,6 +238,59 @@ fn select_uvc_config() -> CameraConfig {
} }
} }
#[cfg(not(coverage))]
fn read_live_uvc_configfs_profile() -> Option<LiveUvcProfile> {
let base = std::env::var(UVC_CONFIGFS_BASE_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_UVC_CONFIGFS_BASE));
let frame_dir = live_uvc_frame_dir(&base)?;
let width = read_u32_file(frame_dir.join("wWidth"))?;
let height = read_u32_file(frame_dir.join("wHeight"))?;
let interval_100ns = read_u32_file(frame_dir.join("dwDefaultFrameInterval"))
.or_else(|| read_first_u32_line(frame_dir.join("dwFrameInterval")))?;
if width == 0 || height == 0 || interval_100ns == 0 {
return None;
}
Some(LiveUvcProfile {
width,
height,
fps: (10_000_000 / interval_100ns).max(1),
interval_100ns,
})
}
#[cfg(not(coverage))]
fn live_uvc_frame_dir(base: &Path) -> Option<PathBuf> {
let preferred = base.join("streaming/mjpeg/m/720p");
if preferred.join("wWidth").is_file() && preferred.join("wHeight").is_file() {
return Some(preferred);
}
let mjpeg_dir = base.join("streaming/mjpeg/m");
let mut candidates = Vec::new();
for entry in fs::read_dir(mjpeg_dir).ok()?.flatten() {
let path = entry.path();
if path.join("wWidth").is_file() && path.join("wHeight").is_file() {
candidates.push(path);
}
}
candidates.sort();
candidates.into_iter().next()
}
#[cfg(not(coverage))]
fn read_u32_file(path: impl AsRef<Path>) -> Option<u32> {
fs::read_to_string(path).ok()?.trim().parse::<u32>().ok()
}
#[cfg(not(coverage))]
fn read_first_u32_line(path: impl AsRef<Path>) -> Option<u32> {
fs::read_to_string(path)
.ok()?
.lines()
.find_map(|line| line.trim().parse::<u32>().ok())
}
#[cfg(coverage)] #[cfg(coverage)]
fn has_hw_h264_decode() -> bool { fn has_hw_h264_decode() -> bool {
std::env::var("LESAVKA_HW_H264").is_ok() std::env::var("LESAVKA_HW_H264").is_ok()

View File

@ -4,6 +4,7 @@ use super::{
update_camera_config, update_camera_config,
}; };
use serial_test::serial; use serial_test::serial;
use std::fs;
use temp_env::with_var; use temp_env::with_var;
#[test] #[test]
@ -32,6 +33,36 @@ fn camera_config_env_override_prefers_uvc_values() {
}); });
} }
#[test]
#[cfg(not(coverage))]
#[serial]
fn uvc_camera_profile_honors_live_attached_descriptor() {
let temp = tempfile::tempdir().expect("temp configfs dir");
let frame_dir = temp.path().join("streaming/mjpeg/m/720p");
fs::create_dir_all(&frame_dir).expect("mock UVC frame descriptor");
fs::write(frame_dir.join("wWidth"), "640\n").expect("width");
fs::write(frame_dir.join("wHeight"), "480\n").expect("height");
fs::write(frame_dir.join("dwDefaultFrameInterval"), "500000\n").expect("interval");
let configfs_base = temp.path().to_string_lossy().into_owned();
temp_env::with_vars(
[
("LESAVKA_CAM_OUTPUT", Some("uvc")),
("LESAVKA_UVC_WIDTH", Some("1280")),
("LESAVKA_UVC_HEIGHT", Some("720")),
("LESAVKA_UVC_FPS", Some("30")),
("LESAVKA_UVC_INTERVAL", None),
("LESAVKA_UVC_CONFIGFS_BASE", Some(configfs_base.as_str())),
],
|| {
let cfg = update_camera_config();
assert_eq!(cfg.output, CameraOutput::Uvc);
assert_eq!(cfg.codec, CameraCodec::Mjpeg);
assert_eq!((cfg.width, cfg.height, cfg.fps), (640, 480, 20));
},
);
}
#[test] #[test]
#[serial] #[serial]
fn camera_config_env_override_honors_uvc_codec() { fn camera_config_env_override_honors_uvc_codec() {