From 1673bb8b1b3df8637ed0dd502a12003bcfe2076f Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 3 May 2026 05:37:16 -0300 Subject: [PATCH] fix: honor live UVC gadget profile --- AGENTS.md | 10 +++- Cargo.lock | 6 +- client/Cargo.toml | 2 +- client/src/bin/lesavka-relayctl.rs | 3 + common/Cargo.toml | 2 +- server/Cargo.toml | 2 +- server/src/bin/lesavka-uvc.real.inc | 48 +++++++++++---- server/src/camera/selection.rs | 93 ++++++++++++++++++++++++++++- server/src/tests/camera.rs | 31 ++++++++++ 9 files changed, 177 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1f6892f..fd1191e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # 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 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 audio/video timing; the mismatched audio is dropped, preserving sync while 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 - [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 become ready even if UAC setup is slow. - [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. - [ ] Add bundled-mode counters for first bundle, first audio push, first video feed, dropped stale bundles, and bundle queue age. @@ -75,6 +82,7 @@ explicit no-camera path. ### Validation - [x] `cargo check -p lesavka_common -p lesavka_client -p lesavka_server --bins` - [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. - [ ] Install on both ends and verify diagnostics show bundled webcam media. - [ ] Manual Google Meet test: camera starts, video is not black/unsupported, diff --git a/Cargo.lock b/Cargo.lock index d95369d..33176af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.18.1" +version = "0.18.2" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.18.1" +version = "0.18.2" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.18.1" +version = "0.18.2" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 371e87a..4cc85a0 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.18.1" +version = "0.18.2" edition = "2024" [dependencies] diff --git a/client/src/bin/lesavka-relayctl.rs b/client/src/bin/lesavka-relayctl.rs index 298a2e1..cf64676 100644 --- a/client/src/bin/lesavka-relayctl.rs +++ b/client/src/bin/lesavka-relayctl.rs @@ -247,6 +247,9 @@ fn print_versions(server_addr: &str, caps: &HandshakeSet) { println!("server_revision={server_revision}"); println!("server_camera_output={}", caps.camera_output); 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); } diff --git a/common/Cargo.toml b/common/Cargo.toml index 7dcdace..6024b64 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.18.1" +version = "0.18.2" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index f39050c..351c7d1 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.18.1" +version = "0.18.2" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index 2fe31e5..1daa246 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -668,10 +668,44 @@ fn parse_args() -> Result<(String, UvcConfig)> { impl UvcConfig { fn from_env() -> Self { - let width = env_u32("LESAVKA_UVC_WIDTH", 1280); - let height = env_u32("LESAVKA_UVC_HEIGHT", 720); - let fps = env_u32("LESAVKA_UVC_FPS", 30).max(1); - let interval = env_u32("LESAVKA_UVC_INTERVAL", 0); + let mut width = env_u32("LESAVKA_UVC_WIDTH", 1280); + let mut height = env_u32("LESAVKA_UVC_HEIGHT", 720); + let mut fps = env_u32("LESAVKA_UVC_FPS", 30).max(1); + 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 frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2); let bulk = env::var("LESAVKA_UVC_BULK").is_ok(); @@ -725,12 +759,6 @@ impl UvcConfig { max_packet = max_packet.min(1024); } - let interval = if interval == 0 { - 10_000_000 / fps - } else { - interval - }; - Self { width, height, diff --git a/server/src/camera/selection.rs b/server/src/camera/selection.rs index b92cbbb..0174a3b 100644 --- a/server/src/camera/selection.rs +++ b/server/src/camera/selection.rs @@ -2,8 +2,24 @@ use super::{CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode, LA use gstreamer as gst; use std::collections::HashMap; use std::fs; +use std::path::{Path, PathBuf}; 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)] pub(super) fn select_camera_config() -> CameraConfig { let output_override = std::env::var("LESAVKA_CAM_OUTPUT") @@ -172,13 +188,13 @@ fn select_uvc_config() -> CameraConfig { 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")) .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")) .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_env("LESAVKA_UVC_INTERVAL") @@ -194,6 +210,24 @@ fn select_uvc_config() -> CameraConfig { .unwrap_or(30); 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 { output: CameraOutput::Uvc, codec, @@ -204,6 +238,59 @@ fn select_uvc_config() -> CameraConfig { } } +#[cfg(not(coverage))] +fn read_live_uvc_configfs_profile() -> Option { + 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 { + 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) -> Option { + fs::read_to_string(path).ok()?.trim().parse::().ok() +} + +#[cfg(not(coverage))] +fn read_first_u32_line(path: impl AsRef) -> Option { + fs::read_to_string(path) + .ok()? + .lines() + .find_map(|line| line.trim().parse::().ok()) +} + #[cfg(coverage)] fn has_hw_h264_decode() -> bool { std::env::var("LESAVKA_HW_H264").is_ok() diff --git a/server/src/tests/camera.rs b/server/src/tests/camera.rs index 7658f8a..a333154 100644 --- a/server/src/tests/camera.rs +++ b/server/src/tests/camera.rs @@ -4,6 +4,7 @@ use super::{ update_camera_config, }; use serial_test::serial; +use std::fs; use temp_env::with_var; #[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] #[serial] fn camera_config_env_override_honors_uvc_codec() {