libobsbot_core/
discovery.rs1use std::sync::Arc;
5
6use crate::status::{Event, EventReceiver, EventSender};
7use crate::types::ProductType;
8use crate::{Device, Result};
9
10#[derive(Debug, Clone)]
12pub struct DeviceInfo {
13 pub vendor_id: u16,
15 pub product_id: u16,
17 pub product_type: ProductType,
19 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 #[cfg(target_os = "macos")]
34 #[allow(dead_code)]
35 pub(crate) registry_id: u64,
36}
37
38pub struct Devices {
43 events_tx: EventSender,
44 events_rx: EventReceiver,
45 stop: Arc<std::sync::atomic::AtomicBool>,
47 watcher: Option<std::thread::JoinHandle<()>>,
48}
49
50impl Devices {
51 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 #[must_use]
66 pub fn list(&self) -> Vec<DeviceInfo> {
67 enumerate()
68 }
69
70 #[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 pub fn open(&self, info: &DeviceInfo) -> Result<Device> {
78 let transport: Arc<dyn crate::transport::Transport> = open_transport(info)?;
79 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 #[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 let _ = handle.join();
111 }
112 }
113}
114
115fn 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
131fn 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
152const 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
165fn 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 for (k, info) in ¤t {
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 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 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
214fn 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 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 while rx.try_recv().is_ok() {}
323 assert!(!rx.is_full(), "unbounded channel should never be full");
324 }
325}