Skip to main content

restorekit/
dongle.rs

1//! Client for RecoverKit dongles over their USB vendor interface.
2//!
3//! The dongle is a small USB-C board that forces a cabled Mac into DFU (or
4//! reboots it) by speaking Apple's USB-PD VDMs, so DFU can be triggered from any
5//! host OS without an Apple Silicon Mac. It enumerates as a composite device: a
6//! human CDC console (see the firmware README) plus a **vendor-specific
7//! interface** (`bInterfaceClass = 0xFF`) that this module drives with `nusb`
8//! control transfers — the same USB stack the rest of the SDK uses, so no serial
9//! port, no OS serial driver, and no extra dependency.
10//!
11//! # Addressing
12//!
13//! Each dongle carries a unique USB serial (e.g. `DL-1A2B3C4D`), used as its id.
14//! A Mac in DFU enumerates as a USB sibling of the dongle under the same hub, so
15//! [`find_for_ecid`] maps a Mac (by ECID) to the dongle it is plugged into via
16//! USB topology. [`find`] ties both together for callers.
17//!
18//! # Example
19//!
20//! ```no_run
21//! # fn main() -> restorekit::Result<()> {
22//! for d in restorekit::dongle::list()? {
23//!     println!("{} ({})", d.serial, d.product);
24//! }
25//! // Trigger DFU on whatever Mac is cabled to the sole dongle.
26//! restorekit::dongle::find(restorekit::dongle::DongleTarget::Auto)?.dfu()?;
27//! # Ok(()) }
28//! ```
29
30use std::time::{Duration, Instant};
31
32use nusb::transfer::{ControlIn, ControlOut, ControlType, Recipient};
33use nusb::{Interface, MaybeFuture};
34// The USB contract shared with the dongle firmware: VID/PID, string
35// descriptors, and the vendor control protocol.
36use restorekit_dongle_proto as proto;
37
38use crate::device::{self, Device, APPLE_VID};
39use crate::error::{Error, Result};
40
41/// USB vendor ID the dongle enumerates with (MCS Electronics).
42pub const DONGLE_VID: u16 = proto::VID;
43/// USB product ID assigned to RecoverKit. Unique to us, but shared by every
44/// RecoverKit model — the specific model is carried in the iProduct string
45/// (see [`DongleModel::from_product`]), not the PID.
46pub const DONGLE_PID: u16 = proto::PID;
47
48/// Which RecoverKit device this is, derived from its USB iProduct string.
49///
50/// Adding a new model (e.g. Dongle Pro, RecoverKit Pro):
51/// 1. Add its iProduct string to `restorekit-dongle-proto` and set it in that
52///    model's firmware (`config.product`), keeping the shared VID/PID.
53/// 2. Add a variant here and a match arm in [`DongleModel::from_product`].
54///
55/// Nothing else changes: discovery, udev, and the vendor protocol all key off
56/// the shared VID/PID.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
58#[serde(rename_all = "kebab-case")]
59pub enum DongleModel {
60    /// `Dongle-Lite`
61    Lite,
62}
63
64impl DongleModel {
65    /// Identify the model from a USB iProduct string, e.g. `Dongle-Lite`.
66    /// `None` if the string isn't one of ours.
67    pub fn from_product(product: &str) -> Option<Self> {
68        match product {
69            proto::PRODUCT_LITE => Some(Self::Lite),
70            _ => None,
71        }
72    }
73
74    /// Git-tag prefix this model's firmware releases are published under.
75    fn release_tag_prefix(self) -> &'static str {
76        match self {
77            Self::Lite => "dongle-lite-fw-v",
78        }
79    }
80
81    /// Release-asset name of this model's raw update image.
82    fn release_asset(self) -> &'static str {
83        match self {
84            Self::Lite => "dongle-lite-fw.bin",
85        }
86    }
87}
88
89/// GitHub repo firmware releases are published to, via tags like
90/// `dongle-lite-fw-v0.2.0` (see .github/workflows/release-fw.yml).
91const FW_RELEASE_REPO: &str = "fcjr/restorekit";
92
93/// A firmware release published on GitHub.
94#[derive(Debug, Clone, serde::Serialize)]
95pub struct FirmwareRelease {
96    /// Firmware version, e.g. `0.2.0`.
97    pub version: String,
98    /// The release tag, e.g. `dongle-lite-fw-v0.2.0`.
99    pub tag: String,
100    /// Direct download URL of the update image.
101    #[serde(skip)]
102    url: String,
103}
104
105impl FirmwareRelease {
106    /// Whether this release is newer than a dongle's reported version.
107    pub fn newer_than(&self, fw_version: &str) -> bool {
108        match (parse_version(&self.version), parse_version(fw_version)) {
109            (Some(a), Some(b)) => a > b,
110            // An unparseable device version means we can't claim it's current.
111            (Some(_), None) => true,
112            _ => false,
113        }
114    }
115
116    /// Download the update image, ready for [`DongleHandle::update`].
117    pub fn download(&self) -> Result<Vec<u8>> {
118        let resp = crate::firmware::http_client()?
119            .get(&self.url)
120            .send()
121            .map_err(Error::Http)?
122            .error_for_status()
123            .map_err(Error::Http)?;
124        Ok(resp.bytes().map_err(Error::Http)?.to_vec())
125    }
126}
127
128fn parse_version(s: &str) -> Option<(u32, u32, u32)> {
129    let mut it = s.split('.').map(|p| p.parse::<u32>().ok());
130    match (it.next(), it.next(), it.next(), it.next()) {
131        (Some(Some(a)), Some(Some(b)), Some(Some(c)), None) => Some((a, b, c)),
132        _ => None,
133    }
134}
135
136/// The latest firmware release published for `model`, or `None` if there are
137/// no published releases for it (yet).
138pub fn latest_firmware(model: DongleModel) -> Result<Option<FirmwareRelease>> {
139    let releases: serde_json::Value = crate::firmware::http_client()?
140        .get(format!(
141            "https://api.github.com/repos/{FW_RELEASE_REPO}/releases?per_page=100"
142        ))
143        .send()
144        .map_err(Error::Http)?
145        .error_for_status()
146        .map_err(Error::Http)?
147        .json()
148        .map_err(Error::Http)?;
149
150    let mut best: Option<((u32, u32, u32), FirmwareRelease)> = None;
151    for rel in releases.as_array().map(Vec::as_slice).unwrap_or_default() {
152        let tag = rel["tag_name"].as_str().unwrap_or_default();
153        let Some(version) = tag.strip_prefix(model.release_tag_prefix()) else {
154            continue;
155        };
156        let Some(parsed) = parse_version(version) else {
157            continue;
158        };
159        let Some(url) = rel["assets"]
160            .as_array()
161            .map(Vec::as_slice)
162            .unwrap_or_default()
163            .iter()
164            .find(|a| a["name"].as_str() == Some(model.release_asset()))
165            .and_then(|a| a["browser_download_url"].as_str())
166        else {
167            continue;
168        };
169        if best.as_ref().is_none_or(|(v, _)| parsed > *v) {
170            best = Some((
171                parsed,
172                FirmwareRelease {
173                    version: version.to_string(),
174                    tag: tag.to_string(),
175                    url: url.to_string(),
176                },
177            ));
178        }
179    }
180    Ok(best.map(|(_, r)| r))
181}
182
183const CTRL_TIMEOUT: Duration = Duration::from_millis(500);
184// Long enough for the firmware's CC-cycle re-establish + VDM spray (~4-5 s).
185const CMD_TIMEOUT: Duration = Duration::from_secs(8);
186// A firmware-update chunk completes only after the sector is erased and
187// written (~50-100 ms); the final request also CRCs the whole staged image.
188const FW_CHUNK_TIMEOUT: Duration = Duration::from_secs(3);
189const FW_DONE_TIMEOUT: Duration = Duration::from_secs(10);
190
191/// A discovered dongle. Cheap to hold; call [`Dongle::open`] (or the one-shot
192/// [`Dongle::dfu`] / [`Dongle::reboot`]) to act on the cabled Mac.
193#[derive(Debug, Clone, serde::Serialize)]
194pub struct Dongle {
195    /// USB serial number, e.g. `DL-1A2B3C4D`. The stable dongle id.
196    pub serial: String,
197    /// USB product string, e.g. `Dongle-Lite`.
198    pub product: String,
199    /// Which RecoverKit model this is, derived from the product string.
200    pub model: DongleModel,
201    /// USB bus this dongle is on (used to correlate a Mac to its dongle).
202    #[serde(skip)]
203    bus_id: String,
204    /// Physical port path from the root hub (topology correlation).
205    #[serde(skip)]
206    port_chain: Vec<u8>,
207    /// Interface number of the vendor interface to claim.
208    #[serde(skip)]
209    vendor_iface: u8,
210}
211
212/// PD state the dongle reports.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
214#[serde(rename_all = "kebab-case")]
215pub enum PdState {
216    Disconnected,
217    VbusOn,
218    Connected,
219    Accept,
220    Idle,
221    Unknown,
222}
223
224/// A live status snapshot read from the dongle.
225#[derive(Debug, Clone, serde::Serialize)]
226pub struct DongleStatus {
227    /// The dongle's PD state machine position.
228    pub pd_state: PdState,
229    /// Whether a target Mac is currently attached to the dongle's USB-C port.
230    pub target_attached: bool,
231    /// Cable orientation: `true` = CC2 (flipped), `false` = CC1 (normal).
232    pub polarity_cc2: bool,
233    /// Result of the last command (raw firmware code, internal).
234    #[serde(skip)]
235    result: u8,
236}
237
238impl DongleStatus {
239    fn parse(buf: &[u8]) -> Result<Self> {
240        if buf.len() < 4 {
241            return Err(Error::Dongle("short status response from dongle".into()));
242        }
243        let pd_state = match buf[1] {
244            proto::PD_DISCONNECTED => PdState::Disconnected,
245            proto::PD_VBUS_ON => PdState::VbusOn,
246            proto::PD_CONNECTED => PdState::Connected,
247            proto::PD_ACCEPT => PdState::Accept,
248            proto::PD_IDLE => PdState::Idle,
249            _ => PdState::Unknown,
250        };
251        Ok(DongleStatus {
252            pd_state,
253            target_attached: buf[2] & proto::FLAG_TARGET_ATTACHED != 0,
254            polarity_cc2: buf[2] & proto::FLAG_POLARITY_CC2 != 0,
255            result: buf[3],
256        })
257    }
258
259    /// Whether a target Mac is attached and the dongle can act on it, without
260    /// the caller having to reason about the PD state machine.
261    pub fn target_ready(&self) -> bool {
262        self.target_attached
263    }
264}
265
266/// How to pick a dongle.
267#[derive(Debug, Clone)]
268pub enum DongleTarget {
269    /// The only connected dongle; an error if several are present.
270    Auto,
271    /// A specific dongle by its USB serial id.
272    Id(String),
273    /// The dongle the DFU Mac with this ECID is plugged into (USB topology).
274    Ecid(u64),
275}
276
277/// List every connected RecoverKit dongle. Cheap enumeration only.
278pub fn list() -> Result<Vec<Dongle>> {
279    let infos = nusb::list_devices()
280        .wait()
281        .map_err(|e| Error::Usb(e.to_string()))?;
282    let mut out = Vec::new();
283    for info in infos {
284        if info.vendor_id() != DONGLE_VID || info.product_id() != DONGLE_PID {
285            continue;
286        }
287        // All RecoverKit models share the VID/PID; the iProduct string says
288        // which one this is. Models this build doesn't know are skipped.
289        let product = info.product_string().unwrap_or("");
290        let Some(model) = DongleModel::from_product(product) else {
291            continue;
292        };
293        // Only usable if it exposes the vendor interface.
294        let Some(vendor_iface) = info
295            .interfaces()
296            .find(|i| i.class() == proto::VENDOR_CLASS)
297            .map(|i| i.interface_number())
298        else {
299            continue;
300        };
301        out.push(Dongle {
302            serial: info.serial_number().unwrap_or("").to_string(),
303            product: product.to_string(),
304            model,
305            bus_id: info.bus_id().to_string(),
306            port_chain: info.port_chain().to_vec(),
307            vendor_iface,
308        });
309    }
310    Ok(out)
311}
312
313/// Find the single dongle a [`DongleTarget`] selects. Mirrors
314/// [`device::find`](crate::device::find).
315pub fn find(target: DongleTarget) -> Result<Dongle> {
316    match target {
317        DongleTarget::Id(id) => select_by_id(list()?, &id),
318        DongleTarget::Ecid(ecid) => find_for_ecid(ecid),
319        DongleTarget::Auto => {
320            let mut ds = list()?;
321            match ds.len() {
322                0 => Err(Error::NoDongle),
323                1 => Ok(ds.remove(0)),
324                _ => Err(Error::MultipleDongles(serials(&ds))),
325            }
326        }
327    }
328}
329
330/// Pick a dongle by serial: an exact match wins, otherwise any unambiguous
331/// case-insensitive fragment of it (`5f41` for `DL-5F417536`).
332fn select_by_id(ds: Vec<Dongle>, id: &str) -> Result<Dongle> {
333    if let Some(i) = ds.iter().position(|d| d.serial.eq_ignore_ascii_case(id)) {
334        let mut ds = ds;
335        return Ok(ds.swap_remove(i));
336    }
337    let needle = id.to_ascii_lowercase();
338    let mut matches: Vec<Dongle> = ds
339        .into_iter()
340        .filter(|d| d.serial.to_ascii_lowercase().contains(&needle))
341        .collect();
342    match matches.len() {
343        0 => Err(Error::Dongle(format!(
344            "no dongle matching '{id}' (see `restorekit dongle list`)"
345        ))),
346        1 => Ok(matches.remove(0)),
347        _ => Err(Error::MultipleDongles(serials(&matches))),
348    }
349}
350
351fn serials(ds: &[Dongle]) -> String {
352    ds.iter()
353        .map(|d| d.serial.as_str())
354        .collect::<Vec<_>>()
355        .join(", ")
356}
357
358/// Block until a dongle matching `target` is connected, or `timeout` elapses.
359/// Mirrors [`device::wait`](crate::device::wait).
360pub fn wait(target: DongleTarget, timeout: std::time::Duration) -> Result<Dongle> {
361    let deadline = std::time::Instant::now() + timeout;
362    loop {
363        match find(target.clone()) {
364            Ok(d) => return Ok(d),
365            Err(Error::NoDongle) if std::time::Instant::now() < deadline => {
366                std::thread::sleep(std::time::Duration::from_millis(500));
367            }
368            Err(Error::NoDongle) => return Err(Error::NoDongle),
369            Err(e) => return Err(e),
370        }
371    }
372}
373
374/// Find the dongle a DFU Mac is plugged into, by USB topology.
375///
376/// The Mac in DFU enumerates as a sibling of the dongle under the same hub, so
377/// the two share a USB bus and a parent port path. Requires the Mac to already
378/// be USB-visible (in DFU) and cabled through the dongle's hub.
379pub fn find_for_ecid(ecid: u64) -> Result<Dongle> {
380    let infos: Vec<_> = nusb::list_devices()
381        .wait()
382        .map_err(|e| Error::Usb(e.to_string()))?
383        .collect();
384
385    let mac = infos
386        .iter()
387        .find(|&i| i.vendor_id() == APPLE_VID && device::from_usb(i).ecid == Some(ecid))
388        .ok_or(Error::EcidNotConnected(ecid))?;
389
390    list()?
391        .into_iter()
392        .find(|d| shares_parent_hub(d, mac))
393        .ok_or_else(|| {
394            Error::Dongle(format!(
395                "the Mac with ECID {ecid:#x} is not behind a known dongle"
396            ))
397        })
398}
399
400/// True if `mac` and `dongle` are siblings under the same hub: same bus, same
401/// depth, and identical port path except the final (per-port) element.
402fn shares_parent_hub(dongle: &Dongle, mac: &nusb::DeviceInfo) -> bool {
403    let mac_chain = mac.port_chain();
404    mac.bus_id() == dongle.bus_id
405        && !dongle.port_chain.is_empty()
406        && mac_chain.len() == dongle.port_chain.len()
407        && mac_chain[..mac_chain.len() - 1] == dongle.port_chain[..dongle.port_chain.len() - 1]
408}
409
410/// USB device class for a hub.
411const USB_CLASS_HUB: u8 = 0x09;
412
413/// How a device physically reaches this host, for DFU purposes.
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub enum Connection {
416    /// Directly on a host port — the host's own USB-PD DFU trigger can reach it.
417    Direct,
418    /// Behind a RecoverKit dongle (its id). Host DFU can't drive the target's CC
419    /// through the dongle's hub — only dongle DFU works.
420    Dongle(String),
421    /// Behind a plain USB hub. Neither host nor dongle DFU can reach it.
422    Hub,
423}
424
425impl Connection {
426    /// Short kind label: `direct` | `dongle` | `hub`.
427    pub fn kind(&self) -> &'static str {
428        match self {
429            Connection::Direct => "direct",
430            Connection::Dongle(_) => "dongle",
431            Connection::Hub => "hub",
432        }
433    }
434
435    /// The dongle id when reached through one.
436    pub fn dongle(&self) -> Option<&str> {
437        match self {
438            Connection::Dongle(s) => Some(s.as_str()),
439            _ => None,
440        }
441    }
442
443    /// Whether the host's own USB-PD DFU trigger can reach this target (only
444    /// when it's directly on a host port).
445    pub fn host_reachable(&self) -> bool {
446        matches!(self, Connection::Direct)
447    }
448}
449
450/// Determine how `dev` reaches this host by USB topology. Distinguishes a Mac
451/// cabled straight to a host port (host DFU works) from one reached through a
452/// dongle (only dongle DFU works) or a plain hub (neither works) — all three
453/// otherwise look like they're simply "on the DFU port".
454pub fn connection_for(dev: &Device) -> Connection {
455    let infos: Vec<_> = match nusb::list_devices().wait() {
456        Ok(it) => it.collect(),
457        Err(_) => return Connection::Direct,
458    };
459    let Some(me) = infos
460        .iter()
461        .find(|i| i.vendor_id() == APPLE_VID && i.serial_number() == Some(dev.serial.as_str()))
462    else {
463        return Connection::Direct;
464    };
465
466    // Behind a RecoverKit dongle? (Its hub also parents the dongle's MCU.)
467    if let Ok(dongles) = list() {
468        for d in dongles {
469            if shares_parent_hub(&d, me) {
470                return Connection::Dongle(d.serial);
471            }
472        }
473    }
474
475    // Behind any other external hub: a hub-class device on this bus whose port
476    // chain is a strict prefix of ours (a real ancestor, not the root).
477    let chain = me.port_chain();
478    let behind_hub = infos.iter().any(|h| {
479        h.class() == USB_CLASS_HUB
480            && h.bus_id() == me.bus_id()
481            && !h.port_chain().is_empty()
482            && h.port_chain().len() < chain.len()
483            && chain.starts_with(h.port_chain())
484    });
485    if behind_hub {
486        Connection::Hub
487    } else {
488        Connection::Direct
489    }
490}
491
492impl Dongle {
493    /// Open the vendor interface for issuing commands.
494    pub fn open(&self) -> Result<DongleHandle> {
495        // Re-find the live device by serial; the list() snapshot may be stale.
496        let info = nusb::list_devices()
497            .wait()
498            .map_err(|e| Error::Usb(e.to_string()))?
499            .find(|i| {
500                i.vendor_id() == DONGLE_VID
501                    && i.product_id() == DONGLE_PID
502                    && i.serial_number() == Some(self.serial.as_str())
503            })
504            .ok_or(Error::NoDongle)?;
505        let dev = info.open().wait().map_err(|e| Error::Usb(e.to_string()))?;
506        let iface = dev
507            .claim_interface(self.vendor_iface)
508            .wait()
509            .map_err(|e| Error::Usb(e.to_string()))?;
510        Ok(DongleHandle {
511            iface,
512            iface_num: self.vendor_iface,
513        })
514    }
515
516    /// Put the cabled Mac into DFU mode.
517    pub fn dfu(&self) -> Result<()> {
518        self.open()?.dfu()
519    }
520
521    /// Reboot the cabled Mac.
522    pub fn reboot(&self) -> Result<()> {
523        self.open()?.reboot()
524    }
525
526    /// Put the cabled Mac into serial-console mode: the dongle muxes the target's
527    /// debug UART onto SBU and bridges it to its target-serial CDC port.
528    pub fn serial(&self) -> Result<()> {
529        self.open()?.serial()
530    }
531
532    /// Read a live status snapshot.
533    pub fn status(&self) -> Result<DongleStatus> {
534        self.open()?.status()
535    }
536
537    /// Reboot the dongle itself into its USB bootloader (for firmware update).
538    pub fn bootsel(&self) -> Result<()> {
539        self.open()?.bootsel()
540    }
541
542    /// The firmware version the dongle reports, e.g. `0.1.0`.
543    pub fn fw_version(&self) -> Result<String> {
544        self.open()?.fw_version()
545    }
546
547    /// The Apple device currently cabled to this dongle and USB-visible on this
548    /// host (in DFU or any mode), matched by USB topology — the forward of
549    /// [`find_for_ecid`]. `None` if the target's USB data isn't routed to this
550    /// host, or nothing Apple is attached.
551    pub fn attached_device(&self) -> Result<Option<Device>> {
552        let infos = nusb::list_devices()
553            .wait()
554            .map_err(|e| Error::Usb(e.to_string()))?;
555        Ok(infos
556            .filter(|i| i.vendor_id() == APPLE_VID)
557            .find(|i| shares_parent_hub(self, i))
558            .map(|i| device::from_usb(&i)))
559    }
560}
561
562/// An open connection to a dongle's vendor interface.
563pub struct DongleHandle {
564    iface: Interface,
565    iface_num: u8,
566}
567
568impl DongleHandle {
569    /// Put the cabled Mac into DFU mode.
570    pub fn dfu(&self) -> Result<()> {
571        self.command(proto::VCMD_DFU, "dfu")
572    }
573
574    /// Reboot the cabled Mac.
575    pub fn reboot(&self) -> Result<()> {
576        self.command(proto::VCMD_REBOOT, "reboot")
577    }
578
579    /// Mux the Mac's debug UART onto the dongle's SBU serial bridge.
580    pub fn serial(&self) -> Result<()> {
581        self.command(proto::VCMD_SERIAL, "serial")
582    }
583
584    /// Switch the Mac's USB data lines to its debug-USB interface.
585    pub fn debugusb(&self) -> Result<()> {
586        self.command(proto::VCMD_DEBUGUSB, "debugusb")
587    }
588
589    /// Liveness check: no-op that confirms the dongle is responding.
590    pub fn nop(&self) -> Result<()> {
591        self.command(proto::VCMD_NOP, "nop")
592    }
593
594    /// Reboot the dongle itself into its USB bootloader for a firmware update.
595    /// Fire-and-forget: the dongle drops off the bus and re-enumerates as the
596    /// RP2040 bootloader, so there is no status to poll.
597    pub fn bootsel(&self) -> Result<()> {
598        self.vendor_out_raw(proto::VREQ_CMD, proto::VCMD_BOOTSEL, &[], CTRL_TIMEOUT)
599            .map_err(|e| match e {
600                // A stall is the firmware rejecting the request: it predates
601                // USB bootsel.
602                nusb::transfer::TransferError::Stall => Error::Dongle(
603                    "this dongle's firmware predates USB bootsel; type `bootsel` on its \
604                     serial console (CDC0) or replug it with the BOOTSEL button held"
605                        .into(),
606                ),
607                e => Error::Dongle(e.to_string()),
608            })
609    }
610
611    /// Stream a new firmware image to the dongle over the vendor interface —
612    /// no bootloader mode, no mass-storage drive. The image is staged into
613    /// the inactive flash slot, CRC-verified, and swapped in by the dongle's
614    /// bootloader on the reboot this triggers; a bad image is rejected before
615    /// the swap, and one that fails to boot is rolled back.
616    ///
617    /// `image` is the raw app binary (the ACTIVE-slot contents, e.g. from
618    /// `llvm-objcopy -O binary --remove-section=.boot2`), NOT a UF2 or ELF.
619    /// `progress` receives (bytes staged, total bytes).
620    pub fn update(&self, image: &[u8], mut progress: impl FnMut(usize, usize)) -> Result<()> {
621        if image.is_empty() {
622            return Err(Error::Dongle("empty firmware image".into()));
623        }
624        let total = image.len();
625        self.vendor_out_raw(
626            proto::VREQ_FW_BEGIN,
627            0,
628            &(total as u32).to_le_bytes(),
629            CTRL_TIMEOUT,
630        )
631        .map_err(|e| match e {
632            nusb::transfer::TransferError::Stall => Error::Dongle(
633                "the dongle rejected the update: its firmware predates USB updates \
634                 (flash it once over the bootrom with `just fw-flash-full`), or the \
635                 image is too big for its spare slot"
636                    .into(),
637            ),
638            e => Error::Dongle(format!("starting the update: {e}")),
639        })?;
640        let mut chunk_buf = vec![0xFFu8; proto::FW_CHUNK];
641        for (i, chunk) in image.chunks(proto::FW_CHUNK).enumerate() {
642            chunk_buf.fill(0xFF);
643            chunk_buf[..chunk.len()].copy_from_slice(chunk);
644            self.vendor_out(proto::VREQ_FW_DATA, i as u16, &chunk_buf, FW_CHUNK_TIMEOUT)
645                .map_err(|e| {
646                    Error::Dongle(format!(
647                        "update failed at {}/{} bytes: {e}",
648                        i * proto::FW_CHUNK,
649                        total
650                    ))
651                })?;
652            progress((i * proto::FW_CHUNK + chunk.len()).min(total), total);
653        }
654        self.vendor_out(
655            proto::VREQ_FW_DONE,
656            0,
657            &proto::crc32(image).to_le_bytes(),
658            FW_DONE_TIMEOUT,
659        )
660        .map_err(|e| Error::Dongle(format!("update verification failed: {e}")))?;
661        Ok(())
662    }
663
664    /// Vendor control OUT to the dongle's interface.
665    fn vendor_out(&self, request: u8, value: u16, data: &[u8], timeout: Duration) -> Result<()> {
666        self.vendor_out_raw(request, value, data, timeout)
667            .map_err(|e| Error::Dongle(e.to_string()))
668    }
669
670    /// Like [`Self::vendor_out`], but keeps the raw transfer error so callers
671    /// can tell a firmware rejection (stall) from a transport failure.
672    fn vendor_out_raw(
673        &self,
674        request: u8,
675        value: u16,
676        data: &[u8],
677        timeout: Duration,
678    ) -> std::result::Result<(), nusb::transfer::TransferError> {
679        self.iface
680            .control_out(
681                ControlOut {
682                    control_type: ControlType::Vendor,
683                    recipient: Recipient::Interface,
684                    request,
685                    value,
686                    index: self.iface_num as u16,
687                    data,
688                },
689                timeout,
690            )
691            .wait()?;
692        Ok(())
693    }
694
695    /// Read a live status snapshot.
696    pub fn status(&self) -> Result<DongleStatus> {
697        let buf = self.vendor_in(proto::VREQ_STATUS, proto::STATUS_LEN as u16)?;
698        DongleStatus::parse(&buf)
699    }
700
701    /// The firmware version the dongle reports, e.g. `0.1.0`.
702    pub fn fw_version(&self) -> Result<String> {
703        let buf = self.vendor_in(proto::VREQ_VERSION, proto::FW_VERSION_MAX_LEN as u16)?;
704        String::from_utf8(buf).map_err(|_| Error::Dongle("firmware version is not UTF-8".into()))
705    }
706
707    /// Vendor control IN from the dongle's interface.
708    fn vendor_in(&self, request: u8, length: u16) -> Result<Vec<u8>> {
709        self.iface
710            .control_in(
711                ControlIn {
712                    control_type: ControlType::Vendor,
713                    recipient: Recipient::Interface,
714                    request,
715                    value: 0,
716                    index: self.iface_num as u16,
717                    length,
718                },
719                CTRL_TIMEOUT,
720            )
721            .wait()
722            .map_err(|e| Error::Dongle(e.to_string()))
723    }
724
725    /// Send a command and block until the firmware reports its outcome.
726    fn command(&self, code: u16, name: &str) -> Result<()> {
727        self.vendor_out(proto::VREQ_CMD, code, &[], CTRL_TIMEOUT)?;
728
729        // The firmware marks the result pending synchronously in the OUT
730        // handler, so we won't read a stale success from a prior command.
731        let deadline = Instant::now() + CMD_TIMEOUT;
732        loop {
733            match self.status()?.result {
734                proto::RES_PENDING => {}
735                proto::RES_OK => return Ok(()),
736                proto::RES_NOTARGET => return Err(Error::DongleNoTarget),
737                _ => {}
738            }
739            if Instant::now() >= deadline {
740                return Err(Error::Dongle(format!(
741                    "{name}: timed out waiting for the dongle"
742                )));
743            }
744            std::thread::sleep(Duration::from_millis(10));
745        }
746    }
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    fn dongle(serial: &str) -> Dongle {
754        Dongle {
755            serial: serial.into(),
756            product: proto::PRODUCT_LITE.into(),
757            model: DongleModel::Lite,
758            bus_id: String::new(),
759            port_chain: Vec::new(),
760            vendor_iface: 0,
761        }
762    }
763
764    #[test]
765    fn select_by_id_matches_fragments() {
766        let ds = || vec![dongle("DL-5F417536"), dongle("DL-AA00BB11")];
767        // Exact, case-insensitive exact, unique fragment.
768        assert_eq!(
769            select_by_id(ds(), "DL-5F417536").unwrap().serial,
770            "DL-5F417536"
771        );
772        assert_eq!(
773            select_by_id(ds(), "dl-aa00bb11").unwrap().serial,
774            "DL-AA00BB11"
775        );
776        assert_eq!(select_by_id(ds(), "5f41").unwrap().serial, "DL-5F417536");
777        // Ambiguous fragment lists the candidates; no match names the id.
778        match select_by_id(ds(), "DL-") {
779            Err(Error::MultipleDongles(s)) => {
780                assert!(s.contains("DL-5F417536") && s.contains("DL-AA00BB11"))
781            }
782            other => panic!("expected MultipleDongles, got {other:?}"),
783        }
784        assert!(select_by_id(ds(), "zzz").is_err());
785        // An exact serial that is also a fragment of another must win.
786        let mut ds2 = ds();
787        ds2.push(dongle("DL-5F41"));
788        assert_eq!(select_by_id(ds2, "DL-5F41").unwrap().serial, "DL-5F41");
789    }
790}