Skip to main content

libobsbot_core/
discovery.rs

1// SPDX-License-Identifier: GPL-3.0-only
2//! Device enumeration and hot-plug.
3
4use std::sync::Arc;
5
6use crate::status::{Event, EventReceiver, EventSender};
7use crate::types::ProductType;
8use crate::{Device, Result};
9
10/// Description of a connected OBSBOT camera that has not been opened yet.
11#[derive(Debug, Clone)]
12pub struct DeviceInfo {
13    /// USB vendor id.
14    pub vendor_id: u16,
15    /// USB product id.
16    pub product_id: u16,
17    /// Camera model.
18    pub product_type: ProductType,
19    /// Device serial number, when the OS exposes it. Empty when unknown
20    /// (the Meet 2 sets `iSerial = 0` in its USB descriptor, so no serial
21    /// is available before opening; the camera reports one at runtime).
22    pub serial: String,
23
24    #[cfg(target_os = "linux")]
25    pub(crate) busnum: u8,
26    #[cfg(target_os = "linux")]
27    pub(crate) devnum: u8,
28
29    /// `IOKit` registry id of the matched USB device on macOS - used
30    /// to re-open the same device from a `DeviceInfo` even if the
31    /// enumeration order changed. Read by `MacosTransport::open`,
32    /// which lands in a follow-up commit.
33    #[cfg(target_os = "macos")]
34    #[allow(dead_code)]
35    pub(crate) registry_id: u64,
36}
37
38/// Owns the hot-plug watcher and the registry of connected cameras.
39///
40/// Construct with [`Devices::new`]. The watcher thread is spawned on
41/// `Devices::new` and stops when the struct is dropped.
42pub struct Devices {
43    events_tx: EventSender,
44    events_rx: EventReceiver,
45    /// Set by `Drop` to signal the watcher thread to exit.
46    stop: Arc<std::sync::atomic::AtomicBool>,
47    watcher: Option<std::thread::JoinHandle<()>>,
48}
49
50impl Devices {
51    /// Start the hot-plug watcher and return a handle.
52    pub fn new() -> Result<Self> {
53        let (tx, rx) = crossbeam_channel::unbounded::<Event>();
54        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
55        let watcher = spawn_hotplug_thread(tx.clone(), stop.clone());
56        Ok(Self {
57            events_tx: tx,
58            events_rx: rx,
59            stop,
60            watcher: Some(watcher),
61        })
62    }
63
64    /// Snapshot of currently-connected OBSBOT cameras.
65    #[must_use]
66    pub fn list(&self) -> Vec<DeviceInfo> {
67        enumerate()
68    }
69
70    /// Find a connected camera by serial number.
71    #[must_use]
72    pub fn by_serial(&self, sn: &str) -> Option<DeviceInfo> {
73        self.list().into_iter().find(|d| d.serial == sn)
74    }
75
76    /// Open a connected camera for control.
77    pub fn open(&self, info: &DeviceInfo) -> Result<Device> {
78        let transport: Arc<dyn crate::transport::Transport> = open_transport(info)?;
79        // Bootstrap: learn the device's MAC tail before constructing
80        // the Device. The MAC-query handshake doesn't itself need a
81        // MAC, so it's safe to issue on a freshly opened camera.
82        let mac = crate::device::learn_mac(transport.as_ref())?;
83        tracing::debug!(
84            mac = ?format!("{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
85                           mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]),
86            "learned device MAC"
87        );
88        Ok(Device::new(
89            info.clone(),
90            transport,
91            Some(self.events_tx.clone()),
92            mac,
93        ))
94    }
95
96    /// Subscribe to device add/remove events. Each call returns a clone
97    /// of the receiver; events are broadcast to every clone.
98    #[must_use]
99    pub fn events(&self) -> EventReceiver {
100        self.events_rx.clone()
101    }
102}
103
104impl Drop for Devices {
105    fn drop(&mut self) {
106        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
107        if let Some(handle) = self.watcher.take() {
108            // The thread checks `stop` between sleeps; give it one
109            // poll interval to wake and exit cleanly.
110            let _ = handle.join();
111        }
112    }
113}
114
115/// Live enumeration of OBSBOT cameras, regardless of platform.
116fn enumerate() -> Vec<DeviceInfo> {
117    #[cfg(target_os = "linux")]
118    {
119        linux::enumerate()
120    }
121    #[cfg(target_os = "macos")]
122    {
123        crate::transport::macos::enumerate()
124    }
125    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
126    {
127        Vec::new()
128    }
129}
130
131/// Open a Transport for the given device on the current platform.
132fn open_transport(info: &DeviceInfo) -> Result<Arc<dyn crate::transport::Transport>> {
133    #[cfg(target_os = "linux")]
134    {
135        Ok(Arc::new(crate::transport::usb::UsbTransport::open(info)?))
136    }
137    #[cfg(target_os = "macos")]
138    {
139        Ok(Arc::new(crate::transport::macos::MacosTransport::open(
140            info,
141        )?))
142    }
143    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
144    {
145        let _ = info;
146        Err(crate::Error::Unsupported(
147            "open: only Linux and macOS transports are implemented; Windows is planned",
148        ))
149    }
150}
151
152/// How often the hot-plug thread polls the host for device changes.
153const HOTPLUG_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
154
155fn spawn_hotplug_thread(
156    tx: crossbeam_channel::Sender<Event>,
157    stop: Arc<std::sync::atomic::AtomicBool>,
158) -> std::thread::JoinHandle<()> {
159    std::thread::Builder::new()
160        .name("libobsbot-hotplug".into())
161        .spawn(move || hotplug_loop(&tx, &stop))
162        .expect("spawn hotplug thread")
163}
164
165/// Diff-based hot-plug detector. Emits one `DeviceAdded` event per
166/// device present at startup, then a `DeviceAdded` or `DeviceRemoved`
167/// every time a poll-interval comparison sees the set change.
168fn hotplug_loop(tx: &crossbeam_channel::Sender<Event>, stop: &std::sync::atomic::AtomicBool) {
169    use std::collections::HashMap;
170    let mut known: HashMap<String, DeviceInfo> = HashMap::new();
171    while !stop.load(std::sync::atomic::Ordering::Relaxed) {
172        let current: HashMap<String, DeviceInfo> = enumerate()
173            .into_iter()
174            .map(|d| (device_key(&d), d))
175            .collect();
176        // Additions.
177        for (k, info) in &current {
178            if !known.contains_key(k)
179                && tx
180                    .send(Event::DeviceAdded {
181                        serial: info.serial.clone(),
182                    })
183                    .is_err()
184            {
185                return;
186            }
187        }
188        // Removals.
189        for (k, info) in &known {
190            if !current.contains_key(k)
191                && tx
192                    .send(Event::DeviceRemoved {
193                        serial: info.serial.clone(),
194                    })
195                    .is_err()
196            {
197                return;
198            }
199        }
200        known = current;
201        // Sleep in small chunks so Drop can interrupt us promptly.
202        let mut slept = std::time::Duration::ZERO;
203        while slept < HOTPLUG_POLL_INTERVAL {
204            if stop.load(std::sync::atomic::Ordering::Relaxed) {
205                return;
206            }
207            let chunk = std::time::Duration::from_millis(100);
208            std::thread::sleep(chunk);
209            slept += chunk;
210        }
211    }
212}
213
214/// Stable key for a `DeviceInfo` so we can diff between polls.
215/// Uses busnum + devnum on Linux (the camera's iSerial is empty, so
216/// `info.serial` isn't a useful key without opening the device).
217fn device_key(info: &DeviceInfo) -> String {
218    #[cfg(target_os = "linux")]
219    {
220        format!("{}:{}", info.busnum, info.devnum)
221    }
222    #[cfg(not(target_os = "linux"))]
223    {
224        format!(
225            "{:04x}:{:04x}:{}",
226            info.vendor_id, info.product_id, info.serial
227        )
228    }
229}
230
231#[cfg(target_os = "linux")]
232mod linux {
233    use super::DeviceInfo;
234    use crate::devices::meet2;
235    use crate::types::ProductType;
236    use std::collections::HashMap;
237
238    /// Walk `/sys/class/video4linux/` and return one entry per OBSBOT USB
239    /// device. Multiple v4l2 nodes for the same camera are collapsed by
240    /// `(busnum, devnum)`.
241    pub(super) fn enumerate() -> Vec<DeviceInfo> {
242        let Ok(dir) = std::fs::read_dir("/sys/class/video4linux") else {
243            return Vec::new();
244        };
245        let mut by_dev: HashMap<(u8, u8), DeviceInfo> = HashMap::new();
246        for entry in dir.flatten() {
247            let device_link = entry.path().join("device");
248            let Ok(iface_dir) = std::fs::canonicalize(&device_link) else {
249                continue;
250            };
251            let Some(dev_dir) = iface_dir.parent() else {
252                continue;
253            };
254            let (Ok(busnum), Ok(devnum), Ok(vendor_id), Ok(product_id)) = (
255                read_u8(&dev_dir.join("busnum")),
256                read_u8(&dev_dir.join("devnum")),
257                read_u16_hex(&dev_dir.join("idVendor")),
258                read_u16_hex(&dev_dir.join("idProduct")),
259            ) else {
260                continue;
261            };
262            if vendor_id != meet2::VENDOR_ID {
263                continue;
264            }
265            let product_type = match product_id {
266                meet2::PRODUCT_ID_MEET2 => ProductType::Meet2,
267                _ => continue,
268            };
269            let serial = std::fs::read_to_string(dev_dir.join("serial"))
270                .unwrap_or_default()
271                .trim()
272                .to_owned();
273            by_dev.entry((busnum, devnum)).or_insert(DeviceInfo {
274                vendor_id,
275                product_id,
276                product_type,
277                serial,
278                busnum,
279                devnum,
280            });
281        }
282        by_dev.into_values().collect()
283    }
284
285    fn read_u8(path: &std::path::Path) -> crate::Result<u8> {
286        let s = std::fs::read_to_string(path)
287            .map_err(|e| crate::Error::Usb(format!("read {}: {e}", path.display())))?;
288        s.trim()
289            .parse()
290            .map_err(|_| crate::Error::Usb(format!("parse u8 from {}", path.display())))
291    }
292
293    fn read_u16_hex(path: &std::path::Path) -> crate::Result<u16> {
294        let s = std::fs::read_to_string(path)
295            .map_err(|e| crate::Error::Usb(format!("read {}: {e}", path.display())))?;
296        u16::from_str_radix(s.trim(), 16)
297            .map_err(|_| crate::Error::Usb(format!("parse u16 hex from {}", path.display())))
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn list_does_not_panic_without_hardware() {
307        let d = Devices::new().expect("ctor");
308        let _ = d.list();
309    }
310
311    #[test]
312    fn by_serial_returns_none_when_empty() {
313        let d = Devices::new().expect("ctor");
314        assert!(d.by_serial("nonexistent").is_none());
315    }
316
317    #[test]
318    fn events_channel_open() {
319        let d = Devices::new().expect("ctor");
320        let rx = d.events();
321        // Drain whatever's already there; channel just needs to be alive.
322        while rx.try_recv().is_ok() {}
323        assert!(!rx.is_full(), "unbounded channel should never be full");
324    }
325}