Skip to main content

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}