styx_v4l2/
lib.rs

1#![doc = include_str!("../README.md")]
2use smallvec::smallvec;
3use std::num::NonZeroU32;
4use std::panic::catch_unwind;
5use styx_capture::prelude::*;
6use styx_core::controls::{Access, ControlKind, ControlValue};
7use v4l::format::Colorspace as V4lColorspace;
8use v4l::{capability::Flags, framesize::FrameSizeEnum, prelude::*, video::Capture};
9
10fn read_node_name(path: &std::path::Path) -> Option<String> {
11    let node = path.file_name()?.to_string_lossy();
12    let sysfs = format!("/sys/class/video4linux/{node}/name");
13    std::fs::read_to_string(sysfs)
14        .ok()
15        .map(|s| s.trim().to_string())
16}
17
18/// V4L2 device information with a descriptor built from advertised formats.
19pub struct V4l2DeviceInfo {
20    pub path: String,
21    pub name: Option<String>,
22    pub card: String,
23    pub driver: String,
24    pub bus_info: String,
25    pub properties: Vec<(String, String)>,
26    pub descriptor: CaptureDescriptor,
27}
28
29/// Probe devices and return (devices, errors) for observability.
30pub fn probe_devices() -> (Vec<V4l2DeviceInfo>, Vec<String>) {
31    let mut devices = Vec::new();
32    let mut errors = Vec::new();
33    for dev in v4l::context::enum_devices() {
34        match build_info(dev.path()) {
35            Ok(info) => devices.push(info),
36            Err(e) => errors.push(format!("{}: {e}", dev.path().display())),
37        };
38    }
39    (devices, errors)
40}
41
42fn build_info(path: &std::path::Path) -> Result<V4l2DeviceInfo, Box<dyn std::error::Error>> {
43    let dev = Device::with_path(path)?;
44    let caps = dev.query_caps()?;
45    let node_name = read_node_name(path);
46
47    if !(caps.capabilities.contains(Flags::VIDEO_CAPTURE)
48        || caps.capabilities.contains(Flags::VIDEO_CAPTURE_MPLANE))
49    {
50        // Skip non-capture nodes (e.g., decoders/encoders) to avoid probing controls they expose.
51        return Err("not a capture device".into());
52    }
53    let card = caps.card;
54    let driver = caps.driver;
55    let bus_info = caps.bus;
56    let driver_lc = driver.to_ascii_lowercase();
57    let card_lc = card.to_ascii_lowercase();
58
59    // Skip pipeline-internal nodes we don't want to expose as cameras.
60    let name_lc = node_name
61        .as_deref()
62        .unwrap_or_default()
63        .to_ascii_lowercase();
64    if card_lc.contains("virtual")
65        || driver_lc.contains("virtual")
66        || driver_lc.contains("pispbe")
67        || card_lc.contains("pispbe")
68        || card_lc.contains("pisp")
69        || driver_lc.contains("rp1-cfe")
70        || card_lc.contains("rp1-cfe")
71        || name_lc.contains("rp1-cfe")
72        || name_lc.contains("embedded")
73        || name_lc.contains("config")
74        || name_lc.contains("fe_")
75        || name_lc.contains("stats")
76    {
77        return Err("filtered non-camera node".into());
78    }
79
80    // Be tolerant of quirky drivers: if formats or frame sizes fail, keep probing
81    // whatever we can instead of dropping the device entirely.
82    let mut modes = Vec::new();
83    let default_color = dev
84        .format()
85        .ok()
86        .map(|fmt| map_color_space(Some(fmt.colorspace)))
87        .unwrap_or(ColorSpace::Unknown);
88    let formats = dev.enum_formats().unwrap_or_default();
89    for fmt in formats {
90        let fourcc = FourCc::from(u32::from_le_bytes(fmt.fourcc.repr));
91        let color = if default_color != ColorSpace::Unknown {
92            default_color
93        } else {
94            guess_color_space(fourcc)
95        };
96        let framesizes = match dev.enum_framesizes(fmt.fourcc) {
97            Ok(sizes) => sizes,
98            Err(_) => continue,
99        };
100        // Pick discrete sizes; stepwise could be supported later.
101        for size in framesizes {
102            match size.size {
103                FrameSizeEnum::Discrete(fs) => {
104                    if let Some(res) = Resolution::new(fs.width, fs.height) {
105                        let mut intervals = smallvec![];
106                        let ivals = dev
107                            .enum_frameintervals(fmt.fourcc, fs.width, fs.height)
108                            .unwrap_or_default();
109                        for iv in ivals {
110                            if let v4l::frameinterval::FrameIntervalEnum::Discrete(discrete) =
111                                iv.interval
112                                && let (Some(n), Some(d)) = (
113                                    NonZeroU32::new(discrete.numerator),
114                                    NonZeroU32::new(discrete.denominator),
115                                )
116                            {
117                                intervals.push(Interval {
118                                    numerator: n,
119                                    denominator: d,
120                                });
121                            }
122                        }
123                        let format = MediaFormat::new(fourcc, res, color);
124                        modes.push(Mode {
125                            id: ModeId {
126                                format,
127                                interval: None,
128                            },
129                            format,
130                            intervals,
131                            interval_stepwise: None,
132                        });
133                    }
134                }
135                FrameSizeEnum::Stepwise(step) => {
136                    if let Some(res) = Resolution::new(step.min_width, step.min_height) {
137                        let format = MediaFormat::new(fourcc, res, color);
138                        modes.push(Mode {
139                            id: ModeId {
140                                format,
141                                interval: None,
142                            },
143                            format,
144                            intervals: smallvec![],
145                            interval_stepwise: None,
146                        });
147                    }
148                }
149            }
150        }
151    }
152
153    // Some kernels expose controls with newer/unknown types; the upstream v4l crate
154    // currently panics when converting those descriptions. Catch the panic and simply
155    // drop the controls list so that device discovery can still succeed.
156    let controls = match catch_unwind(|| dev.query_controls()) {
157        Ok(Ok(ctrls)) => ctrls
158            .into_iter()
159            .filter_map(|ctrl| map_control(ctrl).ok())
160            .collect::<Vec<_>>(),
161        // If controls cannot be queried (ENOTTY) or the v4l crate hit an unsupported
162        // control type, ignore controls rather than skipping the device entirely.
163        Ok(Err(_)) | Err(_) => Vec::new(),
164    };
165
166    let descriptor = CaptureDescriptor { modes, controls };
167    Ok(V4l2DeviceInfo {
168        path: path.display().to_string(),
169        name: node_name.clone(),
170        card: card.clone(),
171        driver: driver.clone(),
172        bus_info: bus_info.clone(),
173        properties: vec![
174            ("path".into(), path.display().to_string()),
175            ("name".into(), node_name.unwrap_or_default()),
176            ("driver".into(), driver),
177            ("card".into(), card),
178            ("bus".into(), bus_info),
179        ],
180        descriptor,
181    })
182}
183
184fn map_control(ctrl: v4l::control::Description) -> Result<ControlMeta, Box<dyn std::error::Error>> {
185    let id = ControlId(ctrl.id);
186    let name = ctrl.name;
187    use v4l::control::Type::*;
188    let (min, max, default) = match ctrl.typ {
189        Integer => (
190            ControlValue::Int(ctrl.minimum as i32),
191            ControlValue::Int(ctrl.maximum as i32),
192            ControlValue::Int(ctrl.default as i32),
193        ),
194        Boolean => (
195            ControlValue::Bool(ctrl.minimum != 0),
196            ControlValue::Bool(ctrl.maximum != 0),
197            ControlValue::Bool(ctrl.default != 0),
198        ),
199        Menu | IntegerMenu => (
200            ControlValue::Uint(ctrl.minimum as u32),
201            ControlValue::Uint(ctrl.maximum as u32),
202            ControlValue::Uint(ctrl.default as u32),
203        ),
204        Bitmask | Integer64 | CtrlClass | Button | String => {
205            return Err("unsupported control type".into());
206        }
207        _ => {
208            return Err("unsupported control type".into());
209        }
210    };
211
212    let access = if ctrl.flags.contains(v4l::control::Flags::READ_ONLY) {
213        Access::ReadOnly
214    } else {
215        Access::ReadWrite
216    };
217
218    let menu = ctrl.items.map(|items| {
219        items
220            .into_iter()
221            .map(|(_, item)| item.to_string())
222            .collect()
223    });
224
225    let step = match ctrl.typ {
226        Integer | Integer64 | Bitmask | IntegerMenu => Some(ControlValue::Uint(ctrl.step as u32)),
227        _ => None,
228    };
229
230    Ok(ControlMeta {
231        id,
232        name,
233        kind: match ctrl.typ {
234            Integer => ControlKind::Int,
235            Boolean => ControlKind::Bool,
236            Menu => ControlKind::Menu,
237            IntegerMenu => ControlKind::IntMenu,
238            Bitmask => ControlKind::Uint,
239            Integer64 => ControlKind::Int,
240            CtrlClass | Button | String => ControlKind::Unknown,
241            _ => ControlKind::Unknown,
242        },
243        access,
244        min,
245        max,
246        default,
247        step,
248        menu,
249    })
250}
251
252fn map_color_space(cs: Option<V4lColorspace>) -> ColorSpace {
253    match cs {
254        Some(V4lColorspace::SRGB) => ColorSpace::Srgb,
255        Some(V4lColorspace::Rec709) => ColorSpace::Bt709,
256        Some(V4lColorspace::Rec2020) => ColorSpace::Bt2020,
257        Some(V4lColorspace::SMPTE170M) => ColorSpace::Bt709,
258        _ => ColorSpace::Unknown,
259    }
260}
261
262fn guess_color_space(fcc: FourCc) -> ColorSpace {
263    match &fcc.to_u32().to_le_bytes() {
264        b"MJPG" | b"JPEG" | b"RG24" | b"RGB3" | b"RGB6" | b"BG24" | b"RGBA" | b"BGRA" | b"XB24"
265        | b"XR24" => ColorSpace::Srgb,
266        b"NV12" | b"NV21" | b"NV16" | b"NV61" | b"NV24" | b"NV42" | b"YUYV" | b"YVYU" | b"UYVY"
267        | b"VYUY" | b"I420" | b"YU12" | b"YV12" | b"YU16" | b"YV16" | b"YU24" | b"YV24" => {
268            ColorSpace::Bt709
269        }
270        _ => ColorSpace::Unknown,
271    }
272}
273
274pub mod prelude {
275    pub use crate::{V4l2DeviceInfo, probe_devices};
276    pub use styx_capture::prelude::*;
277}