openlogi-camera 0.8.14

Generic Logitech UVC webcam discovery for OpenLogi (AVFoundation on macOS, DirectShow on Windows, V4L2 on Linux).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
//! V4L2 device discovery on Linux.
//!
//! A UVC camera exposes several `/dev/video*` nodes — one for capture, plus a
//! metadata node carrying UVC timing data. `VIDIOC_QUERYCAP`'s `capabilities`
//! field reports the *union* across the physical device's nodes, so it reads
//! `VIDEO_CAPTURE` on the metadata node too; the `v4l` crate doesn't surface
//! the per-node `device_caps`. Nodes are therefore classified by whether
//! `VIDIOC_ENUM_FMT` yields any capture format, which only the capture node
//! does.

use std::fs;
use std::path::{Path, PathBuf};

use v4l::frameinterval::FrameIntervalEnum;
use v4l::video::Capture;
use v4l::{Device, FourCC};

use crate::Camera;

/// Where the kernel lists V4L2 nodes, one directory per `/dev/video*`.
const SYSFS_V4L: &str = "/sys/class/video4linux";

/// Stable-by-serial symlink farm `udev` maintains for V4L2 nodes.
const BY_ID_DIR: &str = "/dev/v4l/by-id";

/// A discovered capture node and the USB identity behind it.
pub(crate) struct Node {
    pub(crate) path: PathBuf,
    pub(crate) name: String,
    pub(crate) vendor_id: u16,
    pub(crate) product_id: u16,
    /// USB `iSerialNumber` from sysfs when the device reports one.
    pub(crate) serial_number: Option<String>,
    /// Canonicalized sysfs directory of the *USB device* (not interface)
    /// behind this node — shared by every capture node the same physical
    /// device exposes. See [`cameras`].
    usb_device: PathBuf,
}

/// Enumerate every V4L2 capture node, newest-first by node index.
///
/// Non-USB devices (virtual cameras, loopback nodes) have no `idVendor` in
/// sysfs and are skipped — they can't be attributed to a vendor, so the
/// Logitech filter in [`crate::enumerate_cameras`] couldn't judge them anyway.
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
}

/// Resolve a [`Camera::unique_id`] back to the `/dev/video*` node it names.
///
/// Ids are `by-id` symlinks when udev provides one, so this canonicalizes
/// before comparing — a `by-id` path and its `/dev/videoN` target must resolve
/// to the same node.
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)
}

/// Build the [`Camera`] view of a node, including its largest frame size and
/// highest frame rate. Format probing is metadata-only — `VIDIOC_ENUM_*`
/// never starts a stream, so this costs no LED and needs no permission beyond
/// opening the node.
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,
    }
}

/// One [`Camera`] per physical USB device.
///
/// A UVC camera can expose more than one *streaming* interface — e.g. the
/// Brio's second, low-resolution node feeding its IR sensor for Windows
/// Hello — and each gets its own `/dev/videoN` capture node just like the
/// main color sensor does (issue #1191). Grouping by the shared USB device
/// directory and keeping only the highest-resolution node per group turns
/// that back into one listing entry per physical camera; `node_for_unique_id`
/// still resolves every node individually, so a secondary node stays
/// controllable if some other path ever needs it.
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)
}

/// How strongly a node's pixel formats mark it as the primary color sensor:
/// a confirmed color node (`Some(true)`) beats one whose formats couldn't be
/// read (`None`), which beats a confirmed monochrome IR/depth node
/// (`Some(false)`). An enumeration failure therefore never hands the listing
/// to a node we *know* is IR-only.
fn color_rank(color: Option<bool>) -> u8 {
    match color {
        Some(true) => 2,
        None => 1,
        Some(false) => 0,
    }
}

/// Collapse `(usb_device, Camera, color)` triples to one `Camera` per
/// distinct `usb_device`, otherwise preserving first-seen order.
///
/// Color capability (see [`color_rank`]) decides first, so a mono-only IR
/// node can never become the capture/control target while a sibling that is
/// (or may be) the color sensor exists — regardless of `/dev/videoN` order
/// or of either node's format enumeration failing.
///
/// Within the same color rank, resolution decides only when both sides are
/// known: `None` means stepwise/continuous sizes or a failed enumeration,
/// i.e. *unknown*, not "0x0". When nothing decides, the first-seen node
/// keeps its place.
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()
}

/// Whether `device` reports at least one pixel format that isn't a known
/// monochrome-only V4L2 format; `None` when the format list can't be read.
///
/// UVC webcams with a secondary IR/depth sensor (e.g. the Brio's Windows
/// Hello node, issue #1191) expose it as a plain capture node just like the
/// primary color sensor. IR sensors report single-channel formats
/// (`GREY`/`Y8`, `Y10`, `Y12`, `Y16`) where the color sensor reports
/// YUV/RGB/compressed formats.
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)),
    )
}

/// Whether `fourcc` names a known monochrome-only V4L2 pixel format (as
/// opposed to a YUV/RGB/Bayer/compressed one carrying color information).
fn is_monochrome_fourcc(fourcc: FourCC) -> bool {
    matches!(
        &fourcc.repr,
        b"GREY" | b"Y8  " | b"Y10 " | b"Y12 " | b"Y16 "
    )
}

/// Pixel count of a resolution, for comparing which capture node is the
/// primary sensor.
fn resolution_area(resolution: (u32, u32)) -> u64 {
    let (w, h) = resolution;
    u64::from(w) * u64::from(h)
}

/// The `by-id` symlink for `path` when udev created one (it embeds the USB
/// serial, so it survives replugging into another port), else the raw node
/// path. Either way it round-trips through [`node_for_unique_id`].
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()
}

/// Read `idVendor`/`idProduct` from the USB device directory.
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()?,
    ))
}

/// USB `iSerialNumber` from the USB device directory, when present and
/// non-empty.
fn usb_serial(usb: &Path) -> Option<String> {
    let serial = read_trimmed(&usb.join("serial"))?;
    let serial = serial.trim();
    // Kernel placeholder when the descriptor has no iSerialNumber.
    if serial.is_empty() || serial == "0" {
        return None;
    }
    Some(serial.to_string())
}

/// Sysfs directory of the USB *device* behind a V4L2 node (parent of the
/// interface entry at `<sysfs>/device`).
fn usb_device_sysfs(sysfs: &Path) -> Option<PathBuf> {
    fs::canonicalize(sysfs.join("device").join("..")).ok()
}

/// Whether the node serves video capture, i.e. enumerates at least one capture
/// format. Metadata nodes open fine but enumerate none.
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())
}

/// Largest frame size across all formats, and the highest frame rate offered
/// at any size. Both are `None` when the driver reports only stepwise or
/// continuous ranges, which carry no single "max" worth showing.
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)
}

/// Highest discrete frame rate the driver offers for one format and size.
///
/// Intervals are periods (seconds per frame), so the highest rate is the
/// smallest interval. Stepwise/continuous ranges are skipped — they describe a
/// span rather than an offered rate — as are zero-numerator entries, which
/// would divide by zero.
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()
}

/// Read a sysfs attribute, trimming the trailing newline the kernel appends.
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() {
        // Reproduces issue #1191: the Brio's two capture-capable /dev/videoN
        // nodes (main sensor + IR sensor for Windows Hello) share one USB
        // device directory and must collapse to a single listing entry.
        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() {
        // Reproduces the failure mode from the #1234 review: if the primary
        // node only reports stepwise/continuous frame sizes (or enumeration
        // fails), `max_resolution` is `None`, not "0x0". It must not be
        // outranked by a sibling IR/secondary node just because that node
        // happens to report a small discrete size.
        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() {
        // Reproduces the second #1234 review finding: when both the color
        // and IR node have unknown resolution, `/dev/videoN` enumeration
        // order is not a reliable tiebreaker — an IR node such as
        // `/dev/video10` can sort before its sibling color node
        // `/dev/video2` (nodes() sorts lexicographically by path, and "1" <
        // "2"). The IR node here is first-seen and would win under plain
        // "first-seen wins", but `is_color` must override that.
        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() {
        // #1234 review: if the color node's format enumeration fails, a
        // known-mono IR node with a discrete size must not take over.
        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]);
    }
}