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