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