lesavka/server/src/uvc_runtime.rs

242 lines
7.8 KiB
Rust

#![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<String> {
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<String> {
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<String> = 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<tokio::process::Child> {
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"
);
}
}