media: cap isochronous uvc mjpeg frames

This commit is contained in:
Brad Stein 2026-05-15 08:37:26 -03:00
parent b2e7a4cb38
commit 58e4b4f5b7
16 changed files with 288 additions and 32 deletions

6
Cargo.lock generated
View File

@ -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",

View File

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

View File

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

View File

@ -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 |

View File

@ -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

View File

@ -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
}

View File

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

View File

@ -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<Duration>,
next_queue_at: Option<Instant>,
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<Instant>,
@ -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,
}
}
}

View File

@ -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<std::time::Duration>,
next_queue_at: Option<std::time::Instant>,
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,
}
}

View File

@ -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
)

View File

@ -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() {

View File

@ -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<Path>) -> Option<u32> {
fs::read_to_string(path)
.ok()
.and_then(|value| value.trim().parse::<u32>().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]

View File

@ -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",

View File

@ -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"));

View File

@ -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!(

View File

@ -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")