use std::time::{Duration, Instant};
use nusb::transfer::{ControlIn, ControlOut, ControlType, Recipient};
use nusb::{Interface, MaybeFuture};
use restorekit_dongle_proto as proto;
use crate::device::{self, Device, APPLE_VID};
use crate::error::{Error, Result};
pub const DONGLE_VID: u16 = proto::VID;
pub const DONGLE_PID: u16 = proto::PID;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DongleModel {
Lite,
}
impl DongleModel {
pub fn from_product(product: &str) -> Option<Self> {
match product {
proto::PRODUCT_LITE => Some(Self::Lite),
_ => None,
}
}
fn release_tag_prefix(self) -> &'static str {
match self {
Self::Lite => "dongle-lite-fw-v",
}
}
fn release_asset(self) -> &'static str {
match self {
Self::Lite => "dongle-lite-fw.bin",
}
}
}
const FW_RELEASE_REPO: &str = "fcjr/restorekit";
#[derive(Debug, Clone, serde::Serialize)]
pub struct FirmwareRelease {
pub version: String,
pub tag: String,
#[serde(skip)]
url: String,
}
impl FirmwareRelease {
pub fn newer_than(&self, fw_version: &str) -> bool {
match (parse_version(&self.version), parse_version(fw_version)) {
(Some(a), Some(b)) => a > b,
(Some(_), None) => true,
_ => false,
}
}
pub fn download(&self) -> Result<Vec<u8>> {
let resp = crate::firmware::http_client()?
.get(&self.url)
.send()
.map_err(Error::Http)?
.error_for_status()
.map_err(Error::Http)?;
Ok(resp.bytes().map_err(Error::Http)?.to_vec())
}
}
fn parse_version(s: &str) -> Option<(u32, u32, u32)> {
let mut it = s.split('.').map(|p| p.parse::<u32>().ok());
match (it.next(), it.next(), it.next(), it.next()) {
(Some(Some(a)), Some(Some(b)), Some(Some(c)), None) => Some((a, b, c)),
_ => None,
}
}
pub fn latest_firmware(model: DongleModel) -> Result<Option<FirmwareRelease>> {
let releases: serde_json::Value = crate::firmware::http_client()?
.get(format!(
"https://api.github.com/repos/{FW_RELEASE_REPO}/releases?per_page=100"
))
.send()
.map_err(Error::Http)?
.error_for_status()
.map_err(Error::Http)?
.json()
.map_err(Error::Http)?;
let mut best: Option<((u32, u32, u32), FirmwareRelease)> = None;
for rel in releases.as_array().map(Vec::as_slice).unwrap_or_default() {
let tag = rel["tag_name"].as_str().unwrap_or_default();
let Some(version) = tag.strip_prefix(model.release_tag_prefix()) else {
continue;
};
let Some(parsed) = parse_version(version) else {
continue;
};
let Some(url) = rel["assets"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.find(|a| a["name"].as_str() == Some(model.release_asset()))
.and_then(|a| a["browser_download_url"].as_str())
else {
continue;
};
if best.as_ref().is_none_or(|(v, _)| parsed > *v) {
best = Some((
parsed,
FirmwareRelease {
version: version.to_string(),
tag: tag.to_string(),
url: url.to_string(),
},
));
}
}
Ok(best.map(|(_, r)| r))
}
const CTRL_TIMEOUT: Duration = Duration::from_millis(500);
const CMD_TIMEOUT: Duration = Duration::from_secs(8);
const FW_CHUNK_TIMEOUT: Duration = Duration::from_secs(3);
const FW_DONE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, serde::Serialize)]
pub struct Dongle {
pub serial: String,
pub product: String,
pub model: DongleModel,
#[serde(skip)]
bus_id: String,
#[serde(skip)]
port_chain: Vec<u8>,
#[serde(skip)]
vendor_iface: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum PdState {
Disconnected,
VbusOn,
Connected,
Accept,
Idle,
Unknown,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DongleStatus {
pub pd_state: PdState,
pub target_attached: bool,
pub polarity_cc2: bool,
#[serde(skip)]
result: u8,
}
impl DongleStatus {
fn parse(buf: &[u8]) -> Result<Self> {
if buf.len() < 4 {
return Err(Error::Dongle("short status response from dongle".into()));
}
let pd_state = match buf[1] {
proto::PD_DISCONNECTED => PdState::Disconnected,
proto::PD_VBUS_ON => PdState::VbusOn,
proto::PD_CONNECTED => PdState::Connected,
proto::PD_ACCEPT => PdState::Accept,
proto::PD_IDLE => PdState::Idle,
_ => PdState::Unknown,
};
Ok(DongleStatus {
pd_state,
target_attached: buf[2] & proto::FLAG_TARGET_ATTACHED != 0,
polarity_cc2: buf[2] & proto::FLAG_POLARITY_CC2 != 0,
result: buf[3],
})
}
pub fn target_ready(&self) -> bool {
self.target_attached
}
}
#[derive(Debug, Clone)]
pub enum DongleTarget {
Auto,
Id(String),
Ecid(u64),
}
pub fn list() -> Result<Vec<Dongle>> {
let infos = nusb::list_devices()
.wait()
.map_err(|e| Error::Usb(e.to_string()))?;
let mut out = Vec::new();
for info in infos {
if info.vendor_id() != DONGLE_VID || info.product_id() != DONGLE_PID {
continue;
}
let product = info.product_string().unwrap_or("");
let Some(model) = DongleModel::from_product(product) else {
continue;
};
let Some(vendor_iface) = info
.interfaces()
.find(|i| i.class() == proto::VENDOR_CLASS)
.map(|i| i.interface_number())
else {
continue;
};
out.push(Dongle {
serial: info.serial_number().unwrap_or("").to_string(),
product: product.to_string(),
model,
bus_id: info.bus_id().to_string(),
port_chain: info.port_chain().to_vec(),
vendor_iface,
});
}
Ok(out)
}
pub fn find(target: DongleTarget) -> Result<Dongle> {
match target {
DongleTarget::Id(id) => select_by_id(list()?, &id),
DongleTarget::Ecid(ecid) => find_for_ecid(ecid),
DongleTarget::Auto => {
let mut ds = list()?;
match ds.len() {
0 => Err(Error::NoDongle),
1 => Ok(ds.remove(0)),
_ => Err(Error::MultipleDongles(serials(&ds))),
}
}
}
}
fn select_by_id(ds: Vec<Dongle>, id: &str) -> Result<Dongle> {
if let Some(i) = ds.iter().position(|d| d.serial.eq_ignore_ascii_case(id)) {
let mut ds = ds;
return Ok(ds.swap_remove(i));
}
let needle = id.to_ascii_lowercase();
let mut matches: Vec<Dongle> = ds
.into_iter()
.filter(|d| d.serial.to_ascii_lowercase().contains(&needle))
.collect();
match matches.len() {
0 => Err(Error::Dongle(format!(
"no dongle matching '{id}' (see `restorekit dongle list`)"
))),
1 => Ok(matches.remove(0)),
_ => Err(Error::MultipleDongles(serials(&matches))),
}
}
fn serials(ds: &[Dongle]) -> String {
ds.iter()
.map(|d| d.serial.as_str())
.collect::<Vec<_>>()
.join(", ")
}
pub fn wait(target: DongleTarget, timeout: std::time::Duration) -> Result<Dongle> {
let deadline = std::time::Instant::now() + timeout;
loop {
match find(target.clone()) {
Ok(d) => return Ok(d),
Err(Error::NoDongle) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(Error::NoDongle) => return Err(Error::NoDongle),
Err(e) => return Err(e),
}
}
}
pub fn find_for_ecid(ecid: u64) -> Result<Dongle> {
let infos: Vec<_> = nusb::list_devices()
.wait()
.map_err(|e| Error::Usb(e.to_string()))?
.collect();
let mac = infos
.iter()
.find(|&i| i.vendor_id() == APPLE_VID && device::from_usb(i).ecid == Some(ecid))
.ok_or(Error::EcidNotConnected(ecid))?;
list()?
.into_iter()
.find(|d| shares_parent_hub(d, mac))
.ok_or_else(|| {
Error::Dongle(format!(
"the Mac with ECID {ecid:#x} is not behind a known dongle"
))
})
}
fn shares_parent_hub(dongle: &Dongle, mac: &nusb::DeviceInfo) -> bool {
let mac_chain = mac.port_chain();
mac.bus_id() == dongle.bus_id
&& !dongle.port_chain.is_empty()
&& mac_chain.len() == dongle.port_chain.len()
&& mac_chain[..mac_chain.len() - 1] == dongle.port_chain[..dongle.port_chain.len() - 1]
}
const USB_CLASS_HUB: u8 = 0x09;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Connection {
Direct,
Dongle(String),
Hub,
}
impl Connection {
pub fn kind(&self) -> &'static str {
match self {
Connection::Direct => "direct",
Connection::Dongle(_) => "dongle",
Connection::Hub => "hub",
}
}
pub fn dongle(&self) -> Option<&str> {
match self {
Connection::Dongle(s) => Some(s.as_str()),
_ => None,
}
}
pub fn host_reachable(&self) -> bool {
matches!(self, Connection::Direct)
}
}
pub fn connection_for(dev: &Device) -> Connection {
let infos: Vec<_> = match nusb::list_devices().wait() {
Ok(it) => it.collect(),
Err(_) => return Connection::Direct,
};
let Some(me) = infos
.iter()
.find(|i| i.vendor_id() == APPLE_VID && i.serial_number() == Some(dev.serial.as_str()))
else {
return Connection::Direct;
};
if let Ok(dongles) = list() {
for d in dongles {
if shares_parent_hub(&d, me) {
return Connection::Dongle(d.serial);
}
}
}
let chain = me.port_chain();
let behind_hub = infos.iter().any(|h| {
h.class() == USB_CLASS_HUB
&& h.bus_id() == me.bus_id()
&& !h.port_chain().is_empty()
&& h.port_chain().len() < chain.len()
&& chain.starts_with(h.port_chain())
});
if behind_hub {
Connection::Hub
} else {
Connection::Direct
}
}
impl Dongle {
pub fn open(&self) -> Result<DongleHandle> {
let info = nusb::list_devices()
.wait()
.map_err(|e| Error::Usb(e.to_string()))?
.find(|i| {
i.vendor_id() == DONGLE_VID
&& i.product_id() == DONGLE_PID
&& i.serial_number() == Some(self.serial.as_str())
})
.ok_or(Error::NoDongle)?;
let dev = info.open().wait().map_err(|e| Error::Usb(e.to_string()))?;
let iface = dev
.claim_interface(self.vendor_iface)
.wait()
.map_err(|e| Error::Usb(e.to_string()))?;
Ok(DongleHandle {
iface,
iface_num: self.vendor_iface,
})
}
pub fn dfu(&self) -> Result<()> {
self.open()?.dfu()
}
pub fn reboot(&self) -> Result<()> {
self.open()?.reboot()
}
pub fn serial(&self) -> Result<()> {
self.open()?.serial()
}
pub fn status(&self) -> Result<DongleStatus> {
self.open()?.status()
}
pub fn bootsel(&self) -> Result<()> {
self.open()?.bootsel()
}
pub fn fw_version(&self) -> Result<String> {
self.open()?.fw_version()
}
pub fn attached_device(&self) -> Result<Option<Device>> {
let infos = nusb::list_devices()
.wait()
.map_err(|e| Error::Usb(e.to_string()))?;
Ok(infos
.filter(|i| i.vendor_id() == APPLE_VID)
.find(|i| shares_parent_hub(self, i))
.map(|i| device::from_usb(&i)))
}
}
pub struct DongleHandle {
iface: Interface,
iface_num: u8,
}
impl DongleHandle {
pub fn dfu(&self) -> Result<()> {
self.command(proto::VCMD_DFU, "dfu")
}
pub fn reboot(&self) -> Result<()> {
self.command(proto::VCMD_REBOOT, "reboot")
}
pub fn serial(&self) -> Result<()> {
self.command(proto::VCMD_SERIAL, "serial")
}
pub fn debugusb(&self) -> Result<()> {
self.command(proto::VCMD_DEBUGUSB, "debugusb")
}
pub fn nop(&self) -> Result<()> {
self.command(proto::VCMD_NOP, "nop")
}
pub fn bootsel(&self) -> Result<()> {
self.vendor_out_raw(proto::VREQ_CMD, proto::VCMD_BOOTSEL, &[], CTRL_TIMEOUT)
.map_err(|e| match e {
nusb::transfer::TransferError::Stall => Error::Dongle(
"this dongle's firmware predates USB bootsel; type `bootsel` on its \
serial console (CDC0) or replug it with the BOOTSEL button held"
.into(),
),
e => Error::Dongle(e.to_string()),
})
}
pub fn update(&self, image: &[u8], mut progress: impl FnMut(usize, usize)) -> Result<()> {
if image.is_empty() {
return Err(Error::Dongle("empty firmware image".into()));
}
let total = image.len();
self.vendor_out_raw(
proto::VREQ_FW_BEGIN,
0,
&(total as u32).to_le_bytes(),
CTRL_TIMEOUT,
)
.map_err(|e| match e {
nusb::transfer::TransferError::Stall => Error::Dongle(
"the dongle rejected the update: its firmware predates USB updates \
(flash it once over the bootrom with `just fw-flash-full`), or the \
image is too big for its spare slot"
.into(),
),
e => Error::Dongle(format!("starting the update: {e}")),
})?;
let mut chunk_buf = vec![0xFFu8; proto::FW_CHUNK];
for (i, chunk) in image.chunks(proto::FW_CHUNK).enumerate() {
chunk_buf.fill(0xFF);
chunk_buf[..chunk.len()].copy_from_slice(chunk);
self.vendor_out(proto::VREQ_FW_DATA, i as u16, &chunk_buf, FW_CHUNK_TIMEOUT)
.map_err(|e| {
Error::Dongle(format!(
"update failed at {}/{} bytes: {e}",
i * proto::FW_CHUNK,
total
))
})?;
progress((i * proto::FW_CHUNK + chunk.len()).min(total), total);
}
self.vendor_out(
proto::VREQ_FW_DONE,
0,
&proto::crc32(image).to_le_bytes(),
FW_DONE_TIMEOUT,
)
.map_err(|e| Error::Dongle(format!("update verification failed: {e}")))?;
Ok(())
}
fn vendor_out(&self, request: u8, value: u16, data: &[u8], timeout: Duration) -> Result<()> {
self.vendor_out_raw(request, value, data, timeout)
.map_err(|e| Error::Dongle(e.to_string()))
}
fn vendor_out_raw(
&self,
request: u8,
value: u16,
data: &[u8],
timeout: Duration,
) -> std::result::Result<(), nusb::transfer::TransferError> {
self.iface
.control_out(
ControlOut {
control_type: ControlType::Vendor,
recipient: Recipient::Interface,
request,
value,
index: self.iface_num as u16,
data,
},
timeout,
)
.wait()?;
Ok(())
}
pub fn status(&self) -> Result<DongleStatus> {
let buf = self.vendor_in(proto::VREQ_STATUS, proto::STATUS_LEN as u16)?;
DongleStatus::parse(&buf)
}
pub fn fw_version(&self) -> Result<String> {
let buf = self.vendor_in(proto::VREQ_VERSION, proto::FW_VERSION_MAX_LEN as u16)?;
String::from_utf8(buf).map_err(|_| Error::Dongle("firmware version is not UTF-8".into()))
}
fn vendor_in(&self, request: u8, length: u16) -> Result<Vec<u8>> {
self.iface
.control_in(
ControlIn {
control_type: ControlType::Vendor,
recipient: Recipient::Interface,
request,
value: 0,
index: self.iface_num as u16,
length,
},
CTRL_TIMEOUT,
)
.wait()
.map_err(|e| Error::Dongle(e.to_string()))
}
fn command(&self, code: u16, name: &str) -> Result<()> {
self.vendor_out(proto::VREQ_CMD, code, &[], CTRL_TIMEOUT)?;
let deadline = Instant::now() + CMD_TIMEOUT;
loop {
match self.status()?.result {
proto::RES_PENDING => {}
proto::RES_OK => return Ok(()),
proto::RES_NOTARGET => return Err(Error::DongleNoTarget),
_ => {}
}
if Instant::now() >= deadline {
return Err(Error::Dongle(format!(
"{name}: timed out waiting for the dongle"
)));
}
std::thread::sleep(Duration::from_millis(10));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dongle(serial: &str) -> Dongle {
Dongle {
serial: serial.into(),
product: proto::PRODUCT_LITE.into(),
model: DongleModel::Lite,
bus_id: String::new(),
port_chain: Vec::new(),
vendor_iface: 0,
}
}
#[test]
fn select_by_id_matches_fragments() {
let ds = || vec![dongle("DL-5F417536"), dongle("DL-AA00BB11")];
assert_eq!(
select_by_id(ds(), "DL-5F417536").unwrap().serial,
"DL-5F417536"
);
assert_eq!(
select_by_id(ds(), "dl-aa00bb11").unwrap().serial,
"DL-AA00BB11"
);
assert_eq!(select_by_id(ds(), "5f41").unwrap().serial, "DL-5F417536");
match select_by_id(ds(), "DL-") {
Err(Error::MultipleDongles(s)) => {
assert!(s.contains("DL-5F417536") && s.contains("DL-AA00BB11"))
}
other => panic!("expected MultipleDongles, got {other:?}"),
}
assert!(select_by_id(ds(), "zzz").is_err());
let mut ds2 = ds();
ds2.push(dongle("DL-5F41"));
assert_eq!(select_by_id(ds2, "DL-5F41").unwrap().serial, "DL-5F41");
}
}