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