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
440
441
442
443
444
445
446
//! Device-level UVC controls over DirectShow (Windows).
//!
//! Windows exposes the same UVC controls the macOS backend reaches over raw
//! IOKit USB, but pre-mapped by the OS: `IAMVideoProcAmp` carries the image
//! controls (brightness/contrast/…) and `IAMCameraControl` the lens controls
//! (zoom/focus/exposure), each with per-property auto/manual flags. Writes
//! land in the camera's own registers, so — exactly as on macOS — a change is
//! seen by every app that opens the camera.
//!
//! Enumeration also lives here: the DirectShow video-input category yields
//! each camera's friendly name and its device path, whose embedded
//! `vid_xxxx&pid_xxxx` markers give the USB identity. The device path is the
//! OS capture id ([`Camera::unique_id`]); when it embeds a real USB serial
//! (not a parent-generated instance id) that serial is preferred for the
//! port-stable [`Camera::config_key`].

#![expect(
    unsafe_code,
    reason = "DirectShow COM (device enumeration + IAMVideoProcAmp / IAMCameraControl)"
)]

use windows::Win32::Media::DirectShow::{IAMCameraControl, IAMVideoProcAmp, IBaseFilter};
use windows::Win32::System::Com::StructuredStorage::IPropertyBag;
use windows::Win32::System::Com::{
    CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, IEnumMoniker,
    IMoniker,
};
use windows::Win32::System::Variant::{VARIANT, VT_BSTR};
use windows::core::{GUID, Interface, w};

use crate::Camera;
use crate::controls::{
    AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange,
};

/// CLSID_SystemDeviceEnum — the DirectShow device-category enumerator.
const CLSID_SYSTEM_DEVICE_ENUM: GUID = GUID::from_u128(0x62be5d10_60eb_11d0_bd3b_00a0c911ce86);
/// CLSID_VideoInputDeviceCategory — webcams and other video capture sources.
const CLSID_VIDEO_INPUT_DEVICE_CATEGORY: GUID =
    GUID::from_u128(0x860bb310_5d01_11d0_bd3b_00a0c911ce86);
// VideoProcAmp / CameraControl property ids (strmif.h). Raw values rather
// than the generated enums so the mapping reads like the UVC tables.
const VPA_BRIGHTNESS: i32 = 0;
const VPA_CONTRAST: i32 = 1;
const VPA_HUE: i32 = 2;
const VPA_SATURATION: i32 = 3;
const VPA_SHARPNESS: i32 = 4;
const VPA_WHITE_BALANCE: i32 = 6;
const CC_ZOOM: i32 = 3;
const CC_EXPOSURE: i32 = 4;
const CC_FOCUS: i32 = 6;
/// `*_Flags_Auto` / `*_Flags_Manual` share values across both interfaces.
const FLAG_AUTO: i32 = 0x1;
const FLAG_MANUAL: i32 = 0x2;

/// Which DirectShow interface carries a control, plus its property id.
#[derive(Clone, Copy)]
enum Prop {
    VideoProcAmp(i32),
    CameraControl(i32),
}

impl CameraControl {
    fn prop(self) -> Prop {
        match self {
            Self::Zoom => Prop::CameraControl(CC_ZOOM),
            Self::Focus => Prop::CameraControl(CC_FOCUS),
            Self::Exposure => Prop::CameraControl(CC_EXPOSURE),
            Self::Brightness => Prop::VideoProcAmp(VPA_BRIGHTNESS),
            Self::Contrast => Prop::VideoProcAmp(VPA_CONTRAST),
            Self::Saturation => Prop::VideoProcAmp(VPA_SATURATION),
            Self::Sharpness => Prop::VideoProcAmp(VPA_SHARPNESS),
            Self::WhiteBalance => Prop::VideoProcAmp(VPA_WHITE_BALANCE),
            Self::Tint => Prop::VideoProcAmp(VPA_HUE),
        }
    }
}

impl AutoToggle {
    /// The property whose auto/manual flag backs this toggle.
    fn prop(self) -> Prop {
        match self {
            Self::Focus => Prop::CameraControl(CC_FOCUS),
            Self::Exposure => Prop::CameraControl(CC_EXPOSURE),
            Self::WhiteBalance => Prop::VideoProcAmp(VPA_WHITE_BALANCE),
        }
    }
}

/// Enumerate every video-input device DirectShow reports, with the USB
/// vendor/product ids parsed out of the device path. Non-USB sources (virtual
/// cameras) carry no `vid_`/`pid_` markers and are dropped.
pub fn enumerate() -> Vec<Camera> {
    monikers()
        .map(|monikers| {
            monikers
                .into_iter()
                .filter_map(|m| camera_from_moniker(&m))
                .collect()
        })
        .unwrap_or_default()
}

/// Read a control's min/max/default/current straight from the device.
///
/// # Errors
/// [`ControlError::NotFound`] when no camera matches `unique_id`,
/// [`ControlError::Unsupported`] when the camera lacks the control.
pub fn control_range(
    unique_id: &str,
    control: CameraControl,
) -> Result<ControlRange, ControlError> {
    let dev = Device::open(unique_id)?;
    dev.range(control.prop()).map(|(range, _)| range)
}

/// Read every supported control in a single device bind.
///
/// # Errors
/// [`ControlError::NotFound`] when no camera matches `unique_id`.
pub fn control_ranges(unique_id: &str) -> Result<Vec<(CameraControl, ControlRange)>, ControlError> {
    Ok(read_camera_state(unique_id)?.controls)
}

/// Read every supported control range *and* auto-toggle state in a single
/// device bind — what the GUI controls panel builds itself from.
///
/// # Errors
/// [`ControlError::NotFound`] when no camera matches `unique_id`.
pub fn read_camera_state(unique_id: &str) -> Result<CameraState, ControlError> {
    let dev = Device::open(unique_id)?;
    let mut state = CameraState::default();
    for control in CameraControl::ALL {
        if let Ok((range, _)) = dev.range(control.prop()) {
            state.controls.push((control, range));
        }
    }
    for toggle in AutoToggle::ALL {
        if let Ok((_, caps)) = dev.range(toggle.prop())
            && caps & FLAG_AUTO != 0
            && let Ok(current) = dev.auto_engaged(toggle.prop())
        {
            // DirectShow reports which modes exist but not a factory default;
            // auto-capable properties ship with auto engaged on every Logitech
            // camera, so that is the reset target.
            state.autos.push((
                toggle,
                AutoState {
                    current,
                    default: true,
                },
            ));
        }
    }
    Ok(state)
}

/// Write a control's current value (switching that property to manual).
///
/// # Errors
/// As [`control_range`].
pub fn set_control(
    unique_id: &str,
    control: CameraControl,
    value: i32,
) -> Result<(), ControlError> {
    let dev = Device::open(unique_id)?;
    dev.set(control.prop(), value, FLAG_MANUAL)
}

/// Switch an auto mode (focus / exposure / white balance) on or off.
///
/// # Errors
/// As [`control_range`].
pub fn set_auto(unique_id: &str, toggle: AutoToggle, on: bool) -> Result<(), ControlError> {
    let dev = Device::open(unique_id)?;
    dev.set_auto(toggle.prop(), on)
}

/// Apply a batch of auto toggles and control values in a single device bind.
/// Every write is attempted, but any failure surfaces so callers never persist
/// a batch the hardware didn't take.
///
/// # Errors
/// [`ControlError::NotFound`] when no camera matches `unique_id`; otherwise the
/// first per-write error after attempting the whole batch.
pub fn apply_settings(
    unique_id: &str,
    autos: &[(AutoToggle, bool)],
    values: &[(CameraControl, i32)],
) -> Result<(), ControlError> {
    let dev = Device::open(unique_id)?;
    let mut first_err = None;
    for (toggle, on) in autos {
        if let Err(e) = dev.set_auto(toggle.prop(), *on) {
            first_err.get_or_insert(e);
        }
    }
    for (control, value) in values {
        if let Err(e) = dev.set(control.prop(), *value, FLAG_MANUAL) {
            first_err.get_or_insert(e);
        }
    }
    first_err.map_or(Ok(()), Err)
}

/// A camera's bound capture filter, with the two control interfaces it may
/// implement (a camera without lens motors typically lacks `IAMCameraControl`).
struct Device {
    proc_amp: Option<IAMVideoProcAmp>,
    camera_control: Option<IAMCameraControl>,
}

impl Device {
    /// Bind the capture filter whose device path equals `unique_id`. Exact
    /// match only — guessing another camera could adjust the wrong hardware.
    fn open(unique_id: &str) -> Result<Self, ControlError> {
        let monikers = monikers().map_err(|e| ControlError::Io(e.to_string()))?;
        for moniker in monikers {
            if read_property(&moniker, w!("DevicePath")).as_deref() != Some(unique_id) {
                continue;
            }
            // SAFETY: documented moniker → filter bind; the returned interface
            // pointers are reference-counted by the `windows` wrappers.
            let filter: IBaseFilter = unsafe { moniker.BindToObject(None, None) }
                .map_err(|e| ControlError::Io(e.to_string()))?;
            return Ok(Self {
                proc_amp: filter.cast().ok(),
                camera_control: filter.cast().ok(),
            });
        }
        Err(ControlError::NotFound)
    }

    /// GetRange for `prop`: the control's bounds plus its capability flags.
    fn range(&self, prop: Prop) -> Result<(ControlRange, i32), ControlError> {
        let (mut min, mut max, mut step, mut default, mut caps) = (0, 0, 0, 0, 0);
        // SAFETY: documented COM calls writing the five out-params.
        unsafe {
            match prop {
                Prop::VideoProcAmp(id) => self
                    .proc_amp
                    .as_ref()
                    .ok_or(ControlError::Unsupported)?
                    .GetRange(
                        id,
                        &raw mut min,
                        &raw mut max,
                        &raw mut step,
                        &raw mut default,
                        &raw mut caps,
                    ),
                Prop::CameraControl(id) => self
                    .camera_control
                    .as_ref()
                    .ok_or(ControlError::Unsupported)?
                    .GetRange(
                        id,
                        &raw mut min,
                        &raw mut max,
                        &raw mut step,
                        &raw mut default,
                        &raw mut caps,
                    ),
            }
        }
        .map_err(|_| ControlError::Unsupported)?;
        let current = self.get(prop).map_or(default, |(value, _)| value);
        Ok((
            ControlRange {
                min,
                max,
                default,
                current,
            },
            caps,
        ))
    }

    /// Get for `prop`: the current value and its auto/manual flags.
    fn get(&self, prop: Prop) -> Result<(i32, i32), ControlError> {
        let (mut value, mut flags) = (0, 0);
        // SAFETY: documented COM calls writing the two out-params.
        unsafe {
            match prop {
                Prop::VideoProcAmp(id) => self
                    .proc_amp
                    .as_ref()
                    .ok_or(ControlError::Unsupported)?
                    .Get(id, &raw mut value, &raw mut flags),
                Prop::CameraControl(id) => self
                    .camera_control
                    .as_ref()
                    .ok_or(ControlError::Unsupported)?
                    .Get(id, &raw mut value, &raw mut flags),
            }
        }
        .map_err(|_| ControlError::Unsupported)?;
        Ok((value, flags))
    }

    /// Whether `prop` currently runs in auto mode.
    fn auto_engaged(&self, prop: Prop) -> Result<bool, ControlError> {
        Ok(self.get(prop)?.1 & FLAG_AUTO != 0)
    }

    /// Set `prop` to `value` under `flags` (auto or manual).
    fn set(&self, prop: Prop, value: i32, flags: i32) -> Result<(), ControlError> {
        // SAFETY: documented COM calls; the device validates the value.
        unsafe {
            match prop {
                Prop::VideoProcAmp(id) => self
                    .proc_amp
                    .as_ref()
                    .ok_or(ControlError::Unsupported)?
                    .Set(id, value, flags),
                Prop::CameraControl(id) => self
                    .camera_control
                    .as_ref()
                    .ok_or(ControlError::Unsupported)?
                    .Set(id, value, flags),
            }
        }
        .map_err(|_| ControlError::Unsupported)
    }

    /// Engage or release auto mode, keeping the current value in place.
    fn set_auto(&self, prop: Prop, on: bool) -> Result<(), ControlError> {
        let value = self.get(prop).map_or(0, |(v, _)| v);
        self.set(prop, value, if on { FLAG_AUTO } else { FLAG_MANUAL })
    }
}

/// Every video-input moniker DirectShow reports (empty when the category has
/// no devices, which the enumerator signals with `S_FALSE`).
fn monikers() -> windows::core::Result<Vec<IMoniker>> {
    // SAFETY: standard COM setup + documented enumerator calls. Double
    // initialization (or an existing STA on this thread) is harmless here —
    // the enumerator works under either apartment model.
    unsafe {
        let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
        let dev_enum: windows::Win32::Media::DirectShow::ICreateDevEnum =
            CoCreateInstance(&CLSID_SYSTEM_DEVICE_ENUM, None, CLSCTX_INPROC_SERVER)?;
        let mut enum_moniker: Option<IEnumMoniker> = None;
        // S_FALSE (an empty category) is Ok with no enumerator — handled below.
        dev_enum.CreateClassEnumerator(
            &CLSID_VIDEO_INPUT_DEVICE_CATEGORY,
            &raw mut enum_moniker,
            0,
        )?;
        let Some(enum_moniker) = enum_moniker else {
            return Ok(Vec::new());
        };
        let mut all = Vec::new();
        loop {
            let mut chunk = [const { None }; 8];
            let mut fetched = 0;
            let hr = enum_moniker.Next(&mut chunk, Some(&raw mut fetched));
            all.extend(chunk.into_iter().take(fetched as usize).flatten());
            if hr.is_err() || fetched == 0 {
                break;
            }
        }
        Ok(all)
    }
}

/// Build a [`Camera`] from one moniker: friendly name + device path, with the
/// USB ids parsed from the path's `vid_xxxx&pid_xxxx` markers.
fn camera_from_moniker(moniker: &IMoniker) -> Option<Camera> {
    let unique_id = read_property(moniker, w!("DevicePath"))?;
    let (vendor_id, product_id) = parse_device_path_ids(&unique_id)?;
    let name = read_property(moniker, w!("FriendlyName")).unwrap_or_else(|| "Camera".into());
    let serial_number = usb_serial_from_device_path(&unique_id);
    Some(Camera {
        name,
        unique_id,
        serial_number,
        vendor_id,
        product_id,
        max_resolution: None,
        max_fps: None,
    })
}

/// USB `iSerialNumber` embedded in a device-interface path, when present.
///
/// Paths look like `\\?\usb#vid_046d&pid_0893&mi_00#SERIAL#{guid}\global`.
/// Windows fabricates a parent-relative instance id (always containing `&`)
/// when the device has no serial — those are port-dependent and rejected.
pub(crate) fn usb_serial_from_device_path(path: &str) -> Option<String> {
    // Split on '#': [prefix, hardware-id, instance, {class-guid}…]
    let instance = path.split('#').nth(2)?.split('\\').next()?.trim();
    if instance.is_empty() || instance.contains('&') {
        return None;
    }
    Some(instance.to_string())
}

/// Read one string property (`FriendlyName` / `DevicePath`) from a moniker's
/// property bag.
fn read_property(moniker: &IMoniker, name: windows::core::PCWSTR) -> Option<String> {
    // SAFETY: documented property-bag reads; the VARIANT is only interpreted
    // as a BSTR when the bag reports that type.
    unsafe {
        let bag: IPropertyBag = moniker.BindToStorage(None, None).ok()?;
        let mut value = VARIANT::default();
        bag.Read(name, &raw mut value, None).ok()?;
        if value.Anonymous.Anonymous.vt != VT_BSTR {
            return None;
        }
        Some(value.Anonymous.Anonymous.Anonymous.bstrVal.to_string())
    }
}

/// Pull the hex USB vendor/product ids out of a device path such as
/// `\\?\usb#vid_046d&pid_0893&mi_00#…`.
fn parse_device_path_ids(path: &str) -> Option<(u16, u16)> {
    let lower = path.to_ascii_lowercase();
    let hex_after = |marker: &str| -> Option<u16> {
        let rest = lower.split(marker).nth(1)?;
        u16::from_str_radix(rest.get(..4)?, 16).ok()
    };
    Some((hex_after("vid_")?, hex_after("pid_")?))
}

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

    #[test]
    fn real_usb_serial_is_extracted() {
        let path = r"\\?\usb#vid_046d&pid_0893&mi_00#6B123456#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global";
        assert_eq!(
            usb_serial_from_device_path(path).as_deref(),
            Some("6B123456")
        );
    }

    #[test]
    fn parent_generated_instance_is_rejected() {
        // StreamCam without a firmware serial: Windows fabricates `9&56d9c30&0&0000`.
        let path = r"\\?\usb#vid_046d&pid_0893&mi_00#9&56d9c30&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global";
        assert_eq!(usb_serial_from_device_path(path), None);
    }
}