2026-04-23 07:00:06 -03:00
|
|
|
fn main() -> Result<()> {
|
|
|
|
|
let (dev, _cfg) = parse_args()?;
|
|
|
|
|
let _ = load_interfaces();
|
|
|
|
|
let _ = UvcConfig::from_env();
|
|
|
|
|
|
|
|
|
|
let _ = open_with_retry(&dev)?;
|
|
|
|
|
anyhow::bail!("coverage harness: control loop disabled");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn parse_args() -> Result<(String, UvcConfig)> {
|
|
|
|
|
let args: Vec<String> = env::args().skip(1).collect();
|
|
|
|
|
let dev = parse_device_arg(&args)
|
|
|
|
|
.or_else(|| env::var("LESAVKA_UVC_DEV").ok())
|
|
|
|
|
.context("missing --device (or LESAVKA_UVC_DEV)")?;
|
|
|
|
|
|
|
|
|
|
Ok((dev, UvcConfig::from_env()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn parse_device_arg(args: &[String]) -> Option<String> {
|
|
|
|
|
args
|
|
|
|
|
.windows(2)
|
|
|
|
|
.find_map(|pair| (pair[0] == "--device" || pair[0] == "-d").then(|| pair[1].clone()))
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
args.iter()
|
|
|
|
|
.rev()
|
|
|
|
|
.find(|arg| {
|
|
|
|
|
arg.as_str() != "--device" && arg.as_str() != "-d" && arg.starts_with('/')
|
|
|
|
|
})
|
|
|
|
|
.cloned()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
impl UvcConfig {
|
|
|
|
|
fn from_env() -> Self {
|
|
|
|
|
let width = env_u32("LESAVKA_UVC_WIDTH", 1280);
|
|
|
|
|
let height = env_u32("LESAVKA_UVC_HEIGHT", 720);
|
2026-05-02 22:59:18 -03:00
|
|
|
let fps = env_u32("LESAVKA_UVC_FPS", 30).max(1);
|
2026-05-14 12:08:53 -03:00
|
|
|
let frame_size = uvc_frame_size_for_active_mode(width, height, fps);
|
2026-04-23 07:00:06 -03:00
|
|
|
let interval = env_u32("LESAVKA_UVC_INTERVAL", 0);
|
2026-05-14 12:08:53 -03:00
|
|
|
let bulk = uvc_bulk_transfer_enabled();
|
2026-04-23 07:00:06 -03:00
|
|
|
let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024);
|
|
|
|
|
|
|
|
|
|
if let Some(limit) = compute_payload_cap(bulk).map(|cap| cap.limit) {
|
|
|
|
|
max_packet = max_packet.min(limit);
|
|
|
|
|
}
|
|
|
|
|
max_packet = if bulk {
|
|
|
|
|
max_packet.min(512)
|
|
|
|
|
} else {
|
|
|
|
|
max_packet.min(1024)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let interval = if interval == 0 {
|
|
|
|
|
10_000_000 / fps
|
|
|
|
|
} else {
|
|
|
|
|
interval
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Self {
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
fps,
|
|
|
|
|
interval,
|
|
|
|
|
max_packet,
|
|
|
|
|
frame_size,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
impl UvcState {
|
|
|
|
|
fn new(cfg: UvcConfig) -> Self {
|
|
|
|
|
let ctrl_len = stream_ctrl_len();
|
|
|
|
|
let default = build_streaming_control(&cfg, ctrl_len);
|
|
|
|
|
Self {
|
|
|
|
|
cfg,
|
|
|
|
|
ctrl_len,
|
|
|
|
|
default,
|
|
|
|
|
probe: default,
|
|
|
|
|
commit: default,
|
|
|
|
|
cfg_snapshot: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn load_interfaces() -> UvcInterfaces {
|
|
|
|
|
let control = env_u8("LESAVKA_UVC_CTRL_INTF").unwrap_or(UVC_STRING_CONTROL_IDX);
|
|
|
|
|
let streaming = env_u8("LESAVKA_UVC_STREAM_INTF").unwrap_or(UVC_STRING_STREAMING_IDX);
|
|
|
|
|
UvcInterfaces { control, streaming }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn read_interface(path: &str) -> Option<u8> {
|
|
|
|
|
std::fs::read_to_string(path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|v| v.trim().parse::<u8>().ok())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn open_with_retry(path: &str) -> Result<std::fs::File> {
|
2026-04-29 01:25:06 -03:00
|
|
|
let read_only = uvc_control_read_only();
|
2026-04-23 07:00:06 -03:00
|
|
|
let mut opts = OpenOptions::new();
|
2026-04-29 01:25:06 -03:00
|
|
|
opts.read(true);
|
|
|
|
|
if !read_only {
|
|
|
|
|
opts.write(true);
|
|
|
|
|
}
|
2026-04-23 07:00:06 -03:00
|
|
|
if env::var("LESAVKA_UVC_BLOCKING").is_err() {
|
|
|
|
|
opts.custom_flags(libc::O_NONBLOCK);
|
|
|
|
|
}
|
|
|
|
|
opts.open(path).with_context(|| format!("open {path}"))
|
|
|
|
|
}
|
2026-04-29 01:25:06 -03:00
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
2026-04-30 08:16:57 -03:00
|
|
|
/// Keep coverage-mode UVC control opens read-only unless a test opts into writes.
|
2026-04-29 01:25:06 -03:00
|
|
|
fn uvc_control_read_only() -> bool {
|
|
|
|
|
env::var("LESAVKA_UVC_CONTROL_READ_ONLY")
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|value| {
|
|
|
|
|
let trimmed = value.trim();
|
|
|
|
|
!(trimmed.eq_ignore_ascii_case("0")
|
|
|
|
|
|| trimmed.eq_ignore_ascii_case("false")
|
|
|
|
|
|| trimmed.eq_ignore_ascii_case("no")
|
|
|
|
|
|| trimmed.eq_ignore_ascii_case("off"))
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(true)
|
|
|
|
|
}
|
2026-05-09 11:34:13 -03:00
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
/// Returns the bounded UVC request-buffer count used by the helper.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: `LESAVKA_UVC_BUFFER_COUNT`. Outputs: a value clamped to the safe
|
|
|
|
|
/// range accepted by the helper. Why: coverage contracts need the same backlog
|
|
|
|
|
/// bound as the real UVC binary without opening a gadget device.
|
|
|
|
|
fn uvc_buffer_count() -> u32 {
|
|
|
|
|
env_u32("LESAVKA_UVC_BUFFER_COUNT", DEFAULT_UVC_BUFFER_COUNT).clamp(1, 8)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
/// Returns the idle frame-pump sleep interval used when no fresh frame is ready.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: `LESAVKA_UVC_IDLE_PUMP_MS`. Outputs: a duration in milliseconds.
|
|
|
|
|
/// Why: this value controls how quickly the UVC helper retries the freshness
|
|
|
|
|
/// spool when browsers are already streaming.
|
|
|
|
|
fn uvc_idle_pump_sleep() -> std::time::Duration {
|
|
|
|
|
std::time::Duration::from_millis(env_u64(
|
|
|
|
|
"LESAVKA_UVC_IDLE_PUMP_MS",
|
|
|
|
|
DEFAULT_UVC_IDLE_PUMP_MS,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 21:48:57 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
/// Bound MJPEG frames before they enter the USB UVC helper.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: UVC mode plus optional `LESAVKA_UVC_FRAME_MAX_BYTES` or
|
|
|
|
|
/// `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC`. Output: maximum accepted JPEG
|
|
|
|
|
/// byte length. Why: coverage tests should lock the artifact-prevention budget
|
|
|
|
|
/// that turns oversized UVC frames into freezes instead of grey half-frames.
|
|
|
|
|
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
2026-05-14 05:18:36 -03:00
|
|
|
if !uvc_frame_size_guard_enabled() {
|
|
|
|
|
return MAX_MJPEG_FRAME_BYTES;
|
|
|
|
|
}
|
2026-05-09 21:48:57 -03:00
|
|
|
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") {
|
|
|
|
|
return if limit == 0 {
|
2026-05-14 05:18:36 -03:00
|
|
|
derived_uvc_frame_max_bytes(cfg)
|
2026-05-09 21:48:57 -03:00
|
|
|
} else {
|
2026-05-14 05:18:36 -03:00
|
|
|
(limit as usize).min(MAX_MJPEG_FRAME_BYTES)
|
2026-05-09 21:48:57 -03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
derived_uvc_frame_max_bytes(cfg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
2026-05-14 12:08:53 -03:00
|
|
|
derived_uvc_frame_max_bytes_for_fps(cfg.fps)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn derived_uvc_frame_max_bytes_for_fps(fps: u32) -> usize {
|
|
|
|
|
let fps = fps.max(1);
|
2026-05-09 21:48:57 -03:00
|
|
|
let budget_per_sec = 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
#[cfg(coverage)]
|
2026-05-14 12:08:53 -03:00
|
|
|
fn env_flag_enabled(name: &str, default: bool) -> bool {
|
|
|
|
|
env::var(name)
|
2026-05-14 05:18:36 -03:00
|
|
|
.ok()
|
|
|
|
|
.map(|value| {
|
|
|
|
|
let trimmed = value.trim();
|
2026-05-14 12:08:53 -03:00
|
|
|
if trimmed.eq_ignore_ascii_case("0")
|
2026-05-14 05:18:36 -03:00
|
|
|
|| trimmed.eq_ignore_ascii_case("false")
|
|
|
|
|
|| trimmed.eq_ignore_ascii_case("no")
|
2026-05-14 12:08:53 -03:00
|
|
|
|| trimmed.eq_ignore_ascii_case("off")
|
|
|
|
|
{
|
|
|
|
|
false
|
|
|
|
|
} else if trimmed.eq_ignore_ascii_case("1")
|
|
|
|
|
|| trimmed.eq_ignore_ascii_case("true")
|
|
|
|
|
|| trimmed.eq_ignore_ascii_case("yes")
|
|
|
|
|
|| trimmed.eq_ignore_ascii_case("on")
|
|
|
|
|
{
|
|
|
|
|
true
|
|
|
|
|
} else {
|
|
|
|
|
default
|
|
|
|
|
}
|
2026-05-14 05:18:36 -03:00
|
|
|
})
|
2026-05-14 12:08:53 -03:00
|
|
|
.unwrap_or(default)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn uvc_frame_size_guard_enabled() -> bool {
|
|
|
|
|
env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn uvc_bulk_transfer_enabled() -> bool {
|
|
|
|
|
env_flag_enabled("LESAVKA_UVC_BULK", true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn uvc_frame_size_for_active_mode(width: u32, height: u32, fps: u32) -> 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)
|
|
|
|
|
.max((width.saturating_mul(height) / 32).max(64 * 1024))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn uvc_stats_path() -> Option<std::path::PathBuf> {
|
|
|
|
|
match std::env::var("LESAVKA_UVC_STATS_PATH") {
|
|
|
|
|
Ok(value) => {
|
|
|
|
|
let trimmed = value.trim();
|
|
|
|
|
if trimmed.is_empty() || trimmed == "0" {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(std::path::PathBuf::from(trimmed))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(_) => Some(std::path::PathBuf::from(DEFAULT_UVC_STATS_PATH)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
std::fs::create_dir_all(parent)?;
|
|
|
|
|
}
|
|
|
|
|
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
|
|
|
|
|
std::fs::write(&tmp, text)?;
|
|
|
|
|
std::fs::rename(&tmp, path)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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\":{}}}\n",
|
|
|
|
|
stats.queued,
|
|
|
|
|
stats.reloaded,
|
|
|
|
|
stats.replayed_stale,
|
|
|
|
|
stats.rejected_oversize,
|
|
|
|
|
stats.rejected_invalid,
|
|
|
|
|
stats.fallback_idle,
|
|
|
|
|
stats.latest_bytes,
|
|
|
|
|
frame_cap
|
|
|
|
|
)
|
2026-05-14 05:18:36 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn uvc_stats_interval() -> Option<std::time::Duration> {
|
|
|
|
|
match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) {
|
|
|
|
|
0 => None,
|
|
|
|
|
value => Some(std::time::Duration::from_millis(value)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 11:34:13 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
/// Returns the optional maximum age for a spooled MJPEG frame.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: `LESAVKA_UVC_FRAME_MAX_AGE_MS`. Outputs: `None` when the TTL is
|
|
|
|
|
/// disabled. Why: stale-frame replay must be explicit because it trades
|
|
|
|
|
/// smoothness against freshness during browser stream recovery.
|
|
|
|
|
fn frame_spool_max_age() -> Option<std::time::Duration> {
|
|
|
|
|
match env_u64(
|
|
|
|
|
"LESAVKA_UVC_FRAME_MAX_AGE_MS",
|
|
|
|
|
DEFAULT_UVC_FRAME_MAX_AGE_MS,
|
|
|
|
|
) {
|
|
|
|
|
0 => None,
|
|
|
|
|
value => Some(std::time::Duration::from_millis(value)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
/// Determines whether a spooled MJPEG frame is too old to replay.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: frame path and optional age limit. Outputs: true when the frame is
|
|
|
|
|
/// missing or older than the limit. Why: the coverage harness should guard the
|
|
|
|
|
/// same freshness cut-off that prevents seconds-old video from re-entering UVC.
|
|
|
|
|
fn frame_spool_is_stale(path: &std::path::Path, max_age: Option<std::time::Duration>) -> bool {
|
|
|
|
|
let Some(max_age) = max_age else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let Ok(metadata) = std::fs::metadata(path) else {
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
let Ok(modified) = metadata.modified() else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
std::time::SystemTime::now()
|
|
|
|
|
.duration_since(modified)
|
|
|
|
|
.map(|age| age > max_age)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
/// Parses an unsigned 64-bit environment value with a fallback.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: variable name and default value. Outputs: parsed value or default.
|
|
|
|
|
/// Why: UVC freshness settings use millisecond durations that should not be
|
|
|
|
|
/// truncated to 32-bit just because the coverage harness is lightweight.
|
|
|
|
|
fn env_u64(name: &str, default: u64) -> u64 {
|
|
|
|
|
env::var(name)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
|
|
|
.unwrap_or(default)
|
|
|
|
|
}
|