use std::fs;
use std::path::{Path, PathBuf};
use v4l::frameinterval::FrameIntervalEnum;
use v4l::video::Capture;
use v4l::{Device, FourCC};
use crate::Camera;
const SYSFS_V4L: &str = "/sys/class/video4linux";
const BY_ID_DIR: &str = "/dev/v4l/by-id";
pub(crate) struct Node {
pub(crate) path: PathBuf,
pub(crate) name: String,
pub(crate) vendor_id: u16,
pub(crate) product_id: u16,
pub(crate) serial_number: Option<String>,
usb_device: PathBuf,
}
pub(crate) fn nodes() -> Vec<Node> {
let Ok(entries) = fs::read_dir(SYSFS_V4L) else {
return Vec::new();
};
let mut nodes: Vec<Node> = entries
.flatten()
.filter_map(|entry| {
let sysfs = entry.path();
let dev_path = PathBuf::from("/dev").join(entry.file_name());
let usb_device = usb_device_sysfs(&sysfs)?;
let (vendor_id, product_id) = usb_ids(&usb_device)?;
if !is_capture_node(&dev_path) {
return None;
}
Some(Node {
name: read_trimmed(&sysfs.join("name"))
.unwrap_or_else(|| dev_path.display().to_string()),
path: dev_path,
vendor_id,
product_id,
serial_number: usb_serial(&usb_device),
usb_device,
})
})
.collect();
nodes.sort_by(|a, b| a.path.cmp(&b.path));
nodes
}
pub(crate) fn node_for_unique_id(unique_id: &str) -> Option<PathBuf> {
let target = fs::canonicalize(unique_id).ok()?;
nodes()
.into_iter()
.find(|node| fs::canonicalize(&node.path).is_ok_and(|p| p == target))
.map(|node| node.path)
}
pub(crate) fn describe(node: &Node) -> Camera {
let (max_resolution, max_fps) =
Device::with_path(&node.path).map_or((None, None), |device| max_format(&device));
Camera {
name: node.name.clone(),
unique_id: unique_id_for(&node.path),
serial_number: node.serial_number.clone(),
vendor_id: node.vendor_id,
product_id: node.product_id,
max_resolution,
max_fps,
}
}
pub(crate) fn cameras() -> Vec<Camera> {
let described = nodes().into_iter().map(|node| {
let color = Device::with_path(&node.path)
.ok()
.and_then(|device| color_capability(&device));
(node.usb_device.clone(), describe(&node), color)
});
merge_by_usb_device(described)
}
fn color_rank(color: Option<bool>) -> u8 {
match color {
Some(true) => 2,
None => 1,
Some(false) => 0,
}
}
fn merge_by_usb_device(
nodes: impl IntoIterator<Item = (PathBuf, Camera, Option<bool>)>,
) -> Vec<Camera> {
let mut by_device: Vec<(PathBuf, Camera, Option<bool>)> = Vec::new();
for (usb_device, camera, color) in nodes {
match by_device.iter_mut().find(|(dev, _, _)| *dev == usb_device) {
Some((_, best, best_color)) => {
let candidate_wins = match color_rank(color).cmp(&color_rank(*best_color)) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => {
match (camera.max_resolution, best.max_resolution) {
(Some(candidate), Some(current)) => {
resolution_area(candidate) > resolution_area(current)
}
_ => false,
}
}
};
if candidate_wins {
*best = camera;
*best_color = color;
}
}
None => by_device.push((usb_device, camera, color)),
}
}
by_device.into_iter().map(|(_, camera, _)| camera).collect()
}
fn color_capability(device: &Device) -> Option<bool> {
let formats = device.enum_formats().ok()?;
if formats.is_empty() {
return None;
}
Some(
formats
.iter()
.any(|format| !is_monochrome_fourcc(format.fourcc)),
)
}
fn is_monochrome_fourcc(fourcc: FourCC) -> bool {
matches!(
&fourcc.repr,
b"GREY" | b"Y8 " | b"Y10 " | b"Y12 " | b"Y16 "
)
}
fn resolution_area(resolution: (u32, u32)) -> u64 {
let (w, h) = resolution;
u64::from(w) * u64::from(h)
}
fn unique_id_for(path: &Path) -> String {
let canonical = fs::canonicalize(path).ok();
let by_id = fs::read_dir(BY_ID_DIR).ok().and_then(|entries| {
entries
.flatten()
.map(|entry| entry.path())
.find(|link| fs::canonicalize(link).ok() == canonical)
});
by_id
.unwrap_or_else(|| path.to_path_buf())
.display()
.to_string()
}
fn usb_ids(usb: &Path) -> Option<(u16, u16)> {
let vendor = read_trimmed(&usb.join("idVendor"))?;
let product = read_trimmed(&usb.join("idProduct"))?;
Some((
u16::from_str_radix(&vendor, 16).ok()?,
u16::from_str_radix(&product, 16).ok()?,
))
}
fn usb_serial(usb: &Path) -> Option<String> {
let serial = read_trimmed(&usb.join("serial"))?;
let serial = serial.trim();
if serial.is_empty() || serial == "0" {
return None;
}
Some(serial.to_string())
}
fn usb_device_sysfs(sysfs: &Path) -> Option<PathBuf> {
fs::canonicalize(sysfs.join("device").join("..")).ok()
}
fn is_capture_node(dev_path: &Path) -> bool {
Device::with_path(dev_path)
.and_then(|device| device.enum_formats())
.is_ok_and(|formats| !formats.is_empty())
}
fn max_format(device: &Device) -> (Option<(u32, u32)>, Option<u32>) {
let Ok(formats) = device.enum_formats() else {
return (None, None);
};
let mut max_resolution: Option<(u32, u32)> = None;
let mut max_fps: Option<u32> = None;
for format in formats {
let Ok(sizes) = device.enum_framesizes(format.fourcc) else {
continue;
};
for size in sizes {
for discrete in size.size.to_discrete() {
let candidate = (discrete.width, discrete.height);
if max_resolution.is_none_or(|(w, h)| {
u64::from(candidate.0) * u64::from(candidate.1) > u64::from(w) * u64::from(h)
}) {
max_resolution = Some(candidate);
}
if let Some(fps) = max_discrete_fps(device, format.fourcc, candidate) {
max_fps = Some(max_fps.map_or(fps, |best: u32| best.max(fps)));
}
}
}
}
(max_resolution, max_fps)
}
fn max_discrete_fps(device: &Device, fourcc: FourCC, size: (u32, u32)) -> Option<u32> {
let intervals = device.enum_frameintervals(fourcc, size.0, size.1).ok()?;
intervals
.into_iter()
.filter_map(|interval| match interval.interval {
FrameIntervalEnum::Discrete(fraction) if fraction.numerator > 0 => {
Some(fraction.denominator / fraction.numerator)
}
_ => None,
})
.max()
}
fn read_trimmed(path: &Path) -> Option<String> {
fs::read_to_string(path)
.ok()
.map(|text| text.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn camera(name: &str, max_resolution: Option<(u32, u32)>) -> Camera {
Camera {
name: name.to_string(),
unique_id: name.to_string(),
serial_number: Some("5091F273".to_string()),
vendor_id: 0x046d,
product_id: 0x085e,
max_resolution,
max_fps: None,
}
}
#[test]
fn brio_ir_node_collapses_into_the_main_capture_node() {
let usb_device = PathBuf::from("/sys/devices/usb1/1-1");
let main = camera("Logitech BRIO", Some((4096, 2160)));
let ir = camera("Logitech BRIO", Some((340, 340)));
let cameras = merge_by_usb_device([
(usb_device.clone(), main.clone(), Some(true)),
(usb_device, ir, Some(false)),
]);
assert_eq!(cameras, vec![main]);
}
#[test]
fn distinct_usb_devices_stay_separate() {
let one = camera("Logitech BRIO", Some((4096, 2160)));
let two = camera("Logitech StreamCam", Some((1920, 1080)));
let cameras = merge_by_usb_device([
(
PathBuf::from("/sys/devices/usb1/1-1"),
one.clone(),
Some(true),
),
(
PathBuf::from("/sys/devices/usb1/1-2"),
two.clone(),
Some(true),
),
]);
assert_eq!(cameras, vec![one, two]);
}
#[test]
fn resolution_area_compares_pixel_counts() {
assert_eq!(resolution_area((340, 340)), 340 * 340);
assert!(resolution_area((4096, 2160)) > resolution_area((340, 340)));
}
#[test]
fn unknown_resolution_does_not_lose_to_a_known_smaller_node() {
let usb_device = PathBuf::from("/sys/devices/usb1/1-1");
let primary_unknown = camera("Logitech BRIO", None);
let ir = camera("Logitech BRIO", Some((340, 340)));
let cameras = merge_by_usb_device([
(usb_device.clone(), primary_unknown.clone(), Some(true)),
(usb_device, ir, Some(false)),
]);
assert_eq!(cameras, vec![primary_unknown]);
}
#[test]
fn color_node_wins_over_an_ir_node_that_enumerates_first() {
let usb_device = PathBuf::from("/sys/devices/usb1/1-1");
let ir_seen_first = camera("video10-ir", None);
let color_seen_second = camera("video2-color", None);
let cameras = merge_by_usb_device([
(usb_device.clone(), ir_seen_first, Some(false)),
(usb_device, color_seen_second.clone(), Some(true)),
]);
assert_eq!(cameras, vec![color_seen_second]);
}
#[test]
fn is_monochrome_fourcc_recognizes_known_ir_formats() {
for code in [b"GREY", b"Y8 ", b"Y10 ", b"Y12 ", b"Y16 "] {
assert!(is_monochrome_fourcc(FourCC::new(code)));
}
for code in [b"YUYV", b"MJPG", b"NV12"] {
assert!(!is_monochrome_fourcc(FourCC::new(code)));
}
}
#[test]
fn unreadable_formats_still_beat_a_known_ir_node() {
let usb_device = PathBuf::from("/sys/devices/usb1/1-1");
let ir_seen_first = camera("video10-ir", Some((340, 340)));
let color_unreadable = camera("video2-color", None);
let cameras = merge_by_usb_device([
(usb_device.clone(), ir_seen_first, Some(false)),
(usb_device, color_unreadable.clone(), None),
]);
assert_eq!(cameras, vec![color_unreadable]);
}
#[test]
fn color_node_beats_larger_ir_node() {
let usb_device = PathBuf::from("/sys/devices/usb1/1-1");
let ir = camera("ir", Some((4096, 2160)));
let color = camera("color", Some((1920, 1080)));
let cameras = merge_by_usb_device([
(usb_device.clone(), ir, Some(false)),
(usb_device, color.clone(), Some(true)),
]);
assert_eq!(cameras, vec![color]);
}
}