diff --git a/Cargo.lock b/Cargo.lock index 15a4fcd..ec9cf3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.14.39" +version = "0.14.40" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.14.39" +version = "0.14.40" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.14.39" +version = "0.14.40" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 5522970..c9bcd8c 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.14.39" +version = "0.14.40" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 3d1dce1..dfa7c51 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.14.39" +version = "0.14.40" edition = "2024" build = "build.rs" diff --git a/scripts/daemon/lesavka-uvc.sh b/scripts/daemon/lesavka-uvc.sh index a4d3e10..4363b6d 100755 --- a/scripts/daemon/lesavka-uvc.sh +++ b/scripts/daemon/lesavka-uvc.sh @@ -8,9 +8,42 @@ if [[ -r /etc/lesavka/uvc.env ]]; then source /etc/lesavka/uvc.env fi -DEV=${LESAVKA_UVC_DEV:-/dev/v4l/by-path/platform-1000480000.usb-video-index0} -if [[ ! -e "$DEV" ]]; then - DEV=/dev/video0 +resolve_default_uvc_dev() { + local ctrl="" + ctrl=$(ls /sys/class/udc 2>/dev/null | head -n1 || true) + if [[ -n $ctrl ]]; then + printf '/dev/v4l/by-path/platform-%s-video-index0\n' "$ctrl" + return 0 + fi + local candidate="" + candidate=$(ls /dev/v4l/by-path/platform-*-video-index0 2>/dev/null | head -n1 || true) + [[ -n $candidate ]] || return 1 + printf '%s\n' "$candidate" +} + +wait_for_uvc_dev() { + local dev="$1" + for _ in {1..50}; do + [[ -e $dev ]] && return 0 + sleep 0.1 + done + return 1 +} + +if [[ -n ${LESAVKA_UVC_DEV:-} ]]; then + DEV=${LESAVKA_UVC_DEV} +else + DEV=$(resolve_default_uvc_dev || true) +fi + +if [[ -z ${DEV:-} ]]; then + echo "[lesavka-uvc] no gadget video_output node discovered; waiting for /dev/v4l/by-path/platform-*-video-index0 or set LESAVKA_UVC_DEV" >&2 + exit 1 +fi + +if ! wait_for_uvc_dev "$DEV"; then + echo "[lesavka-uvc] gadget video_output node is still absent: $DEV" >&2 + exit 1 fi exec /usr/local/bin/lesavka-uvc --device "$DEV" diff --git a/server/Cargo.toml b/server/Cargo.toml index 30dccc8..28b0735 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.14.39" +version = "0.14.40" edition = "2024" autobins = false diff --git a/server/src/uvc_runtime.rs b/server/src/uvc_runtime.rs index 4b45ce9..6a88030 100644 --- a/server/src/uvc_runtime.rs +++ b/server/src/uvc_runtime.rs @@ -13,6 +13,20 @@ fn uvc_by_path_root() -> String { std::env::var("LESAVKA_UVC_BY_PATH_ROOT").unwrap_or_else(|_| "/dev/v4l/by-path".to_string()) } +fn any_platform_uvc_by_path() -> Option { + let root = uvc_by_path_root(); + let root = Path::new(&root); + let entries = std::fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("platform-") && name.ends_with("-video-index0") { + return Some(entry.path().to_string_lossy().into_owned()); + } + } + None +} + /// Pick the UVC gadget video node. /// /// Inputs: none; the function inspects environment overrides and udev state. @@ -51,7 +65,10 @@ pub fn pick_uvc_device() -> anyhow::Result { } } - let mut fallback: Option = None; + if let Some(by_path) = any_platform_uvc_by_path() { + return Ok(by_path); + } + if std::env::var("LESAVKA_UVC_SKIP_UDEV").is_err() && let Ok(mut enumerator) = udev::Enumerator::new() { @@ -82,19 +99,12 @@ pub fn pick_uvc_device() -> anyhow::Result { { 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" + "no Lesavka video_output v4l2 node found; wait for /dev/v4l/by-path/platform--video-index0 or set LESAVKA_UVC_DEV" )) } diff --git a/testing/tests/server_uvc_runtime_contract.rs b/testing/tests/server_uvc_runtime_contract.rs index 7bb9810..433d802 100644 --- a/testing/tests/server_uvc_runtime_contract.rs +++ b/testing/tests/server_uvc_runtime_contract.rs @@ -149,6 +149,37 @@ fn pick_uvc_device_prefers_controller_by_path_override_root() { ); } +#[test] +#[serial] +fn pick_uvc_device_uses_platform_by_path_when_controller_is_unknown() { + let dir = tempdir().expect("tempdir"); + let sys_root = dir.path().join("sys"); + let by_path = dir.path().join("v4l/by-path"); + fs::create_dir_all(&sys_root).expect("create fake sys root"); + fs::create_dir_all(&by_path).expect("create fake by-path root"); + let expected = by_path.join("platform-fallback.usb-video-index0"); + fs::write(&expected, "").expect("touch by-path node"); + + temp_env::with_var( + "LESAVKA_GADGET_SYSFS_ROOT", + Some(sys_root.to_string_lossy().to_string()), + || { + temp_env::with_var( + "LESAVKA_UVC_BY_PATH_ROOT", + Some(by_path.to_string_lossy().to_string()), + || { + temp_env::with_var("LESAVKA_UVC_SKIP_UDEV", Some("1"), || { + temp_env::with_var("LESAVKA_UVC_DEV", None::<&str>, || { + let picked = pick_uvc_device().expect("pick platform by-path device"); + assert_eq!(picked, expected.to_string_lossy()); + }); + }); + }, + ); + }, + ); +} + #[test] #[serial] fn pick_uvc_device_errors_when_overrides_disable_all_discovery_paths() { @@ -169,7 +200,14 @@ fn pick_uvc_device_errors_when_overrides_disable_all_discovery_paths() { temp_env::with_var("LESAVKA_UVC_SKIP_UDEV", Some("1"), || { temp_env::with_var("LESAVKA_UVC_DEV", None::<&str>, || { let err = pick_uvc_device().expect_err("missing paths should error"); - assert!(err.to_string().contains("LESAVKA_UVC_DEV")); + assert!( + err.to_string().contains("LESAVKA_UVC_DEV"), + "error should mention explicit override escape hatch: {err:#}" + ); + assert!( + err.to_string().contains("platform--video-index0"), + "error should point operators at the gadget by-path node: {err:#}" + ); }); }); }, diff --git a/testing/tests/server_uvc_script_contract.rs b/testing/tests/server_uvc_script_contract.rs new file mode 100644 index 0000000..9d91d68 --- /dev/null +++ b/testing/tests/server_uvc_script_contract.rs @@ -0,0 +1,32 @@ +//! Contract tests for the standalone UVC launcher shell script. +//! +//! Scope: statically guard `scripts/daemon/lesavka-uvc.sh`. +//! Targets: gadget-node discovery and wrong-device avoidance. +//! Why: falling back to an unrelated `/dev/video0` can wedge the helper in +//! kernel space and poison later gadget recovery loops. + +const UVC_SCRIPT: &str = include_str!("../../scripts/daemon/lesavka-uvc.sh"); + +#[test] +fn uvc_script_waits_for_gadget_by_path_node() { + for expected in [ + "resolve_default_uvc_dev()", + "wait_for_uvc_dev()", + "platform-%s-video-index0", + "platform-*-video-index0", + "gadget video_output node is still absent", + ] { + assert!( + UVC_SCRIPT.contains(expected), + "lesavka-uvc launcher guard missing: {expected}" + ); + } +} + +#[test] +fn uvc_script_refuses_unrelated_video0_fallback() { + assert!( + !UVC_SCRIPT.contains("DEV=/dev/video0"), + "lesavka-uvc launcher must not fall back to an unrelated /dev/video0" + ); +}