diff --git a/Cargo.lock b/Cargo.lock index e83da45..9fd1b93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.36" +version = "0.22.37" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.36" +version = "0.22.37" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.36" +version = "0.22.37" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index c3e0964..e255e1e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.36" +version = "0.22.37" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 1b623c1..e213fce 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.36" +version = "0.22.37" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index 0e5bb12..842bb16 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -338,12 +338,13 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS` | server HEVC decode-to-MJPEG branch queue depth; defaults to `2` and is capped at `4` so decode/JPEG scheduling jitter does not starve the UVC helper while stale frames still get dropped | | `LESAVKA_UVC_IDLE_PUMP_MS` | UVC helper freshness override; idle poll sleep while pumping host-returned buffers, defaults to `2` | | `LESAVKA_UVC_INTERVAL` | server hardware/device override | +| `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT` | UVC MJPEG isochronous safety cap; defaults to `85`, limiting advertised/runtime frame bytes to a safe fraction of high-speed isochronous payload capacity when bulk UVC is unavailable | | `LESAVKA_UVC_LIMIT_PCT` | server hardware/device override | | `LESAVKA_UVC_MAXBURST` | server hardware/device override | | `LESAVKA_UVC_MAXPACKET` | server hardware/device override | | `LESAVKA_UVC_MAXPAYLOAD_LIMIT` | server hardware/device override | | `LESAVKA_UVC_MJPEG` | server hardware/device override | -| `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC` | UVC helper MJPEG budget guard; derives a per-frame byte cap from target FPS when `LESAVKA_UVC_FRAME_MAX_BYTES` is unset | +| `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC` | UVC helper MJPEG budget guard; derives a per-frame byte cap from target FPS when `LESAVKA_UVC_FRAME_MAX_BYTES` is unset; non-bulk UVC is additionally capped by `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT` | | `LESAVKA_UVC_SKIP_UDEV` | server hardware/device override | | `LESAVKA_UVC_STATS_INTERVAL_MS` | UVC helper telemetry interval for queued/reloaded/rejected MJPEG frame counters; defaults to `5000`, `0` disables | | `LESAVKA_UVC_STATS_PATH` | UVC helper JSON stats snapshot path for queued/reloaded/rejected MJPEG frame counters; defaults to `/run/lesavka-uvc-video-stats.json`, set `0` or empty to disable file snapshots | diff --git a/scripts/daemon/lesavka-core.sh b/scripts/daemon/lesavka-core.sh index 8b01c30..9d7c69d 100755 --- a/scripts/daemon/lesavka-core.sh +++ b/scripts/daemon/lesavka-core.sh @@ -322,13 +322,33 @@ UVC_FRAME_SIZE=${LESAVKA_UVC_FRAME_SIZE:-$((UVC_WIDTH * UVC_HEIGHT * 2))} UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333} UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000} UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-10000000} +UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85} + +uvc_isochronous_budget_bytes_per_sec() { + local maxpacket=${UVC_MAXPACKET:-1024} + local pct=${UVC_ISOCHRONOUS_LIMIT_PCT:-85} + if ((pct < 1)); then + pct=1 + elif ((pct > 100)); then + pct=100 + fi + echo $((maxpacket * 8000 * pct / 100)) +} uvc_mjpeg_frame_size_for_fps() { local fps=$1 if ((fps < 1)); then fps=1 fi - local per_frame=$((UVC_MJPEG_BUDGET_BYTES_PER_SEC / fps)) + local budget=$UVC_MJPEG_BUDGET_BYTES_PER_SEC + if [[ -z $UVC_BULK ]]; then + local isoch_budget + isoch_budget="$(uvc_isochronous_budget_bytes_per_sec)" + if ((isoch_budget > 0 && budget > isoch_budget)); then + budget=$isoch_budget + fi + fi + local per_frame=$((budget / fps)) if ((per_frame < 65536)); then per_frame=65536 elif ((per_frame > 8388608)); then diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 870724a..3e6176a 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -361,6 +361,7 @@ LESAVKA_UVC_BULK=$(uvc_env_value LESAVKA_UVC_BULK 1) LESAVKA_UVC_FRAME_SIZE_GUARD=$(uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1) LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0) LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=$(uvc_env_value LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC 10000000) +LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT=$(uvc_env_value LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT 85) LESAVKA_UVC_STATS_PATH=$(uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json) EOF } diff --git a/server/Cargo.toml b/server/Cargo.toml index 4cbd54e..b94300d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.36" +version = "0.22.37" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index 1c4507b..1148f77 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -52,6 +52,8 @@ const DEFAULT_UVC_BUFFER_COUNT: u32 = 2; const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; +const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; +const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000; const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json"; @@ -191,6 +193,7 @@ struct UvcConfig { interval: u32, max_packet: u32, frame_size: u32, + bulk: bool, } struct PayloadCap { @@ -247,6 +250,8 @@ struct UvcVideoStream { frame_period: Option, next_queue_at: Option, streaming: bool, + transport_bulk: bool, + max_packet: u32, stats: UvcVideoStats, } @@ -259,6 +264,8 @@ struct UvcVideoStats { rejected_invalid: u64, fallback_idle: u64, latest_bytes: usize, + last_rejected_oversize_bytes: usize, + last_rejected_oversize_cap: usize, paced_sleeps: u64, paced_sleep_ms: u64, last_report: Option, @@ -275,6 +282,8 @@ impl UvcVideoStream { frame_period: None, next_queue_at: None, streaming: false, + transport_bulk: false, + max_packet: 0, stats: UvcVideoStats::default(), } } @@ -284,6 +293,8 @@ impl UvcVideoStream { self.frame_max_bytes = uvc_frame_max_bytes(cfg); self.frame_period = uvc_queue_period(cfg.fps); self.next_queue_at = None; + self.transport_bulk = cfg.bulk; + self.max_packet = cfg.max_packet; self.set_format(cfg)?; self.request_buffers(uvc_buffer_count())?; for index in 0..self.buffers.len() { @@ -296,9 +307,17 @@ impl UvcVideoStream { return Err(std::io::Error::last_os_error()).context("VIDIOC_STREAMON"); } self.streaming = true; + let transport = if self.transport_bulk { + "bulk" + } else { + "isochronous" + }; eprintln!( - "[lesavka-uvc] video stream started with {} buffers; frame_path={}", + "[lesavka-uvc] video stream started with {} buffers; transport={} max_packet={} frame_cap={} frame_path={}", self.buffers.len(), + transport, + self.max_packet, + self.frame_payload_limit(), self.frame_path.display() ); Ok(()) @@ -494,6 +513,8 @@ impl UvcVideoStream { } Ok(frame) if frame.len() > max_frame_bytes => { self.stats.rejected_oversize += 1; + self.stats.last_rejected_oversize_bytes = frame.len(); + self.stats.last_rejected_oversize_cap = max_frame_bytes; } Ok(frame) => { self.stats.reloaded += 1; @@ -540,7 +561,7 @@ impl UvcVideoStream { } self.stats.last_report = Some(now); eprintln!( - "[lesavka-uvc] video stats queued={} reloaded={} stale_replay={} rejected_oversize={} rejected_invalid={} fallback_idle={} latest_bytes={} frame_cap={} paced_sleeps={} paced_sleep_ms={}", + "[lesavka-uvc] video stats queued={} reloaded={} stale_replay={} rejected_oversize={} rejected_invalid={} fallback_idle={} latest_bytes={} frame_cap={} last_rejected_oversize_bytes={} last_rejected_oversize_cap={} paced_sleeps={} paced_sleep_ms={}", self.stats.queued, self.stats.reloaded, self.stats.replayed_stale, @@ -549,6 +570,8 @@ impl UvcVideoStream { self.stats.fallback_idle, self.stats.latest_bytes, self.frame_payload_limit(), + self.stats.last_rejected_oversize_bytes, + self.stats.last_rejected_oversize_cap, self.stats.paced_sleeps, self.stats.paced_sleep_ms ); @@ -664,18 +687,42 @@ fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { } fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize { - derived_uvc_frame_max_bytes_for_fps(cfg.fps) + derived_uvc_frame_max_bytes_for_transport(cfg.fps, cfg.max_packet, cfg.bulk) } -fn derived_uvc_frame_max_bytes_for_fps(fps: u32) -> usize { +fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> usize { let fps = fps.max(1); - let budget_per_sec = env_u32( + let budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk); + let per_frame = (budget_per_sec / fps).max(64 * 1024); + per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize +} + +fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 { + let configured = env_u32( "LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, ) .max(1); - let per_frame = (budget_per_sec / fps).max(64 * 1024); - per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize + if bulk { + return configured; + } + + configured + .min(uvc_isochronous_budget_bytes_per_sec(max_packet)) + .max(1) +} + +fn uvc_isochronous_budget_bytes_per_sec(max_packet: u32) -> u32 { + let pct = env_u32( + "LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", + DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, + ) + .clamp(1, 100); + let bytes = u64::from(max_packet) + .saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC)) + .saturating_mul(u64::from(pct)) + / 100; + bytes.min(u64::from(u32::MAX)) as u32 } fn env_flag_enabled(name: &str, default: bool) -> bool { @@ -720,9 +767,18 @@ fn uvc_bulk_transfer_enabled() -> bool { true } -fn uvc_frame_size_for_active_mode(width: u32, height: u32, fps: u32) -> u32 { +fn uvc_frame_size_for_active_mode( + width: u32, + height: u32, + fps: u32, + max_packet: u32, + bulk: bool, +) -> u32 { env_u32_opt("LESAVKA_UVC_FRAME_SIZE") - .unwrap_or_else(|| derived_uvc_frame_max_bytes_for_fps(fps).min(u32::MAX as usize) as u32) + .unwrap_or_else(|| { + derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk) + .min(u32::MAX as usize) as u32 + }) .max((width.saturating_mul(height) / 32).max(64 * 1024)) } @@ -738,7 +794,7 @@ fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> { fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String { format!( - "{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{}}}\n", + "{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{}}}\n", stats.queued, stats.reloaded, stats.replayed_stale, @@ -747,6 +803,8 @@ fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String { stats.fallback_idle, stats.latest_bytes, frame_cap, + stats.last_rejected_oversize_bytes, + stats.last_rejected_oversize_cap, stats.paced_sleeps, stats.paced_sleep_ms ) @@ -1029,9 +1087,8 @@ impl UvcConfig { interval = live_interval; } } - let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024); - let frame_size = uvc_frame_size_for_active_mode(width, height, fps); let bulk = uvc_bulk_transfer_enabled(); + let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024); if let Some(cap) = compute_payload_cap(bulk) { if max_packet > cap.limit { eprintln!( @@ -1081,6 +1138,7 @@ impl UvcConfig { } else { max_packet = max_packet.min(1024); } + let frame_size = uvc_frame_size_for_active_mode(width, height, fps, max_packet, bulk); Self { width, @@ -1089,6 +1147,7 @@ impl UvcConfig { interval, max_packet, frame_size, + bulk, } } } diff --git a/server/src/bin/lesavka_uvc/coverage_model.rs b/server/src/bin/lesavka_uvc/coverage_model.rs index d747644..833acb9 100644 --- a/server/src/bin/lesavka_uvc/coverage_model.rs +++ b/server/src/bin/lesavka_uvc/coverage_model.rs @@ -58,6 +58,10 @@ const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; #[cfg(coverage)] const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; #[cfg(coverage)] +const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; +#[cfg(coverage)] +const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; +#[cfg(coverage)] const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000; #[cfg(coverage)] const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json"; @@ -103,6 +107,7 @@ struct UvcConfig { interval: u32, max_packet: u32, frame_size: u32, + bulk: bool, } #[cfg(coverage)] @@ -164,6 +169,8 @@ struct UvcVideoStream { frame_max_bytes: usize, frame_period: Option, next_queue_at: Option, + transport_bulk: bool, + max_packet: u32, } #[cfg(coverage)] @@ -176,6 +183,8 @@ struct UvcVideoStats { rejected_invalid: u64, fallback_idle: u64, latest_bytes: usize, + last_rejected_oversize_bytes: usize, + last_rejected_oversize_cap: usize, paced_sleeps: u64, paced_sleep_ms: u64, } @@ -190,6 +199,8 @@ impl UvcVideoStream { frame_max_bytes: MAX_MJPEG_FRAME_BYTES, frame_period: None, next_queue_at: None, + transport_bulk: false, + max_packet: 0, } } diff --git a/server/src/bin/lesavka_uvc/coverage_startup.rs b/server/src/bin/lesavka_uvc/coverage_startup.rs index 0f93b52..f9ecd70 100644 --- a/server/src/bin/lesavka_uvc/coverage_startup.rs +++ b/server/src/bin/lesavka_uvc/coverage_startup.rs @@ -38,7 +38,6 @@ impl UvcConfig { 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 frame_size = uvc_frame_size_for_active_mode(width, height, fps); let interval = env_u32("LESAVKA_UVC_INTERVAL", 0); let bulk = uvc_bulk_transfer_enabled(); let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024); @@ -51,6 +50,7 @@ impl UvcConfig { } else { max_packet.min(1024) }; + let frame_size = uvc_frame_size_for_active_mode(width, height, fps, max_packet, bulk); let interval = if interval == 0 { 10_000_000 / fps @@ -65,6 +65,7 @@ impl UvcConfig { interval, max_packet, frame_size, + bulk, } } } @@ -189,19 +190,45 @@ fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { #[cfg(coverage)] fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize { - derived_uvc_frame_max_bytes_for_fps(cfg.fps) + derived_uvc_frame_max_bytes_for_transport(cfg.fps, cfg.max_packet, cfg.bulk) } #[cfg(coverage)] -fn derived_uvc_frame_max_bytes_for_fps(fps: u32) -> usize { +fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> usize { let fps = fps.max(1); - let budget_per_sec = env_u32( + let budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk); + let per_frame = (budget_per_sec / fps).max(64 * 1024); + per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize +} + +#[cfg(coverage)] +fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 { + let configured = env_u32( "LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, ) .max(1); - let per_frame = (budget_per_sec / fps).max(64 * 1024); - per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize + if bulk { + return configured; + } + + configured + .min(uvc_isochronous_budget_bytes_per_sec(max_packet)) + .max(1) +} + +#[cfg(coverage)] +fn uvc_isochronous_budget_bytes_per_sec(max_packet: u32) -> u32 { + let pct = env_u32( + "LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", + DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, + ) + .clamp(1, 100); + let bytes = u64::from(max_packet) + .saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC)) + .saturating_mul(u64::from(pct)) + / 100; + bytes.min(u64::from(u32::MAX)) as u32 } #[cfg(coverage)] @@ -247,9 +274,18 @@ fn uvc_bulk_transfer_enabled() -> bool { } #[cfg(coverage)] -fn uvc_frame_size_for_active_mode(width: u32, height: u32, fps: u32) -> u32 { +fn uvc_frame_size_for_active_mode( + width: u32, + height: u32, + fps: u32, + max_packet: u32, + bulk: bool, +) -> u32 { env_u32_opt("LESAVKA_UVC_FRAME_SIZE") - .unwrap_or_else(|| derived_uvc_frame_max_bytes_for_fps(fps).min(u32::MAX as usize) as u32) + .unwrap_or_else(|| { + derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk) + .min(u32::MAX as usize) as u32 + }) .max((width.saturating_mul(height) / 32).max(64 * 1024)) } @@ -282,7 +318,7 @@ fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> { #[cfg(coverage)] fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String { format!( - "{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{}}}\n", + "{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{}}}\n", stats.queued, stats.reloaded, stats.replayed_stale, @@ -291,6 +327,8 @@ fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String { stats.fallback_idle, stats.latest_bytes, frame_cap, + stats.last_rejected_oversize_bytes, + stats.last_rejected_oversize_cap, stats.paced_sleeps, stats.paced_sleep_ms ) diff --git a/server/src/bin/tests/lesavka_uvc.rs b/server/src/bin/tests/lesavka_uvc.rs index 637dbb6..02a10d0 100644 --- a/server/src/bin/tests/lesavka_uvc.rs +++ b/server/src/bin/tests/lesavka_uvc.rs @@ -12,6 +12,7 @@ fn sample_cfg() -> UvcConfig { interval: 400_000, max_packet: 1024, frame_size: 1_843_200, + bulk: true, } } @@ -113,6 +114,27 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() { }); } +#[test] +#[serial] +fn uvc_frame_budget_caps_isochronous_transport() { + with_vars( + [ + ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("10000000")), + ("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", None::<&str>), + ], + || { + assert_eq!( + derived_uvc_frame_max_bytes_for_transport(30, 1024, false), + 232_106 + ); + assert_eq!( + derived_uvc_frame_max_bytes_for_transport(30, 1024, true), + 333_333 + ); + }, + ); +} + #[test] #[serial] fn main_coverage_mode_returns_error_for_non_uvc_node() { diff --git a/server/src/video_sinks/mjpeg_spool.rs b/server/src/video_sinks/mjpeg_spool.rs index 8c35f22..09d5e5e 100644 --- a/server/src/video_sinks/mjpeg_spool.rs +++ b/server/src/video_sinks/mjpeg_spool.rs @@ -11,6 +11,9 @@ static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1); static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; +const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; +const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; +const CONFIGFS_UVC_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0"; #[derive(Clone, Copy)] pub(super) struct MjpegSpoolTiming { @@ -107,6 +110,56 @@ fn env_flag_enabled(name: &str, default: bool) -> bool { .unwrap_or(default) } +fn read_u32_file(path: impl AsRef) -> Option { + fs::read_to_string(path) + .ok() + .and_then(|value| value.trim().parse::().ok()) +} + +fn uvc_bulk_transfer_enabled() -> bool { + if !env_flag_enabled("LESAVKA_UVC_BULK", true) { + return false; + } + let base = Path::new(CONFIGFS_UVC_BASE); + !(base.exists() && !base.join("streaming_bulk").exists()) +} + +fn uvc_streaming_maxpacket(bulk: bool) -> u32 { + let mut maxpacket = env_u32_opt("LESAVKA_UVC_MAXPACKET").unwrap_or(1024); + if let Some(live) = read_u32_file(Path::new(CONFIGFS_UVC_BASE).join("streaming_maxpacket")) { + maxpacket = maxpacket.min(live); + } + if bulk { + maxpacket.min(512) + } else { + maxpacket.min(1024) + } +} + +fn uvc_isochronous_budget_bytes_per_sec(maxpacket: u32) -> u32 { + let pct = env_u32_opt("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT") + .unwrap_or(DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT) + .clamp(1, 100); + let bytes = u64::from(maxpacket) + .saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC)) + .saturating_mul(u64::from(pct)) + / 100; + bytes.min(u64::from(u32::MAX)) as u32 +} + +fn effective_mjpeg_budget_bytes_per_sec() -> u32 { + let configured = env_u32_opt("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC") + .unwrap_or(DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC) + .max(1); + if uvc_bulk_transfer_enabled() { + configured + } else { + configured + .min(uvc_isochronous_budget_bytes_per_sec(uvc_streaming_maxpacket(false))) + .max(1) + } +} + /// Resolve the MJPEG byte budget used before publishing to the helper. /// /// Inputs: active FPS plus `LESAVKA_UVC_FRAME_MAX_BYTES`, @@ -125,9 +178,7 @@ pub(super) fn mjpeg_spool_frame_max_bytes(fps: u32) -> usize { } let fps = fps.max(1); - let budget_per_sec = env_u32_opt("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC") - .unwrap_or(DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC) - .max(1); + let budget_per_sec = effective_mjpeg_budget_bytes_per_sec(); let per_frame = (budget_per_sec / fps).max(64 * 1024); per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize } @@ -368,6 +419,20 @@ mod tests { assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 65_536); }, ); + + temp_env::with_vars( + [ + ("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("1")), + ("LESAVKA_UVC_FRAME_MAX_BYTES", None::<&str>), + ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("10000000")), + ("LESAVKA_UVC_BULK", Some("0")), + ("LESAVKA_UVC_MAXPACKET", Some("1024")), + ("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", None::<&str>), + ], + || { + assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 232_106); + }, + ); } #[test] diff --git a/tests/contract/scripts/daemon/server_core_script_contract.rs b/tests/contract/scripts/daemon/server_core_script_contract.rs index a473090..240108b 100644 --- a/tests/contract/scripts/daemon/server_core_script_contract.rs +++ b/tests/contract/scripts/daemon/server_core_script_contract.rs @@ -102,6 +102,9 @@ fn core_script_keeps_uvc_output_on_supported_mjpeg_descriptor() { "UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}", "uvc_mjpeg_frame_size_for_fps()", "UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-10000000}", + "UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}", + "uvc_isochronous_budget_bytes_per_sec()", + "isoch_budget=\"$(uvc_isochronous_budget_bytes_per_sec)\"", "UVC_FRAME_SIZE=\"$(uvc_mjpeg_frame_size_for_fps \"$UVC_FPS\")\"", "write_mjpeg_frame_descriptor 1080p 1920 1080", "write_mjpeg_frame_descriptor 720p 1280 720", diff --git a/tests/contract/server/uvc/server_uvc_binary_contract.rs b/tests/contract/server/uvc/server_uvc_binary_contract.rs index 8d1d477..45c79b3 100644 --- a/tests/contract/server/uvc/server_uvc_binary_contract.rs +++ b/tests/contract/server/uvc/server_uvc_binary_contract.rs @@ -29,6 +29,7 @@ mod uvc_binary { interval: 400_000, max_packet: 1024, frame_size: 1_843_200, + bulk: true, } } @@ -332,6 +333,7 @@ mod uvc_binary { assert_eq!(cfg.interval, 10_000_000 / 30); assert_eq!(cfg.frame_size, 300_000); assert_eq!(cfg.max_packet, 512); + assert!(cfg.bulk); assert!(uvc_bulk_transfer_enabled()); }, ); @@ -341,6 +343,31 @@ mod uvc_binary { }); } + #[test] + #[serial] + fn uvc_isochronous_transport_caps_mjpeg_budget_to_wire_rate() { + with_vars( + [ + ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("10000000")), + ("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", None::<&str>), + ], + || { + assert_eq!( + derived_uvc_frame_max_bytes_for_transport(30, 1024, false), + 232_106 + ); + assert_eq!( + derived_uvc_frame_max_bytes_for_transport(20, 1024, false), + 348_160 + ); + assert_eq!( + derived_uvc_frame_max_bytes_for_transport(30, 1024, true), + 333_333 + ); + }, + ); + } + #[test] fn uvc_bulk_mode_is_tied_to_live_configfs_support() { let source = include_str!("../../../../server/src/bin/lesavka-uvc.real.inc"); @@ -360,6 +387,8 @@ mod uvc_binary { rejected_invalid: 3, fallback_idle: 4, latest_bytes: 77_036, + last_rejected_oversize_bytes: 300_001, + last_rejected_oversize_cap: 300_000, paced_sleeps: 5, paced_sleep_ms: 123, last_report: None, @@ -368,6 +397,8 @@ mod uvc_binary { assert!(json.contains("\"queued\":7")); assert!(json.contains("\"latest_bytes\":77036")); assert!(json.contains("\"frame_cap\":333333")); + assert!(json.contains("\"last_rejected_oversize_bytes\":300001")); + assert!(json.contains("\"last_rejected_oversize_cap\":300000")); assert!(json.contains("\"paced_sleeps\":5")); assert!(json.contains("\"paced_sleep_ms\":123")); diff --git a/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs b/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs index 889b653..bf9f7b6 100644 --- a/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs +++ b/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs @@ -28,6 +28,7 @@ mod uvc_binary_extra { interval: 400_000, max_packet: 1024, frame_size: 1_843_200, + bulk: true, } } @@ -427,6 +428,9 @@ mod uvc_binary_extra { stream.refresh_latest_frame(); assert_eq!(stream.latest_frame, vec![0xff, 0xd8, 0x11, 0xff, 0xd9]); + assert_eq!(stream.stats.rejected_oversize, 1); + assert_eq!(stream.stats.last_rejected_oversize_bytes, 12); + assert_eq!(stream.stats.last_rejected_oversize_cap, 8); assert_eq!(stream.frame_payload_limit(), 8); assert_eq!(stream.frame_for_buffer(4), MINIMAL_MJPEG_FRAME); assert_eq!( diff --git a/tests/installer/scripts/install/server_install_script_contract.rs b/tests/installer/scripts/install/server_install_script_contract.rs index 904c7f6..4820efd 100644 --- a/tests/installer/scripts/install/server_install_script_contract.rs +++ b/tests/installer/scripts/install/server_install_script_contract.rs @@ -197,6 +197,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_BULK 1")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0")); + assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT 85")); assert!( SERVER_INSTALL .contains("uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json")