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