Skip to main content

openlogi_camera/
uvc_linux.rs

1//! UVC controls on Linux, over V4L2.
2//!
3//! The kernel's `uvcvideo` driver already speaks UVC to the camera, so this
4//! backend issues `VIDIOC_G_CTRL` / `VIDIOC_S_CTRL` against standard control
5//! ids rather than the raw Processing Unit / Camera Terminal transfers the
6//! macOS backend has to build by hand.
7//!
8//! Two V4L2 details shape the code:
9//!
10//! * **Auto-exposure is a menu, not a boolean.** `V4L2_CID_EXPOSURE_AUTO`
11//!   selects one of four modes; two count as automatic. See [`exposure_mode`].
12//! * **Batched writes can't cross a control class.** `VIDIOC_S_EXT_CTRLS`
13//!   requires every control in one call to share a class, and the controls this
14//!   crate exposes span the User (`0x0098_0000`) and Camera (`0x009a_0000`)
15//!   classes. [`apply_settings`] groups by class instead of issuing one call.
16
17use v4l::Device;
18use v4l::control::{Control, Description, Flags, Value};
19
20use crate::controls::{
21    AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange,
22};
23use crate::linux;
24
25/// `V4L2_CID_BRIGHTNESS` — the User control class base.
26const CID_BRIGHTNESS: u32 = 0x0098_0900;
27const CID_CONTRAST: u32 = 0x0098_0901;
28const CID_SATURATION: u32 = 0x0098_0902;
29const CID_AUTO_WHITE_BALANCE: u32 = 0x0098_090c;
30const CID_WHITE_BALANCE_TEMPERATURE: u32 = 0x0098_091a;
31const CID_SHARPNESS: u32 = 0x0098_091b;
32
33/// `V4L2_CID_EXPOSURE_AUTO` — the Camera control class base.
34const CID_EXPOSURE_AUTO: u32 = 0x009a_0901;
35const CID_EXPOSURE_ABSOLUTE: u32 = 0x009a_0902;
36const CID_FOCUS_ABSOLUTE: u32 = 0x009a_090a;
37const CID_FOCUS_AUTO: u32 = 0x009a_090c;
38const CID_ZOOM_ABSOLUTE: u32 = 0x009a_090d;
39
40/// `V4L2_CID_EXPOSURE_AUTO` menu values, in the kernel's order.
41const EXPOSURE_AUTO: i64 = 0;
42const EXPOSURE_MANUAL: i64 = 1;
43const EXPOSURE_SHUTTER_PRIORITY: i64 = 2;
44const EXPOSURE_APERTURE_PRIORITY: i64 = 3;
45
46/// The V4L2 control id backing each [`CameraControl`].
47///
48/// [`CameraControl::Tint`] has no V4L2 equivalent — UVC exposes white balance
49/// as a single colour temperature, and the component (blue/red balance) form
50/// the macOS backend uses for tint isn't a standard V4L2 control — so it
51/// reports [`ControlError::Unsupported`].
52fn control_id(control: CameraControl) -> Option<u32> {
53    Some(match control {
54        CameraControl::Zoom => CID_ZOOM_ABSOLUTE,
55        CameraControl::Focus => CID_FOCUS_ABSOLUTE,
56        CameraControl::Exposure => CID_EXPOSURE_ABSOLUTE,
57        CameraControl::Brightness => CID_BRIGHTNESS,
58        CameraControl::Contrast => CID_CONTRAST,
59        CameraControl::Saturation => CID_SATURATION,
60        CameraControl::Sharpness => CID_SHARPNESS,
61        CameraControl::WhiteBalance => CID_WHITE_BALANCE_TEMPERATURE,
62        CameraControl::Tint => return None,
63    })
64}
65
66/// The V4L2 control id backing each [`AutoToggle`].
67fn auto_id(toggle: AutoToggle) -> u32 {
68    match toggle {
69        AutoToggle::Focus => CID_FOCUS_AUTO,
70        AutoToggle::Exposure => CID_EXPOSURE_AUTO,
71        AutoToggle::WhiteBalance => CID_AUTO_WHITE_BALANCE,
72    }
73}
74
75/// Open the V4L2 node for `unique_id`.
76fn open(unique_id: &str) -> Result<Device, ControlError> {
77    let path = linux::node_for_unique_id(unique_id).ok_or(ControlError::NotFound)?;
78    Device::with_path(&path).map_err(|error| ControlError::Io(error.to_string()))
79}
80
81/// Read one control's range and current value.
82///
83/// # Errors
84/// [`ControlError::Unsupported`] when the camera doesn't expose the control.
85pub fn control_range(
86    unique_id: &str,
87    control: CameraControl,
88) -> Result<ControlRange, ControlError> {
89    let device = open(unique_id)?;
90    let id = control_id(control).ok_or(ControlError::Unsupported)?;
91    let description = describe(&device, id).ok_or(ControlError::Unsupported)?;
92    range_of(&device, &description).ok_or(ControlError::Unsupported)
93}
94
95/// Read the range of every control this camera supports, skipping the rest.
96///
97/// # Errors
98/// [`ControlError::NotFound`] when no node matches `unique_id`.
99pub fn control_ranges(unique_id: &str) -> Result<Vec<(CameraControl, ControlRange)>, ControlError> {
100    let device = open(unique_id)?;
101    let descriptions = query(&device)?;
102
103    Ok(CameraControl::ALL
104        .into_iter()
105        .filter_map(|control| {
106            let id = control_id(control)?;
107            let description = descriptions.iter().find(|d| d.id == id)?;
108            Some((control, range_of(&device, description)?))
109        })
110        .collect())
111}
112
113/// Read every supported control range and auto-toggle state in one device open.
114///
115/// # Errors
116/// [`ControlError::NotFound`] when no node matches `unique_id`.
117pub fn read_camera_state(unique_id: &str) -> Result<CameraState, ControlError> {
118    let device = open(unique_id)?;
119    let descriptions = query(&device)?;
120
121    let controls = CameraControl::ALL
122        .into_iter()
123        .filter_map(|control| {
124            let id = control_id(control)?;
125            let description = descriptions.iter().find(|d| d.id == id)?;
126            Some((control, range_of(&device, description)?))
127        })
128        .collect();
129
130    let autos = AutoToggle::ALL
131        .into_iter()
132        .filter_map(|toggle| {
133            let id = auto_id(toggle);
134            let description = descriptions.iter().find(|d| d.id == id)?;
135            let current = read_auto(&device, toggle)?;
136            let default = if toggle == AutoToggle::Exposure {
137                is_auto_mode(description.default)
138            } else {
139                description.default != 0
140            };
141            Some((toggle, AutoState { current, default }))
142        })
143        .collect();
144
145    Ok(CameraState { controls, autos })
146}
147
148/// Write one control value.
149///
150/// # Errors
151/// [`ControlError::Unsupported`] when the camera doesn't expose the control, or
152/// rejects the write because an auto mode currently owns it.
153pub fn set_control(
154    unique_id: &str,
155    control: CameraControl,
156    value: i32,
157) -> Result<(), ControlError> {
158    let device = open(unique_id)?;
159    let id = control_id(control).ok_or(ControlError::Unsupported)?;
160    write_value(&device, id, i64::from(value))
161}
162
163/// Turn one auto mode on or off.
164///
165/// # Errors
166/// [`ControlError::Unsupported`] when the camera has no such toggle.
167pub fn set_auto(unique_id: &str, toggle: AutoToggle, on: bool) -> Result<(), ControlError> {
168    let device = open(unique_id)?;
169    write_auto(&device, toggle, on)
170}
171
172/// Apply auto toggles and control values in one device open.
173///
174/// Autos are written first: a manual value is rejected while its auto mode
175/// still owns the control, so dragging an auto-gated slider must clear the
176/// mode before the value lands. Controls are then batched per class, since
177/// `VIDIOC_S_EXT_CTRLS` refuses a mixed-class call.
178///
179/// Unsupported controls are skipped rather than failing the batch — a profile
180/// saved against a Brio shouldn't fail wholesale when applied to a C270.
181///
182/// # Errors
183/// [`ControlError::NotFound`] when no node matches `unique_id`; the first I/O
184/// error otherwise.
185pub fn apply_settings(
186    unique_id: &str,
187    autos: &[(AutoToggle, bool)],
188    values: &[(CameraControl, i32)],
189) -> Result<(), ControlError> {
190    let device = open(unique_id)?;
191    let supported = query(&device)?;
192    let has = |id: u32| supported.iter().any(|d| d.id == id);
193
194    for &(toggle, on) in autos {
195        if has(auto_id(toggle)) {
196            write_auto(&device, toggle, on)?;
197        }
198    }
199
200    let writable: Vec<(u32, i64)> = values
201        .iter()
202        .filter(|&&(control, _)| !gated_by_enabled_auto(control, autos))
203        .filter_map(|&(control, value)| {
204            let id = control_id(control)?;
205            has(id).then_some((id, i64::from(value)))
206        })
207        .collect();
208
209    for class in [CLASS_USER, CLASS_CAMERA] {
210        let in_class = || {
211            writable
212                .iter()
213                .filter(move |(id, _)| id & CLASS_MASK == class)
214        };
215        let batch: Vec<Control> = in_class()
216            .map(|&(id, value)| Control {
217                id,
218                value: Value::Integer(value),
219            })
220            .collect();
221        if batch.is_empty() {
222            continue;
223        }
224        // A rejected batch falls back to per-control writes so one control the
225        // camera dislikes can't discard the whole profile. A control the device
226        // refuses outright is skipped for the same reason — only a genuine I/O
227        // failure aborts.
228        if device.set_controls(batch).is_err() {
229            for &(id, value) in in_class() {
230                match write_value(&device, id, value) {
231                    Ok(()) | Err(ControlError::Unsupported) => {}
232                    Err(error) => return Err(error),
233                }
234            }
235        }
236    }
237
238    Ok(())
239}
240
241/// Whether this call is handing `control` over to an auto mode.
242///
243/// A control under automatic control rejects manual writes, so a profile that
244/// carries both "auto on" and the value it gates would otherwise fail — and,
245/// because the write aborts the batch, would strand later controls unapplied.
246/// The auto toggle expresses the intent; the stale manual value is redundant.
247fn gated_by_enabled_auto(control: CameraControl, autos: &[(AutoToggle, bool)]) -> bool {
248    control
249        .auto_toggle()
250        .is_some_and(|gate| autos.iter().any(|&(toggle, on)| toggle == gate && on))
251}
252
253/// Mask selecting the class bits of a V4L2 control id.
254const CLASS_MASK: u32 = 0xFFFF_0000;
255const CLASS_USER: u32 = 0x0098_0000;
256const CLASS_CAMERA: u32 = 0x009a_0000;
257
258/// Every control the device advertises.
259fn query(device: &Device) -> Result<Vec<Description>, ControlError> {
260    device
261        .query_controls()
262        .map_err(|error| ControlError::Io(error.to_string()))
263}
264
265/// One control's description, if the device advertises it.
266fn describe(device: &Device, id: u32) -> Option<Description> {
267    device
268        .query_controls()
269        .ok()?
270        .into_iter()
271        .find(|description| description.id == id)
272}
273
274/// Build a [`ControlRange`], reading the live value.
275///
276/// Disabled controls are dropped — the driver refuses to read them, and they
277/// can't be adjusted. An *inactive* control (one an auto mode currently owns,
278/// like `exposure_time_absolute` under aperture priority) is kept: its range
279/// and last value are exactly what the UI needs to show the slider it will
280/// enable the moment auto is switched off.
281fn range_of(device: &Device, description: &Description) -> Option<ControlRange> {
282    if description.flags.contains(Flags::DISABLED) {
283        return None;
284    }
285    let current = read_int(device, description.id).unwrap_or(description.default);
286    Some(ControlRange {
287        min: clamp_i32(description.minimum),
288        max: clamp_i32(description.maximum),
289        default: clamp_i32(description.default),
290        current: clamp_i32(current),
291    })
292}
293
294/// Read an integer/boolean control's current value.
295fn read_int(device: &Device, id: u32) -> Option<i64> {
296    match device.control(id).ok()?.value {
297        Value::Integer(value) => Some(value),
298        Value::Boolean(value) => Some(i64::from(value)),
299        _ => None,
300    }
301}
302
303/// Read whether an auto mode is currently engaged.
304fn read_auto(device: &Device, toggle: AutoToggle) -> Option<bool> {
305    let raw = read_int(device, auto_id(toggle))?;
306    Some(if toggle == AutoToggle::Exposure {
307        is_auto_mode(raw)
308    } else {
309        raw != 0
310    })
311}
312
313/// Whether a `V4L2_CID_EXPOSURE_AUTO` menu value counts as automatic.
314///
315/// `AUTO` and `APERTURE_PRIORITY` both let the camera drive exposure time;
316/// `MANUAL` and `SHUTTER_PRIORITY` leave it under application control.
317fn is_auto_mode(value: i64) -> bool {
318    value == EXPOSURE_AUTO || value == EXPOSURE_APERTURE_PRIORITY
319}
320
321/// Write an auto toggle, translating the exposure menu.
322fn write_auto(device: &Device, toggle: AutoToggle, on: bool) -> Result<(), ControlError> {
323    if toggle == AutoToggle::Exposure {
324        let mode = exposure_mode(device, on).ok_or(ControlError::Unsupported)?;
325        return write_value(device, CID_EXPOSURE_AUTO, mode);
326    }
327    let control = Control {
328        id: auto_id(toggle),
329        value: Value::Boolean(on),
330    };
331    device
332        .set_control(control)
333        .map_err(|error| ControlError::Io(error.to_string()))
334}
335
336/// Pick an exposure menu value for the requested automatic/manual intent.
337///
338/// Cameras implement different subsets — the MX Brio offers only
339/// `APERTURE_PRIORITY` and `MANUAL`, while others offer `AUTO` — so the
340/// preferred value is checked against the advertised menu before falling back
341/// to the alternative with the same meaning.
342fn exposure_mode(device: &Device, on: bool) -> Option<i64> {
343    let description = describe(device, CID_EXPOSURE_AUTO)?;
344    let offered = |value: i64| -> bool {
345        // A menu with no enumerated items (some drivers omit them) still
346        // accepts values inside its advertised min/max.
347        description.items.as_ref().map_or(
348            value >= description.minimum && value <= description.maximum,
349            |items| items.iter().any(|(index, _)| i64::from(*index) == value),
350        )
351    };
352
353    let preferences: [i64; 2] = if on {
354        [EXPOSURE_APERTURE_PRIORITY, EXPOSURE_AUTO]
355    } else {
356        [EXPOSURE_MANUAL, EXPOSURE_SHUTTER_PRIORITY]
357    };
358    preferences.into_iter().find(|&value| offered(value))
359}
360
361/// `errno` values that mean "this camera won't take that write" rather than
362/// "the call went wrong": unknown control, value out of range, or an auto mode
363/// currently owning the control.
364const REJECTED: [i32; 4] = [
365    22, // EINVAL
366    34, // ERANGE
367    13, // EACCES
368    16, // EBUSY
369];
370
371/// Write an integer control, mapping a driver rejection to `Unsupported`.
372fn write_value(device: &Device, id: u32, value: i64) -> Result<(), ControlError> {
373    let control = Control {
374        id,
375        value: Value::Integer(value),
376    };
377    device.set_control(control).map_err(|error| {
378        if error
379            .raw_os_error()
380            .is_some_and(|no| REJECTED.contains(&no))
381        {
382            ControlError::Unsupported
383        } else {
384            ControlError::Io(error.to_string())
385        }
386    })
387}
388
389/// Narrow a V4L2 `i64` control bound to the `i32` the shared vocabulary uses.
390///
391/// Standard UVC controls fit comfortably; saturating keeps a driver reporting
392/// an absurd bound from wrapping into a negative slider bound.
393fn clamp_i32(value: i64) -> i32 {
394    i32::try_from(value).unwrap_or(if value.is_negative() {
395        i32::MIN
396    } else {
397        i32::MAX
398    })
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn exposure_auto_maps_only_two_menu_values_to_automatic() {
407        assert!(is_auto_mode(EXPOSURE_AUTO));
408        assert!(is_auto_mode(EXPOSURE_APERTURE_PRIORITY));
409        assert!(!is_auto_mode(EXPOSURE_MANUAL));
410        assert!(!is_auto_mode(EXPOSURE_SHUTTER_PRIORITY));
411    }
412
413    #[test]
414    fn a_control_handed_to_auto_is_skipped() {
415        let autos = [(AutoToggle::Exposure, true)];
416        // Exposure is gated by the toggle being switched on...
417        assert!(gated_by_enabled_auto(CameraControl::Exposure, &autos));
418        // ...while ungated controls, and controls gated by a *different*
419        // toggle, still apply.
420        assert!(!gated_by_enabled_auto(CameraControl::Zoom, &autos));
421        assert!(!gated_by_enabled_auto(CameraControl::Focus, &autos));
422    }
423
424    #[test]
425    fn a_control_taken_off_auto_still_applies() {
426        // Switching auto *off* is exactly when the manual value must be written.
427        let autos = [(AutoToggle::Focus, false)];
428        assert!(!gated_by_enabled_auto(CameraControl::Focus, &autos));
429    }
430
431    #[test]
432    fn an_unmentioned_toggle_leaves_its_control_writable() {
433        assert!(!gated_by_enabled_auto(CameraControl::WhiteBalance, &[]));
434    }
435
436    #[test]
437    fn every_supported_control_has_a_known_class() {
438        // apply_settings batches per class; a control outside both would be
439        // silently dropped from every batch.
440        for control in CameraControl::ALL {
441            let Some(id) = control_id(control) else {
442                continue; // Tint has no V4L2 equivalent.
443            };
444            let class = id & CLASS_MASK;
445            assert!(
446                class == CLASS_USER || class == CLASS_CAMERA,
447                "{} has class {class:#x}",
448                control.name()
449            );
450        }
451    }
452}