impl CameraCapture { /// Fuzzy‑match devices under `/dev/v4l/by-id`, preferring capture nodes #[cfg(not(coverage))] fn find_device(substr: &str) -> Option { let wanted = normalize_device_fragment(substr); let mut matches: Vec<_> = std::fs::read_dir("/dev/v4l/by-id") .ok()? .flatten() .filter_map(|e| { let p = e.path(); let name = normalize_device_fragment(&p.file_name()?.to_string_lossy()); if name.contains(&wanted) { Some(p) } else { None } }) .collect(); // deterministic order matches.sort(); for p in matches { if let Ok(target) = std::fs::read_link(&p) { let dev = format!("/dev/{}", target.file_name()?.to_string_lossy()); if Self::is_capture(&dev) { return Some(dev); } } } None } #[cfg(coverage)] fn find_device(substr: &str) -> Option { let wanted = normalize_device_fragment(substr); let by_id_dir = std::env::var("LESAVKA_CAM_BY_ID_DIR").unwrap_or_else(|_| "/dev/v4l/by-id".to_string()); let dev_root = std::env::var("LESAVKA_CAM_DEV_ROOT").unwrap_or_else(|_| "/dev".to_string()); let mut matches: Vec<_> = std::fs::read_dir(by_id_dir) .ok()? .flatten() .filter_map(|e| { let p = e.path(); let name = normalize_device_fragment(&p.file_name()?.to_string_lossy()); if name.contains(&wanted) { Some(p) } else { None } }) .collect(); matches.sort(); for p in matches { if let Ok(target) = std::fs::read_link(&p) { let dev = format!("{}/{}", dev_root, target.file_name()?.to_string_lossy()); if Self::is_capture(&dev) { return Some(dev); } } } None } #[cfg(not(coverage))] fn is_capture(dev: &str) -> bool { const V4L2_CAP_VIDEO_CAPTURE: u32 = 0x0000_0001; const V4L2_CAP_VIDEO_CAPTURE_MPLANE: u32 = 0x0000_1000; v4l::Device::with_path(dev) .ok() .and_then(|d| d.query_caps().ok()) .map(|caps| { let bits = caps.capabilities.bits(); (bits & V4L2_CAP_VIDEO_CAPTURE != 0) || (bits & V4L2_CAP_VIDEO_CAPTURE_MPLANE != 0) }) .unwrap_or(false) } #[cfg(coverage)] fn is_capture(dev: &str) -> bool { dev.starts_with("/dev/video") } /// Cheap stub used when the web‑cam is disabled pub fn new_stub() -> Self { let pipeline = gst::Pipeline::new(); let sink: gst_app::AppSink = gst::ElementFactory::make("appsink") .build() .expect("appsink") .downcast::() .unwrap(); Self { pipeline, sink, preview_tap_running: None, pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser::default(), frame_duration_us: 1, } } } fn normalize_device_fragment(value: &str) -> String { value .chars() .filter(|ch| ch.is_ascii_alphanumeric()) .flat_map(char::to_lowercase) .collect() }