fix: honor live UVC gadget profile
This commit is contained in:
parent
f1e3faa404
commit
1673bb8b1b
10
AGENTS.md
10
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,
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.18.1"
|
||||
version = "0.18.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.18.1"
|
||||
version = "0.18.2"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.18.1"
|
||||
version = "0.18.2"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<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)]
|
||||
fn has_hw_h264_decode() -> bool {
|
||||
std::env::var("LESAVKA_HW_H264").is_ok()
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user