openlogi-camera 0.6.26

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
//! Generic discovery of Logitech USB Video Class (UVC) webcams.
//!
//! Mice and keyboards speak Logitech's proprietary HID++ (over a Bolt/Unifying
//! receiver or directly) — see the `openlogi-hid` crate. Webcams don't: every
//! Logitech camera (StreamCam, Brio, C920, C922, C270, C930e, …) is a standard
//! UVC device and enumerates the same way. So detection keys off the USB vendor
//! id (`0x046d`) rather than any per-model quirk — plug in *any* Logitech
//! camera and it's recognised, with no model table to maintain.
//!
//! macOS has the full backend (AVFoundation capture + IOKit UVC controls);
//! Windows matches it with Media Foundation capture and DirectShow controls;
//! Linux uses V4L2 for both, through the kernel's `uvcvideo` driver. Other
//! platforms return an empty list.

use serde::Serialize;

mod controls;
pub use controls::{AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange};

mod capture_types;
pub use capture_types::{CaptureError, Frame};

#[cfg(target_os = "macos")]
mod macos;

#[cfg(target_os = "macos")]
mod capture;
#[cfg(target_os = "macos")]
pub use capture::{
    CameraStream, camera_access_granted, camera_authorization, capture_frame,
    request_camera_access, start_stream,
};

#[cfg(target_os = "windows")]
mod capture_windows;
#[cfg(target_os = "windows")]
pub use capture_windows::{
    CameraStream, camera_access_granted, camera_authorization, capture_frame,
    request_camera_access, start_stream,
};

#[cfg(target_os = "macos")]
mod uvc;
#[cfg(target_os = "macos")]
pub use uvc::{
    apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
};

#[cfg(target_os = "windows")]
mod uvc_windows;
#[cfg(target_os = "windows")]
pub use uvc_windows::{
    apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
};

#[cfg(target_os = "linux")]
mod linux;

#[cfg(target_os = "linux")]
mod capture_linux;
#[cfg(target_os = "linux")]
pub use capture_linux::{
    CameraStream, camera_access_granted, camera_authorization, capture_frame,
    request_camera_access, start_stream,
};

#[cfg(target_os = "linux")]
mod uvc_linux;
#[cfg(target_os = "linux")]
pub use uvc_linux::{
    apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
};

#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
mod capture {
    //! Stub capture backend for platforms without one.
    use std::sync::Arc;
    use std::time::Duration;

    use crate::capture_types::{CaptureError, Frame};

    /// Stub: returns [`CaptureError::Unsupported`] on this platform.
    pub fn capture_frame(_unique_id: &str, _timeout: Duration) -> Result<Frame, CaptureError> {
        Err(CaptureError::Unsupported)
    }

    /// Stub live stream (never yields a frame on this platform).
    pub struct CameraStream;

    impl CameraStream {
        #[must_use]
        pub fn latest_frame(&self) -> Option<Arc<Frame>> {
            None
        }

        #[must_use]
        pub fn take_frame(&self) -> Option<Arc<Frame>> {
            None
        }

        #[must_use]
        pub fn frame_generation(&self) -> u64 {
            0
        }
    }

    /// Stub: returns [`CaptureError::Unsupported`] on this platform.
    pub fn start_stream(_unique_id: &str) -> Result<CameraStream, CaptureError> {
        Err(CaptureError::Unsupported)
    }

    /// Stub: camera access is never granted on this platform.
    #[must_use]
    pub fn camera_access_granted() -> bool {
        false
    }

    /// Stub: camera permission is always undetermined on this platform.
    #[must_use]
    pub fn camera_authorization() -> crate::CameraAuthorization {
        crate::CameraAuthorization::Undetermined
    }

    /// Stub: no consent prompt exists on this platform.
    pub fn request_camera_access() {}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
pub use capture::{
    CameraStream, camera_access_granted, camera_authorization, capture_frame,
    request_camera_access, start_stream,
};

#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
mod uvc {
    //! Stub UVC control backend for platforms without one.
    use crate::controls::{AutoToggle, CameraControl, CameraState, ControlError, ControlRange};

    /// Stub: no UVC backend on this platform.
    pub fn control_range(_id: &str, _c: CameraControl) -> Result<ControlRange, ControlError> {
        Err(ControlError::Unsupported)
    }

    /// Stub: no UVC backend on this platform.
    pub fn control_ranges(_id: &str) -> Result<Vec<(CameraControl, ControlRange)>, ControlError> {
        Ok(Vec::new())
    }

    /// Stub: no UVC backend on this platform.
    pub fn read_camera_state(_id: &str) -> Result<CameraState, ControlError> {
        Ok(CameraState::default())
    }

    /// Stub: no UVC backend on this platform.
    pub fn set_control(_id: &str, _c: CameraControl, _value: i32) -> Result<(), ControlError> {
        Err(ControlError::Unsupported)
    }

    /// Stub: no UVC backend on this platform.
    pub fn set_auto(_id: &str, _t: AutoToggle, _on: bool) -> Result<(), ControlError> {
        Err(ControlError::Unsupported)
    }

    /// Stub: no UVC backend on this platform.
    pub fn apply_settings(
        _id: &str,
        _autos: &[(AutoToggle, bool)],
        _values: &[(CameraControl, i32)],
    ) -> Result<(), ControlError> {
        Err(ControlError::Unsupported)
    }
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
pub use uvc::{
    apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
};

/// Logitech's USB vendor id. Reported in decimal (`1133`) inside an
/// `AVCaptureDevice` modelID, and in hex (`046d`) most everywhere else.
pub const LOGITECH_VID: u16 = 0x046d;

/// Tri-state Camera permission, mirroring macOS `AVAuthorizationStatus`.
///
/// Only macOS has a consent model with a pending state. Linux decides access
/// by filesystem permission on the device node, so it reports `Granted` or
/// `Denied` but never `Undetermined`; platforms with no backend at all report
/// `Undetermined`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CameraAuthorization {
    /// The process may open cameras.
    Granted,
    /// The user denied access, or the system restricts it.
    Denied,
    /// Not yet requested — opening a camera will prompt.
    Undetermined,
}

/// A connected USB Video Class camera.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Camera {
    /// Human-readable name, e.g. `"Logitech StreamCam"`.
    pub name: String,
    /// OS capture-layer identifier (AVFoundation `uniqueID`, DirectShow device
    /// path). Used to open preview/controls; may embed a USB location and so
    /// change when the camera is moved to another port.
    pub unique_id: String,
    /// USB `iSerialNumber` when the device reports one. Port-stable; preferred
    /// for persisted config keys via [`Self::config_key`].
    pub serial_number: Option<String>,
    /// USB vendor id (`0x046d` for Logitech).
    pub vendor_id: u16,
    /// USB product id (e.g. `0x0893` for the StreamCam).
    pub product_id: u16,
    /// Largest supported frame size `(width, height)`, when the OS reports the
    /// device's formats. Read from metadata only — no capture, no permission.
    pub max_resolution: Option<(u32, u32)>,
    /// Highest supported frame rate (fps) across all formats, when known.
    pub max_fps: Option<u32>,
}

impl Camera {
    /// Persistence key that is stable across USB ports.
    ///
    /// Prefers the USB serial when the device reports one. When it doesn't,
    /// falls back to a model-scoped key (`camera:vid:pid`) so settings survive
    /// a port change. Two serial-less units of the same model share this key
    /// (no stronger USB identity); the GUI keeps them as separate live cards
    /// via the OS capture id, not via this settings key.
    #[must_use]
    pub fn config_key(&self) -> String {
        if let Some(serial) = self
            .serial_number
            .as_deref()
            .map(str::trim)
            .filter(|s| !s.is_empty())
        {
            format!(
                "camera:{:04x}:{:04x}:serial:{}",
                self.vendor_id,
                self.product_id,
                serial.to_ascii_lowercase()
            )
        } else {
            format!("camera:{:04x}:{:04x}", self.vendor_id, self.product_id)
        }
    }
}

/// Whether this platform has a live-capture backend (preview + snapshot).
/// Enumeration and UVC controls can be supported without it.
#[must_use]
pub const fn capture_supported() -> bool {
    cfg!(any(
        target_os = "macos",
        target_os = "windows",
        target_os = "linux"
    ))
}

/// Serializes UVC device seizes against enumeration within this process.
/// `USBDeviceOpenSeize` briefly detaches the camera's kernel driver, and an
/// enumeration racing that window sees no camera at all — which read as the
/// camera "disappearing" from the device list mid-slider-drag once
/// enumeration moved off the UI thread. Control paths hold this for the
/// seize's lifetime; enumeration takes it for the duration of the scan.
#[cfg(target_os = "macos")]
pub(crate) static USB_QUIESCE: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Enumerate every connected **Logitech** UVC camera.
///
/// Non-Logitech cameras (the built-in FaceTime camera, virtual cameras, other
/// vendors' webcams) are filtered out. Returns an empty list on platforms with
/// no capture backend, or when no Logitech camera is attached.
#[must_use]
pub fn enumerate_cameras() -> Vec<Camera> {
    enumerate_all()
        .into_iter()
        .filter(|camera| camera.vendor_id == LOGITECH_VID)
        .collect()
}

#[cfg(target_os = "macos")]
fn enumerate_all() -> Vec<Camera> {
    // Wait out any in-flight control seize so the scan can't land in the
    // window where the kernel driver is detached (poisoning is impossible —
    // holders never panic — but recover anyway rather than unwrap).
    let _quiesce = USB_QUIESCE
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    let serials = uvc::usb_serials_by_location();
    macos::enumerate()
        .iter()
        .filter_map(|raw| {
            let mut camera = Camera::from_raw(&raw.name, &raw.unique_id, &raw.model_id)?;
            if raw.max_width > 0 && raw.max_height > 0 {
                camera.max_resolution = Some((raw.max_width, raw.max_height));
            }
            if raw.max_fps > 0 {
                camera.max_fps = Some(raw.max_fps);
            }
            if let Some(location) = uvc::location_hint(&raw.unique_id) {
                camera.serial_number = serials.get(&location).cloned();
            }
            Some(camera)
        })
        .collect()
}

#[cfg(target_os = "windows")]
fn enumerate_all() -> Vec<Camera> {
    uvc_windows::enumerate()
}

#[cfg(target_os = "linux")]
fn enumerate_all() -> Vec<Camera> {
    linux::nodes().iter().map(linux::describe).collect()
}

#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
fn enumerate_all() -> Vec<Camera> {
    Vec::new()
}

#[cfg(any(test, target_os = "macos"))]
impl Camera {
    /// Build a [`Camera`] from an OS-reported `(name, unique_id, model_id)`.
    ///
    /// Returns `None` when `model_id` carries no USB vendor/product id — i.e.
    /// it isn't a real USB camera (the macOS FaceTime camera's modelID is just
    /// `"FaceTime HD Camera"`), so it can't be attributed to a vendor and is
    /// dropped before the Logitech filter even runs. Format fields start `None`;
    /// the platform backend fills them in.
    fn from_raw(name: &str, unique_id: &str, model_id: &str) -> Option<Self> {
        let (vendor_id, product_id) = parse_vid_pid(model_id)?;
        Some(Self {
            name: name.to_string(),
            unique_id: unique_id.to_string(),
            serial_number: None,
            vendor_id,
            product_id,
            max_resolution: None,
            max_fps: None,
        })
    }
}

/// Pull the USB vendor/product id out of an `AVCaptureDevice` modelID such as
/// `"UVC Camera VendorID_1133 ProductID_2195"`. Both ids are **decimal** in
/// that string (1133 == 0x046d, 2195 == 0x0893). `None` if either marker is
/// absent.
#[cfg(any(test, target_os = "macos"))]
fn parse_vid_pid(model_id: &str) -> Option<(u16, u16)> {
    let vendor_id = parse_marker(model_id, "VendorID_")?;
    let product_id = parse_marker(model_id, "ProductID_")?;
    Some((vendor_id, product_id))
}

/// Read the decimal number immediately following `marker` in `haystack`.
#[cfg(any(test, target_os = "macos"))]
fn parse_marker(haystack: &str, marker: &str) -> Option<u16> {
    let rest = haystack.split(marker).nth(1)?;
    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
    digits.parse().ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_logitech_streamcam_model_id() {
        assert_eq!(
            parse_vid_pid("UVC Camera VendorID_1133 ProductID_2195"),
            Some((0x046d, 0x0893))
        );
    }

    #[test]
    fn rejects_model_id_without_usb_ids() {
        assert_eq!(parse_vid_pid("FaceTime HD Camera"), None);
        assert_eq!(parse_vid_pid("VendorID_1133 only"), None);
    }

    #[test]
    fn from_raw_keeps_usb_cameras_and_drops_the_rest() {
        assert_eq!(
            Camera::from_raw(
                "Logitech StreamCam",
                "0x1123000046d0893",
                "UVC Camera VendorID_1133 ProductID_2195",
            ),
            Some(Camera {
                name: "Logitech StreamCam".to_string(),
                unique_id: "0x1123000046d0893".to_string(),
                serial_number: None,
                vendor_id: LOGITECH_VID,
                product_id: 0x0893,
                max_resolution: None,
                max_fps: None,
            })
        );
        assert_eq!(
            Camera::from_raw("FaceTime HD Camera", "uuid", "FaceTime HD Camera"),
            None
        );
    }

    #[test]
    fn config_key_prefers_usb_serial_over_capture_id() {
        let with_serial = Camera {
            name: "Logitech StreamCam".into(),
            unique_id: "0x1123000046d0893".into(),
            serial_number: Some("ABC123".into()),
            vendor_id: LOGITECH_VID,
            product_id: 0x0893,
            max_resolution: None,
            max_fps: None,
        };
        assert_eq!(with_serial.config_key(), "camera:046d:0893:serial:abc123");
        // Same physical camera on another USB port → same config key.
        let moved = Camera {
            unique_id: "0x14110000046d0893".into(),
            ..with_serial.clone()
        };
        assert_eq!(moved.config_key(), with_serial.config_key());

        let no_serial = Camera {
            serial_number: None,
            unique_id: "0x1123000046d0893".into(),
            ..with_serial.clone()
        };
        // Model-scoped — same key after a port change even without a serial.
        assert_eq!(no_serial.config_key(), "camera:046d:0893");
        let moved_no_serial = Camera {
            unique_id: "0x14110000046d0893".into(),
            ..no_serial
        };
        assert_eq!(moved_no_serial.config_key(), "camera:046d:0893");
    }
}