Skip to main content

openlogi_camera/
lib.rs

1//! Generic discovery of Logitech USB Video Class (UVC) webcams.
2//!
3//! Mice and keyboards speak Logitech's proprietary HID++ (over a Bolt/Unifying
4//! receiver or directly) — see the `openlogi-hid` crate. Webcams don't: every
5//! Logitech camera (StreamCam, Brio, C920, C922, C270, C930e, …) is a standard
6//! UVC device and enumerates the same way. So detection keys off the USB vendor
7//! id (`0x046d`) rather than any per-model quirk — plug in *any* Logitech
8//! camera and it's recognised, with no model table to maintain.
9//!
10//! macOS has the full backend (AVFoundation capture + IOKit UVC controls);
11//! Windows matches it with Media Foundation capture and DirectShow controls;
12//! Linux uses V4L2 for both, through the kernel's `uvcvideo` driver. Other
13//! platforms return an empty list.
14
15use serde::Serialize;
16
17mod controls;
18pub use controls::{AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange};
19
20mod capture_types;
21pub use capture_types::{CaptureError, Frame};
22
23#[cfg(target_os = "macos")]
24mod macos;
25
26#[cfg(target_os = "macos")]
27mod capture;
28#[cfg(target_os = "macos")]
29pub use capture::{
30    CameraStream, camera_access_granted, camera_authorization, capture_frame,
31    request_camera_access, start_stream,
32};
33
34#[cfg(target_os = "windows")]
35mod com_windows;
36
37#[cfg(target_os = "windows")]
38mod capture_windows;
39#[cfg(target_os = "windows")]
40pub use capture_windows::{
41    CameraStream, camera_access_granted, camera_authorization, capture_frame,
42    request_camera_access, start_stream,
43};
44
45#[cfg(target_os = "macos")]
46mod uvc;
47#[cfg(target_os = "macos")]
48pub use uvc::{
49    apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
50};
51
52#[cfg(target_os = "windows")]
53mod uvc_windows;
54#[cfg(target_os = "windows")]
55pub use uvc_windows::{
56    apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
57};
58
59#[cfg(target_os = "linux")]
60mod linux;
61
62#[cfg(target_os = "linux")]
63mod capture_linux;
64#[cfg(target_os = "linux")]
65pub use capture_linux::{
66    CameraStream, camera_access_granted, camera_authorization, capture_frame,
67    request_camera_access, start_stream,
68};
69
70#[cfg(target_os = "linux")]
71mod uvc_linux;
72#[cfg(target_os = "linux")]
73pub use uvc_linux::{
74    apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
75};
76
77#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
78mod capture {
79    //! Stub capture backend for platforms without one.
80    use std::sync::Arc;
81    use std::time::Duration;
82
83    use crate::capture_types::{CaptureError, Frame};
84
85    /// Stub: returns [`CaptureError::Unsupported`] on this platform.
86    pub fn capture_frame(_unique_id: &str, _timeout: Duration) -> Result<Frame, CaptureError> {
87        Err(CaptureError::Unsupported)
88    }
89
90    /// Stub live stream (never yields a frame on this platform).
91    pub struct CameraStream;
92
93    impl CameraStream {
94        #[must_use]
95        pub fn latest_frame(&self) -> Option<Arc<Frame>> {
96            None
97        }
98
99        #[must_use]
100        pub fn take_frame(&self) -> Option<Arc<Frame>> {
101            None
102        }
103
104        #[must_use]
105        pub fn frame_generation(&self) -> u64 {
106            0
107        }
108    }
109
110    /// Stub: returns [`CaptureError::Unsupported`] on this platform.
111    pub fn start_stream(_unique_id: &str) -> Result<CameraStream, CaptureError> {
112        Err(CaptureError::Unsupported)
113    }
114
115    /// Stub: camera access is never granted on this platform.
116    #[must_use]
117    pub fn camera_access_granted() -> bool {
118        false
119    }
120
121    /// Stub: camera permission is always undetermined on this platform.
122    #[must_use]
123    pub fn camera_authorization() -> crate::CameraAuthorization {
124        crate::CameraAuthorization::Undetermined
125    }
126
127    /// Stub: no consent prompt exists on this platform.
128    pub fn request_camera_access() {}
129}
130#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
131pub use capture::{
132    CameraStream, camera_access_granted, camera_authorization, capture_frame,
133    request_camera_access, start_stream,
134};
135
136#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
137mod uvc {
138    //! Stub UVC control backend for platforms without one.
139    use crate::controls::{AutoToggle, CameraControl, CameraState, ControlError, ControlRange};
140
141    /// Stub: no UVC backend on this platform.
142    pub fn control_range(_id: &str, _c: CameraControl) -> Result<ControlRange, ControlError> {
143        Err(ControlError::Unsupported)
144    }
145
146    /// Stub: no UVC backend on this platform.
147    pub fn control_ranges(_id: &str) -> Result<Vec<(CameraControl, ControlRange)>, ControlError> {
148        Ok(Vec::new())
149    }
150
151    /// Stub: no UVC backend on this platform.
152    pub fn read_camera_state(_id: &str) -> Result<CameraState, ControlError> {
153        Ok(CameraState::default())
154    }
155
156    /// Stub: no UVC backend on this platform.
157    pub fn set_control(_id: &str, _c: CameraControl, _value: i32) -> Result<(), ControlError> {
158        Err(ControlError::Unsupported)
159    }
160
161    /// Stub: no UVC backend on this platform.
162    pub fn set_auto(_id: &str, _t: AutoToggle, _on: bool) -> Result<(), ControlError> {
163        Err(ControlError::Unsupported)
164    }
165
166    /// Stub: no UVC backend on this platform.
167    pub fn apply_settings(
168        _id: &str,
169        _autos: &[(AutoToggle, bool)],
170        _values: &[(CameraControl, i32)],
171    ) -> Result<(), ControlError> {
172        Err(ControlError::Unsupported)
173    }
174}
175#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
176pub use uvc::{
177    apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
178};
179
180/// Logitech's USB vendor id. Reported in decimal (`1133`) inside an
181/// `AVCaptureDevice` modelID, and in hex (`046d`) most everywhere else.
182pub const LOGITECH_VID: u16 = 0x046d;
183
184/// Tri-state Camera permission, mirroring macOS `AVAuthorizationStatus`.
185///
186/// Only macOS has a consent model with a pending state. Linux decides access
187/// by filesystem permission on the device node, so it reports `Granted` or
188/// `Denied` but never `Undetermined`; platforms with no backend at all report
189/// `Undetermined`.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum CameraAuthorization {
192    /// The process may open cameras.
193    Granted,
194    /// The user denied access, or the system restricts it.
195    Denied,
196    /// Not yet requested — opening a camera will prompt.
197    Undetermined,
198}
199
200/// A connected USB Video Class camera.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
202pub struct Camera {
203    /// Human-readable name, e.g. `"Logitech StreamCam"`.
204    pub name: String,
205    /// OS capture-layer identifier (AVFoundation `uniqueID`, DirectShow device
206    /// path). Used to open preview/controls; may embed a USB location and so
207    /// change when the camera is moved to another port.
208    pub unique_id: String,
209    /// USB `iSerialNumber` when the device reports one. Port-stable; preferred
210    /// for persisted config keys via [`Self::config_key`].
211    pub serial_number: Option<String>,
212    /// USB vendor id (`0x046d` for Logitech).
213    pub vendor_id: u16,
214    /// USB product id (e.g. `0x0893` for the StreamCam).
215    pub product_id: u16,
216    /// Largest supported frame size `(width, height)`, when the OS reports the
217    /// device's formats. Read from metadata only — no capture, no permission.
218    pub max_resolution: Option<(u32, u32)>,
219    /// Highest supported frame rate (fps) across all formats, when known.
220    pub max_fps: Option<u32>,
221}
222
223impl Camera {
224    /// Persistence key that is stable across USB ports.
225    ///
226    /// Prefers the USB serial when the device reports one. When it doesn't,
227    /// falls back to a model-scoped key (`camera:vid:pid`) so settings survive
228    /// a port change. Two serial-less units of the same model share this key
229    /// (no stronger USB identity); the GUI keeps them as separate live cards
230    /// via the OS capture id, not via this settings key.
231    #[must_use]
232    pub fn config_key(&self) -> String {
233        if let Some(serial) = self
234            .serial_number
235            .as_deref()
236            .map(str::trim)
237            .filter(|s| !s.is_empty())
238        {
239            format!(
240                "camera:{:04x}:{:04x}:serial:{}",
241                self.vendor_id,
242                self.product_id,
243                serial.to_ascii_lowercase()
244            )
245        } else {
246            format!("camera:{:04x}:{:04x}", self.vendor_id, self.product_id)
247        }
248    }
249}
250
251/// Whether this platform has a live-capture backend (preview + snapshot).
252/// Enumeration and UVC controls can be supported without it.
253#[must_use]
254pub const fn capture_supported() -> bool {
255    cfg!(any(
256        target_os = "macos",
257        target_os = "windows",
258        target_os = "linux"
259    ))
260}
261
262/// Serializes UVC device seizes against enumeration within this process.
263/// `USBDeviceOpenSeize` briefly detaches the camera's kernel driver, and an
264/// enumeration racing that window sees no camera at all — which read as the
265/// camera "disappearing" from the device list mid-slider-drag once
266/// enumeration moved off the UI thread. Control paths hold this for the
267/// seize's lifetime; enumeration takes it for the duration of the scan.
268#[cfg(target_os = "macos")]
269pub(crate) static USB_QUIESCE: std::sync::Mutex<()> = std::sync::Mutex::new(());
270
271/// Enumerate every connected **Logitech** UVC camera.
272///
273/// Non-Logitech cameras (the built-in FaceTime camera, virtual cameras, other
274/// vendors' webcams) are filtered out. Returns an empty list on platforms with
275/// no capture backend, or when no Logitech camera is attached.
276#[must_use]
277pub fn enumerate_cameras() -> Vec<Camera> {
278    enumerate_all()
279        .into_iter()
280        .filter(|camera| camera.vendor_id == LOGITECH_VID)
281        .collect()
282}
283
284#[cfg(target_os = "macos")]
285fn enumerate_all() -> Vec<Camera> {
286    // Wait out any in-flight control seize so the scan can't land in the
287    // window where the kernel driver is detached (poisoning is impossible —
288    // holders never panic — but recover anyway rather than unwrap).
289    let _quiesce = USB_QUIESCE
290        .lock()
291        .unwrap_or_else(std::sync::PoisonError::into_inner);
292    let serials = uvc::usb_serials_by_location();
293    macos::enumerate()
294        .iter()
295        .filter_map(|raw| {
296            let mut camera = Camera::from_raw(&raw.name, &raw.unique_id, &raw.model_id)?;
297            if raw.max_width > 0 && raw.max_height > 0 {
298                camera.max_resolution = Some((raw.max_width, raw.max_height));
299            }
300            if raw.max_fps > 0 {
301                camera.max_fps = Some(raw.max_fps);
302            }
303            if let Some(location) = uvc::location_hint(&raw.unique_id) {
304                camera.serial_number = serials.get(&location).cloned();
305            }
306            Some(camera)
307        })
308        .collect()
309}
310
311#[cfg(target_os = "windows")]
312fn enumerate_all() -> Vec<Camera> {
313    uvc_windows::enumerate()
314}
315
316#[cfg(target_os = "linux")]
317fn enumerate_all() -> Vec<Camera> {
318    linux::nodes().iter().map(linux::describe).collect()
319}
320
321#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
322fn enumerate_all() -> Vec<Camera> {
323    Vec::new()
324}
325
326#[cfg(any(test, target_os = "macos"))]
327impl Camera {
328    /// Build a [`Camera`] from an OS-reported `(name, unique_id, model_id)`.
329    ///
330    /// Returns `None` when `model_id` carries no USB vendor/product id — i.e.
331    /// it isn't a real USB camera (the macOS FaceTime camera's modelID is just
332    /// `"FaceTime HD Camera"`), so it can't be attributed to a vendor and is
333    /// dropped before the Logitech filter even runs. Format fields start `None`;
334    /// the platform backend fills them in.
335    fn from_raw(name: &str, unique_id: &str, model_id: &str) -> Option<Self> {
336        let (vendor_id, product_id) = parse_vid_pid(model_id)?;
337        Some(Self {
338            name: name.to_string(),
339            unique_id: unique_id.to_string(),
340            serial_number: None,
341            vendor_id,
342            product_id,
343            max_resolution: None,
344            max_fps: None,
345        })
346    }
347}
348
349/// Pull the USB vendor/product id out of an `AVCaptureDevice` modelID such as
350/// `"UVC Camera VendorID_1133 ProductID_2195"`. Both ids are **decimal** in
351/// that string (1133 == 0x046d, 2195 == 0x0893). `None` if either marker is
352/// absent.
353#[cfg(any(test, target_os = "macos"))]
354fn parse_vid_pid(model_id: &str) -> Option<(u16, u16)> {
355    let vendor_id = parse_marker(model_id, "VendorID_")?;
356    let product_id = parse_marker(model_id, "ProductID_")?;
357    Some((vendor_id, product_id))
358}
359
360/// Read the decimal number immediately following `marker` in `haystack`.
361#[cfg(any(test, target_os = "macos"))]
362fn parse_marker(haystack: &str, marker: &str) -> Option<u16> {
363    let rest = haystack.split(marker).nth(1)?;
364    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
365    digits.parse().ok()
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn parses_logitech_streamcam_model_id() {
374        assert_eq!(
375            parse_vid_pid("UVC Camera VendorID_1133 ProductID_2195"),
376            Some((0x046d, 0x0893))
377        );
378    }
379
380    #[test]
381    fn rejects_model_id_without_usb_ids() {
382        assert_eq!(parse_vid_pid("FaceTime HD Camera"), None);
383        assert_eq!(parse_vid_pid("VendorID_1133 only"), None);
384    }
385
386    #[test]
387    fn from_raw_keeps_usb_cameras_and_drops_the_rest() {
388        assert_eq!(
389            Camera::from_raw(
390                "Logitech StreamCam",
391                "0x1123000046d0893",
392                "UVC Camera VendorID_1133 ProductID_2195",
393            ),
394            Some(Camera {
395                name: "Logitech StreamCam".to_string(),
396                unique_id: "0x1123000046d0893".to_string(),
397                serial_number: None,
398                vendor_id: LOGITECH_VID,
399                product_id: 0x0893,
400                max_resolution: None,
401                max_fps: None,
402            })
403        );
404        assert_eq!(
405            Camera::from_raw("FaceTime HD Camera", "uuid", "FaceTime HD Camera"),
406            None
407        );
408    }
409
410    #[test]
411    fn config_key_prefers_usb_serial_over_capture_id() {
412        let with_serial = Camera {
413            name: "Logitech StreamCam".into(),
414            unique_id: "0x1123000046d0893".into(),
415            serial_number: Some("ABC123".into()),
416            vendor_id: LOGITECH_VID,
417            product_id: 0x0893,
418            max_resolution: None,
419            max_fps: None,
420        };
421        assert_eq!(with_serial.config_key(), "camera:046d:0893:serial:abc123");
422        // Same physical camera on another USB port → same config key.
423        let moved = Camera {
424            unique_id: "0x14110000046d0893".into(),
425            ..with_serial.clone()
426        };
427        assert_eq!(moved.config_key(), with_serial.config_key());
428
429        let no_serial = Camera {
430            serial_number: None,
431            unique_id: "0x1123000046d0893".into(),
432            ..with_serial.clone()
433        };
434        // Model-scoped — same key after a port change even without a serial.
435        assert_eq!(no_serial.config_key(), "camera:046d:0893");
436        let moved_no_serial = Camera {
437            unique_id: "0x14110000046d0893".into(),
438            ..no_serial
439        };
440        assert_eq!(moved_no_serial.config_key(), "camera:046d:0893");
441    }
442}