ostool-server 0.1.2

Server for managing development boards, serial sessions, and TFTP artifacts
use nusb::MaybeFuture;

use crate::api::models::SerialPortSummary;

#[derive(Debug, Clone, PartialEq, Eq)]
struct UsbDeviceRecord {
    vendor_id: u16,
    product_id: u16,
    manufacturer: Option<String>,
    product: Option<String>,
    serial_number: Option<String>,
}

pub fn list_serial_ports() -> anyhow::Result<Vec<SerialPortSummary>> {
    let usb_devices = load_usb_devices().unwrap_or_default();
    let mut ports = serialport::available_ports()?
        .into_iter()
        .map(|port| match port.port_type {
            serialport::SerialPortType::UsbPort(info) => {
                let usb_match = match_usb_device(&usb_devices, &info);
                let manufacturer = usb_match
                    .and_then(|device| device.manufacturer.clone())
                    .or(info.manufacturer.clone());
                let product = usb_match
                    .and_then(|device| device.product.clone())
                    .or(info.product.clone());
                let serial_number = usb_match
                    .and_then(|device| device.serial_number.clone())
                    .or(info.serial_number.clone());

                let mut detail_parts = Vec::new();
                if let Some(manufacturer) = manufacturer.clone() {
                    detail_parts.push(manufacturer);
                }
                if let Some(product) = product.clone() {
                    detail_parts.push(product);
                }
                detail_parts.push(format!("VID:PID {:04x}:{:04x}", info.vid, info.pid));
                if let Some(serial_number) = serial_number.clone() {
                    detail_parts.push(format!("SN {serial_number}"));
                }

                SerialPortSummary {
                    port_name: port.port_name.clone(),
                    port_type: "usb".to_string(),
                    label: if detail_parts.is_empty() {
                        format!("{} (usb)", port.port_name)
                    } else {
                        format!("{} ({})", port.port_name, detail_parts.join(" / "))
                    },
                    usb_vendor_id: Some(info.vid),
                    usb_product_id: Some(info.pid),
                    manufacturer,
                    product,
                    serial_number,
                }
            }
            serialport::SerialPortType::BluetoothPort => SerialPortSummary {
                port_name: port.port_name.clone(),
                port_type: "bluetooth".to_string(),
                label: format!("{} (蓝牙串口)", port.port_name),
                usb_vendor_id: None,
                usb_product_id: None,
                manufacturer: None,
                product: None,
                serial_number: None,
            },
            serialport::SerialPortType::PciPort => SerialPortSummary {
                port_name: port.port_name.clone(),
                port_type: "pci".to_string(),
                label: format!("{} (PCI 串口)", port.port_name),
                usb_vendor_id: None,
                usb_product_id: None,
                manufacturer: None,
                product: None,
                serial_number: None,
            },
            serialport::SerialPortType::Unknown => SerialPortSummary {
                port_name: port.port_name.clone(),
                port_type: "unknown".to_string(),
                label: format!("{} (未知类型)", port.port_name),
                usb_vendor_id: None,
                usb_product_id: None,
                manufacturer: None,
                product: None,
                serial_number: None,
            },
        })
        .collect::<Vec<_>>();

    ports.sort_by(|a, b| a.port_name.cmp(&b.port_name));
    Ok(ports)
}

fn load_usb_devices() -> anyhow::Result<Vec<UsbDeviceRecord>> {
    let devices = nusb::list_devices()
        .wait()?
        .map(|device| UsbDeviceRecord {
            vendor_id: device.vendor_id(),
            product_id: device.product_id(),
            manufacturer: device.manufacturer_string().map(ToOwned::to_owned),
            product: device.product_string().map(ToOwned::to_owned),
            serial_number: device.serial_number().map(ToOwned::to_owned),
        })
        .collect::<Vec<_>>();
    Ok(devices)
}

fn match_usb_device<'a>(
    usb_devices: &'a [UsbDeviceRecord],
    info: &serialport::UsbPortInfo,
) -> Option<&'a UsbDeviceRecord> {
    if let Some(serial_number) = info.serial_number.as_deref()
        && let Some(exact) = usb_devices.iter().find(|device| {
            device.vendor_id == info.vid
                && device.product_id == info.pid
                && device.serial_number.as_deref() == Some(serial_number)
        })
    {
        return Some(exact);
    }

    if let Some(product) = info.product.as_deref()
        && let Some(exact) = usb_devices.iter().find(|device| {
            device.vendor_id == info.vid
                && device.product_id == info.pid
                && device.product.as_deref() == Some(product)
        })
    {
        return Some(exact);
    }

    let candidates = usb_devices
        .iter()
        .filter(|device| device.vendor_id == info.vid && device.product_id == info.pid)
        .collect::<Vec<_>>();

    if candidates.len() == 1 {
        candidates.into_iter().next()
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::{UsbDeviceRecord, match_usb_device};

    fn usb_info(
        vid: u16,
        pid: u16,
        manufacturer: Option<&str>,
        product: Option<&str>,
        serial_number: Option<&str>,
    ) -> serialport::UsbPortInfo {
        serialport::UsbPortInfo {
            vid,
            pid,
            serial_number: serial_number.map(ToOwned::to_owned),
            manufacturer: manufacturer.map(ToOwned::to_owned),
            product: product.map(ToOwned::to_owned),
        }
    }

    #[test]
    fn match_usb_device_prefers_serial_number() {
        let devices = vec![
            UsbDeviceRecord {
                vendor_id: 0x1a86,
                product_id: 0x7523,
                manufacturer: Some("QinHeng".into()),
                product: Some("USB2.0-Serial".into()),
                serial_number: Some("A".into()),
            },
            UsbDeviceRecord {
                vendor_id: 0x1a86,
                product_id: 0x7523,
                manufacturer: Some("QinHeng".into()),
                product: Some("USB2.0-Serial".into()),
                serial_number: Some("B".into()),
            },
        ];

        let matched =
            match_usb_device(&devices, &usb_info(0x1a86, 0x7523, None, None, Some("B"))).unwrap();

        assert_eq!(matched.serial_number.as_deref(), Some("B"));
    }

    #[test]
    fn match_usb_device_falls_back_to_unique_vid_pid() {
        let devices = vec![UsbDeviceRecord {
            vendor_id: 0x10c4,
            product_id: 0xea60,
            manufacturer: Some("Silicon Labs".into()),
            product: Some("CP2102".into()),
            serial_number: None,
        }];

        let matched =
            match_usb_device(&devices, &usb_info(0x10c4, 0xea60, None, None, None)).unwrap();

        assert_eq!(matched.product.as_deref(), Some("CP2102"));
    }
}