mtp_rs/mtp/hotplug.rs
1//! Watch for MTP devices being plugged in and unplugged.
2//!
3//! This module is private; its contents are re-exported from [`crate::mtp`], so the published
4//! contract lives on [`DeviceWatch`] rather than here.
5
6use std::collections::HashMap;
7use std::pin::Pin;
8use std::task::{Context, Poll};
9use std::time::Duration;
10
11use futures::future::BoxFuture;
12use futures::{FutureExt, Stream, StreamExt};
13
14use crate::mtp::{Error, MtpDeviceInfo};
15use crate::transport::NusbTransport;
16
17/// How long to wait after a USB event before enumerating, by default.
18///
19/// A device is not necessarily ready to describe itself the instant the OS announces it: its
20/// interface descriptors can still be unpopulated, in which case it doesn't look like an MTP
21/// device yet and would be silently skipped. Waiting a beat and then asking the OS afresh is what
22/// makes arrival detection reliable. Override with [`DeviceWatchBuilder::settle_delay`].
23pub const DEFAULT_SETTLE_DELAY: Duration = Duration::from_millis(500);
24
25/// A device was plugged in or unplugged.
26///
27/// Only MTP devices produce events; see [`DeviceWatch`] for what the stream guarantees.
28///
29/// Deliberately not `#[non_exhaustive]`: a device is either here or it isn't, and there's no third
30/// state to grow into. Consumers get an exhaustive `match` instead of a dead wildcard arm. The
31/// payload carries [`MtpDeviceInfo`], which *is* `#[non_exhaustive]`, so new device facts still land
32/// without breaking anyone.
33#[derive(Debug, Clone)]
34pub enum HotplugEvent {
35 /// A device appeared, or was already present when watching began.
36 Arrived(MtpDeviceInfo),
37 /// A device went away. The info is what the watch last saw for it.
38 Left(MtpDeviceInfo),
39}
40
41/// Identity of a device, used to tell "same device, still there" from "different device now".
42///
43/// Keyed on the USB topology position, because that's the one field the OS reports on both connect
44/// and disconnect. The rest of the tuple detects a swap: unplug one phone, plug another into the
45/// same port between two enumerations, and the position alone would call it unchanged.
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47struct DeviceKey {
48 location_id: u64,
49 vendor_id: u16,
50 product_id: u16,
51 serial_number: Option<String>,
52}
53
54impl DeviceKey {
55 fn of(info: &MtpDeviceInfo) -> Self {
56 Self {
57 location_id: info.location_id,
58 vendor_id: info.vendor_id,
59 product_id: info.product_id,
60 serial_number: info.serial_number.clone(),
61 }
62 }
63}
64
65/// Compare the devices present now against the ones last seen, and report what changed.
66///
67/// Pure, so the interesting behavior is testable without USB hardware. Updates `known` in place.
68fn diff(
69 known: &mut HashMap<DeviceKey, MtpDeviceInfo>,
70 current: Vec<MtpDeviceInfo>,
71) -> Vec<HotplugEvent> {
72 let current: HashMap<DeviceKey, MtpDeviceInfo> = current
73 .into_iter()
74 .map(|i| (DeviceKey::of(&i), i))
75 .collect();
76
77 // Departures first: a device that changed identity in place should read as "the old one left,
78 // then the new one arrived", not the other way around.
79 let mut events: Vec<HotplugEvent> = known
80 .iter()
81 .filter(|(key, _)| !current.contains_key(key))
82 .map(|(_, info)| HotplugEvent::Left(info.clone()))
83 .collect();
84
85 events.extend(
86 current
87 .iter()
88 .filter(|(key, _)| !known.contains_key(key))
89 .map(|(_, info)| HotplugEvent::Arrived(info.clone())),
90 );
91
92 *known = current;
93 events
94}
95
96/// Configure a [`DeviceWatch`] before starting it.
97///
98/// Only needed to widen device matching or to tune the settle delay; most callers want
99/// [`watch_devices`].
100#[derive(Debug, Clone)]
101pub struct DeviceWatchBuilder {
102 known_devices: Vec<(u16, u16)>,
103 settle_delay: Duration,
104}
105
106impl Default for DeviceWatchBuilder {
107 fn default() -> Self {
108 Self {
109 known_devices: Vec::new(),
110 settle_delay: DEFAULT_SETTLE_DELAY,
111 }
112 }
113}
114
115impl DeviceWatchBuilder {
116 /// Start configuring a watch.
117 #[must_use]
118 pub fn new() -> Self {
119 Self::default()
120 }
121
122 /// Also report devices matching these VID/PID pairs, even when their USB descriptors don't
123 /// carry the standard MTP class codes.
124 ///
125 /// Mirrors
126 /// [`MtpDevice::list_devices_with_known`](crate::mtp::MtpDevice::list_devices_with_known); pass
127 /// the same list to both so a device you can list is also a device you can watch for.
128 #[must_use]
129 pub fn known_devices(mut self, known: &[(u16, u16)]) -> Self {
130 self.known_devices = known.to_vec();
131 self
132 }
133
134 /// How long to wait after a USB event before enumerating (default
135 /// [`DEFAULT_SETTLE_DELAY`]).
136 ///
137 /// Lower it to react faster at the risk of missing a device that hasn't finished describing
138 /// itself; raise it for devices that are slow to enumerate. `Duration::ZERO` enumerates
139 /// immediately.
140 #[must_use]
141 pub fn settle_delay(mut self, delay: Duration) -> Self {
142 self.settle_delay = delay;
143 self
144 }
145
146 /// Start watching.
147 ///
148 /// Enumerates once up front, so every device already connected is reported as
149 /// [`HotplugEvent::Arrived`] when the stream is first polled.
150 ///
151 /// # Errors
152 ///
153 /// Returns an error if the OS refuses to set up USB hotplug notifications.
154 pub fn watch(self) -> Result<DeviceWatch, Error> {
155 let usb = nusb::watch_devices().map_err(crate::PtpError::Usb)?;
156 Ok(DeviceWatch {
157 usb,
158 known: HashMap::new(),
159 known_devices: self.known_devices,
160 settle_delay: self.settle_delay,
161 pending: Vec::new(),
162 settling: None,
163 started: false,
164 })
165 }
166}
167
168/// A stream of [`HotplugEvent`]s reporting MTP devices arriving and leaving.
169///
170/// Build one with [`watch_devices`] or [`DeviceWatchBuilder`]. Only devices that
171/// [`MtpDevice::list_devices`](crate::mtp::MtpDevice::list_devices) would list produce events; mice,
172/// hubs, and chargers never reach the consumer.
173///
174/// ```rust,no_run
175/// use futures::StreamExt;
176/// use mtp_rs::mtp::{watch_devices, HotplugEvent, MtpDevice};
177///
178/// # async fn example() -> Result<(), mtp_rs::Error> {
179/// let mut watch = watch_devices()?;
180/// while let Some(event) = watch.next().await {
181/// match event {
182/// HotplugEvent::Arrived(info) => {
183/// let serial = info.serial_number.clone().unwrap_or_default();
184/// let device = MtpDevice::open_by_serial(&serial).await?;
185/// println!("mounted {}", device.device_info().model);
186/// }
187/// HotplugEvent::Left(info) => println!("gone: {:?}", info.serial_number),
188/// }
189/// }
190/// # Ok(())
191/// # }
192/// ```
193///
194/// # What the stream guarantees
195///
196/// - **Devices already plugged in when watching starts arrive as [`HotplugEvent::Arrived`]**, before
197/// any live event. So a consumer needs one code path, not an enumerate-then-watch pair, and a
198/// device plugged in during startup can't slip through the gap between the two. Consumers must not
199/// enumerate separately as well, or they'll count those devices twice.
200/// - **[`HotplugEvent::Left`] carries the full [`MtpDeviceInfo`]** of the device that went away,
201/// including its serial. The OS reports only an opaque id on disconnect, so the watch caches what
202/// it last saw; without that a consumer can't tell which of two phones was unplugged.
203/// - **A device that changes identity in place** (a phone switching from charge-only to file
204/// transfer, which re-enumerates it with new descriptors) is reported as a `Left` for the old
205/// identity followed by an `Arrived` for the new one.
206/// - **The stream never ends on its own**; it stays live until dropped. Dropping it stops the
207/// notifications and any later device changes are missed, so a consumer tracking devices for the
208/// lifetime of a process should hold it for that long.
209///
210/// # Scope
211///
212/// USB only. Virtual devices (feature `virtual-device`) are registered in-process rather than
213/// plugged in, so they never produce hotplug events even though `list_devices` includes them.
214///
215/// The watch reports device presence; it does not open anything. A device that has just arrived may
216/// still refuse to open a session for a moment, and on Android the user may not have granted
217/// file-transfer mode yet. Retry with backoff rather than treating the first failure as fatal.
218pub struct DeviceWatch {
219 usb: nusb::hotplug::HotplugWatch,
220 known: HashMap<DeviceKey, MtpDeviceInfo>,
221 known_devices: Vec<(u16, u16)>,
222 settle_delay: Duration,
223 /// Events produced by the last enumeration, not yet handed to the consumer.
224 pending: Vec<HotplugEvent>,
225 /// In-flight settle delay; enumeration happens when it completes.
226 settling: Option<BoxFuture<'static, ()>>,
227 started: bool,
228}
229
230impl std::fmt::Debug for DeviceWatch {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 f.debug_struct("DeviceWatch")
233 .field("known", &self.known.len())
234 .field("settle_delay", &self.settle_delay)
235 .field("pending", &self.pending.len())
236 .finish_non_exhaustive()
237 }
238}
239
240impl DeviceWatch {
241 /// Ask the OS what's connected now and turn the difference into events.
242 ///
243 /// Enumerating afresh rather than trusting the event's own device snapshot is deliberate: the
244 /// snapshot can predate the device's descriptors being readable, and it says nothing about
245 /// devices whose events were coalesced.
246 fn enumerate(&mut self) {
247 match NusbTransport::list_mtp_devices_with_known(&self.known_devices) {
248 Ok(devices) => {
249 let current = devices.into_iter().map(MtpDeviceInfo::from_usb).collect();
250 self.pending.extend(diff(&mut self.known, current));
251 }
252 // A failed enumeration means "we don't know what's out there", not "nothing is out
253 // there". Reporting every device as departed on a transient failure would be worse
254 // than waiting for the next event, which re-enumerates anyway.
255 Err(e) => {
256 diag_debug!(
257 "hotplug enumeration failed, keeping last known device set: {}",
258 e
259 );
260 }
261 }
262 }
263}
264
265impl Stream for DeviceWatch {
266 type Item = HotplugEvent;
267
268 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
269 let this = self.get_mut();
270
271 loop {
272 if !this.pending.is_empty() {
273 return Poll::Ready(Some(this.pending.remove(0)));
274 }
275
276 // First poll: report what's already plugged in, with no settle delay. These devices
277 // have been present since before watching began, so they've had their beat already.
278 if !this.started {
279 this.started = true;
280 this.enumerate();
281 continue;
282 }
283
284 if let Some(settle) = this.settling.as_mut() {
285 match settle.poll_unpin(cx) {
286 Poll::Ready(()) => {
287 this.settling = None;
288 this.enumerate();
289 continue;
290 }
291 Poll::Pending => return Poll::Pending,
292 }
293 }
294
295 match this.usb.poll_next_unpin(cx) {
296 // The event itself is only a trigger; `enumerate` is what decides whether anything
297 // relevant changed, so connect and disconnect are handled identically. Coalescing
298 // is free: further events during the settle delay fold into the one enumeration.
299 Poll::Ready(Some(_)) => {
300 this.settling = Some(sleep(this.settle_delay).boxed());
301 continue;
302 }
303 Poll::Ready(None) => return Poll::Ready(None),
304 Poll::Pending => return Poll::Pending,
305 }
306 }
307 }
308}
309
310async fn sleep(duration: Duration) {
311 if !duration.is_zero() {
312 futures_timer::Delay::new(duration).await;
313 }
314}
315
316/// Watch for MTP devices being plugged in and unplugged.
317///
318/// Devices already connected are reported as [`HotplugEvent::Arrived`] when the stream is first
319/// polled, so this is the only enumeration a consumer needs. See [`DeviceWatch`] for the full
320/// contract, and [`DeviceWatchBuilder`] to widen matching or tune timing.
321///
322/// # Errors
323///
324/// Returns an error if the OS refuses to set up USB hotplug notifications.
325///
326/// # Example
327///
328/// ```rust,no_run
329/// use futures::StreamExt;
330/// use mtp_rs::mtp::{watch_devices, HotplugEvent};
331///
332/// # async fn example() -> Result<(), mtp_rs::Error> {
333/// let mut watch = watch_devices()?;
334/// while let Some(event) = watch.next().await {
335/// match event {
336/// HotplugEvent::Arrived(info) => println!("arrived: {:?}", info.product),
337/// HotplugEvent::Left(info) => println!("left: {:?}", info.product),
338/// }
339/// }
340/// # Ok(())
341/// # }
342/// ```
343pub fn watch_devices() -> Result<DeviceWatch, Error> {
344 DeviceWatchBuilder::new().watch()
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::transport::MtpMatchReason;
351
352 fn info(location_id: u64, serial: Option<&str>) -> MtpDeviceInfo {
353 MtpDeviceInfo {
354 vendor_id: 0x18d1,
355 product_id: 0x4ee1,
356 manufacturer: Some("Google".into()),
357 product: Some("Pixel 9 Pro XL".into()),
358 serial_number: serial.map(String::from),
359 location_id,
360 speed: None,
361 match_reason: MtpMatchReason::StandardClass,
362 }
363 }
364
365 fn serials(events: &[HotplugEvent]) -> Vec<(&'static str, Option<String>)> {
366 events
367 .iter()
368 .map(|e| match e {
369 HotplugEvent::Arrived(i) => ("arrived", i.serial_number.clone()),
370 HotplugEvent::Left(i) => ("left", i.serial_number.clone()),
371 })
372 .collect()
373 }
374
375 #[test]
376 fn first_enumeration_reports_every_connected_device_as_arrived() {
377 let mut known = HashMap::new();
378 let events = diff(&mut known, vec![info(1, Some("a")), info(2, Some("b"))]);
379
380 assert_eq!(events.len(), 2);
381 assert!(events.iter().all(|e| matches!(e, HotplugEvent::Arrived(_))));
382 assert_eq!(known.len(), 2);
383 }
384
385 #[test]
386 fn unchanged_device_set_produces_no_events() {
387 let mut known = HashMap::new();
388 diff(&mut known, vec![info(1, Some("a"))]);
389
390 // Every USB event on the system triggers an enumeration, including ones from a mouse or a
391 // hub. Those must not reach the consumer as spurious device churn.
392 let events = diff(&mut known, vec![info(1, Some("a"))]);
393 assert!(events.is_empty());
394 }
395
396 #[test]
397 fn unplugged_device_is_reported_with_the_info_last_seen() {
398 let mut known = HashMap::new();
399 diff(&mut known, vec![info(1, Some("a")), info(2, Some("b"))]);
400
401 let events = diff(&mut known, vec![info(1, Some("a"))]);
402
403 // The OS reports only an opaque id on disconnect, so carrying the cached info is the whole
404 // point: a consumer with two phones attached has to know which one went away.
405 assert_eq!(events.len(), 1);
406 match &events[0] {
407 HotplugEvent::Left(i) => {
408 assert_eq!(i.serial_number.as_deref(), Some("b"));
409 assert_eq!(i.product.as_deref(), Some("Pixel 9 Pro XL"));
410 }
411 other => panic!("expected Left, got {other:?}"),
412 }
413 assert_eq!(known.len(), 1);
414 }
415
416 #[test]
417 fn device_swapped_on_the_same_port_reports_left_before_arrived() {
418 let mut known = HashMap::new();
419 diff(&mut known, vec![info(1, Some("a"))]);
420
421 // Same USB position, different phone. Keying on position alone would call this unchanged
422 // and leave the consumer talking to the wrong device.
423 let events = diff(&mut known, vec![info(1, Some("b"))]);
424
425 assert_eq!(
426 serials(&events),
427 vec![
428 ("left", Some("a".to_string())),
429 ("arrived", Some("b".to_string()))
430 ]
431 );
432 }
433
434 #[test]
435 fn device_re_enumerating_into_file_transfer_mode_reports_left_then_arrived() {
436 let mut known = HashMap::new();
437 let mut charging = info(1, Some("a"));
438 charging.product_id = 0x4ee7; // charge-only composite
439 diff(&mut known, vec![charging]);
440
441 // An Android phone switching to file transfer comes back with a different product ID. The
442 // consumer needs a fresh Arrived to know it can open a session now.
443 let events = diff(&mut known, vec![info(1, Some("a"))]);
444
445 assert_eq!(
446 serials(&events),
447 vec![
448 ("left", Some("a".to_string())),
449 ("arrived", Some("a".to_string()))
450 ]
451 );
452 }
453
454 #[test]
455 fn devices_without_serials_are_distinguished_by_port() {
456 let mut known = HashMap::new();
457 let events = diff(&mut known, vec![info(1, None), info(2, None)]);
458
459 // Cameras often report no serial. Two of the same model must not collapse into one entry.
460 assert_eq!(events.len(), 2);
461 assert_eq!(known.len(), 2);
462
463 let events = diff(&mut known, vec![info(2, None)]);
464 assert_eq!(events.len(), 1);
465 assert!(matches!(events[0], HotplugEvent::Left(_)));
466 }
467
468 #[test]
469 fn all_devices_gone_reports_every_one_as_left() {
470 let mut known = HashMap::new();
471 diff(&mut known, vec![info(1, Some("a")), info(2, Some("b"))]);
472
473 let events = diff(&mut known, vec![]);
474
475 assert_eq!(events.len(), 2);
476 assert!(events.iter().all(|e| matches!(e, HotplugEvent::Left(_))));
477 assert!(known.is_empty());
478 }
479}