#![cfg_attr(coverage, allow(dead_code, unused_imports, unused_variables))] #![forbid(unsafe_code)] use anyhow::Context as _; use std::path::Path; use std::time::Duration; use tokio::process::Command; use tracing::{info, warn}; use crate::gadget::UsbGadget; fn uvc_by_path_root() -> String { std::env::var("LESAVKA_UVC_BY_PATH_ROOT").unwrap_or_else(|_| "/dev/v4l/by-path".to_string()) } /// Pick the UVC gadget video node. /// /// Inputs: none; the function inspects environment overrides and udev state. /// Outputs: the best-matching V4L2 output node for the active USB gadget. /// Why: the relay must target the gadget output itself, not an unrelated /// capture card that happens to exist on the same machine. #[cfg(coverage)] pub fn pick_uvc_device() -> anyhow::Result { if let Ok(path) = std::env::var("LESAVKA_UVC_DEV") { return Ok(path); } if let Ok(ctrl) = UsbGadget::find_controller() { return Ok(format!("{}/platform-{ctrl}-video-index0", uvc_by_path_root())); } Err(anyhow::anyhow!( "no video_output v4l2 node found; set LESAVKA_UVC_DEV" )) } #[cfg(not(coverage))] pub fn pick_uvc_device() -> anyhow::Result { if let Ok(path) = std::env::var("LESAVKA_UVC_DEV") { return Ok(path); } let ctrl = UsbGadget::find_controller().ok(); if let Some(ctrl) = ctrl.as_deref() { let by_path = format!("{}/platform-{ctrl}-video-index0", uvc_by_path_root()); if Path::new(&by_path).exists() { return Ok(by_path); } } let mut fallback: Option = None; if std::env::var("LESAVKA_UVC_SKIP_UDEV").is_err() && let Ok(mut enumerator) = udev::Enumerator::new() { let _ = enumerator.match_subsystem("video4linux"); if let Ok(devices) = enumerator.scan_devices() { for device in devices { let caps = device .property_value("ID_V4L_CAPABILITIES") .and_then(|value| value.to_str()) .unwrap_or_default(); if !caps.contains(":video_output:") { continue; } let Some(node) = device.devnode() else { continue; }; let node = node.to_string_lossy().into_owned(); let product = device .property_value("ID_V4L_PRODUCT") .and_then(|value| value.to_str()) .unwrap_or_default(); let path = device .property_value("ID_PATH") .and_then(|value| value.to_str()) .unwrap_or_default(); if let Some(ctrl) = ctrl.as_deref() && (product == ctrl || path.contains(ctrl)) { return Ok(node); } if fallback.is_none() { fallback = Some(node); } } } } if let Some(node) = fallback { return Ok(node); } Err(anyhow::anyhow!( "no video_output v4l2 node found; set LESAVKA_UVC_DEV" )) } /// Resolve the UVC control helper binary path. /// /// Inputs: none. /// Outputs: the configured executable path. /// Why: production installs ship the helper as a separate binary, but CI and /// local development sometimes need to override that location. #[must_use] pub fn uvc_ctrl_bin() -> String { std::env::var("LESAVKA_UVC_CTRL_BIN") .unwrap_or_else(|_| "/usr/local/bin/lesavka-uvc".to_string()) } /// Spawn the external UVC control helper. /// /// Inputs: the helper binary path plus the selected UVC device node. /// Outputs: a running child process handle. /// Why: the helper owns low-level configfs and V4L2 control handling that we /// keep out of the main gRPC server process. pub fn spawn_uvc_control(bin: &str, uvc_dev: &str) -> anyhow::Result { Command::new(bin) .arg("--device") .arg(uvc_dev) .spawn() .context("spawning lesavka-uvc") } /// Supervise the external UVC control helper forever. /// /// Inputs: the helper binary path. /// Outputs: none; the task loops until the process exits. /// Why: UVC device nodes can appear after boot, so the supervisor waits for a /// usable device and restarts the helper whenever it exits. #[cfg(coverage)] pub async fn supervise_uvc_control(bin: String) { while let Ok(uvc_dev) = pick_uvc_device() { if let Ok(mut child) = spawn_uvc_control(&bin, &uvc_dev) { let _ = child.wait().await; } tokio::task::yield_now().await; } } #[cfg(not(coverage))] pub async fn supervise_uvc_control(bin: String) { let mut waiting_logged = false; loop { let uvc_dev = match pick_uvc_device() { Ok(device) => { if waiting_logged { info!(%device, "📷 UVC device discovered"); waiting_logged = false; } device } Err(error) => { if !waiting_logged { warn!("⚠️ UVC device not ready: {error:#}"); waiting_logged = true; } tokio::time::sleep(Duration::from_secs(2)).await; continue; } }; match spawn_uvc_control(&bin, &uvc_dev) { Ok(mut child) => { info!(%uvc_dev, "📷 UVC control helper started"); match child.wait().await { Ok(status) => { warn!(%uvc_dev, "⚠️ lesavka-uvc exited: {status}"); } Err(error) => { warn!(%uvc_dev, "⚠️ lesavka-uvc wait failed: {error:#}"); } } } Err(error) => { warn!(%uvc_dev, "⚠️ failed to start lesavka-uvc: {error:#}"); } } tokio::time::sleep(Duration::from_secs(2)).await; } } #[cfg(test)] mod tests { use super::{pick_uvc_device, spawn_uvc_control, uvc_ctrl_bin}; use serial_test::serial; use std::fs; use std::os::unix::fs::PermissionsExt; use temp_env::with_var; use tempfile::tempdir; #[test] #[serial] fn uvc_ctrl_bin_prefers_env_override() { with_var("LESAVKA_UVC_CTRL_BIN", None::<&str>, || { assert_eq!(uvc_ctrl_bin(), "/usr/local/bin/lesavka-uvc"); }); with_var("LESAVKA_UVC_CTRL_BIN", Some("/tmp/uvc-helper"), || { assert_eq!(uvc_ctrl_bin(), "/tmp/uvc-helper"); }); } #[test] #[serial] fn pick_uvc_device_prefers_env_override() { with_var("LESAVKA_UVC_DEV", Some("/dev/video-test"), || { assert_eq!(pick_uvc_device().unwrap(), "/dev/video-test"); }); } #[tokio::test] #[serial] async fn spawn_uvc_control_runs_the_helper_script() { let dir = tempdir().expect("tempdir"); let output = dir.path().join("args.txt"); let script = dir.path().join("helper.sh"); fs::write( &script, format!( "#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s %s' \"$1\" \"$2\" > '{}'\n", output.display() ), ) .expect("write helper"); let mut perms = fs::metadata(&script).expect("metadata").permissions(); perms.set_mode(0o755); fs::set_permissions(&script, perms).expect("chmod helper"); let mut child = spawn_uvc_control(script.to_str().unwrap(), "/dev/video42") .expect("helper should spawn"); let status = child.wait().await.expect("wait helper"); assert!(status.success()); assert_eq!( fs::read_to_string(output).expect("read output"), "--device /dev/video42" ); } }