diff --git a/Cargo.lock b/Cargo.lock index 2bbfa066..7605e68a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8064,6 +8064,7 @@ dependencies = [ "rand 0.8.5", "reqwest 0.12.24", "secrecy 0.8.0", + "serde", "serde_json", "tempfile", "thiserror 1.0.69", diff --git a/hil/Cargo.toml b/hil/Cargo.toml index 09dac352..e0feb089 100644 --- a/hil/Cargo.toml +++ b/hil/Cargo.toml @@ -35,6 +35,7 @@ probe-rs = "0.27.0" rand = "0.8" reqwest = { workspace = true, default-features = false, features = ["rustls-tls"] } secrecy.workspace = true +serde = { workspace = true, features = ["derive"] } serde_json.workspace = true tempfile = "3" thiserror.workspace = true diff --git a/hil/README.md b/hil/README.md index 8fd9926d..ca34de6d 100644 --- a/hil/README.md +++ b/hil/README.md @@ -8,3 +8,40 @@ ors-os artifacts from S3 and pass that as an env var. See [here][aws cli config] info. [aws cli config]: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html + +## Debug board configuration + +⚠️ **Warning**: EEPROM programming is a critical operation. Incorrect values can render your device unusable. Always +read and backup the current EEPROM before writing, and verify the configuration values before programming. + +With debug board v1.1 comes an embedded EEPROM that can be programmed to set a configuration +to the FTDI chip, such as the serial number. +This serial number enables selection among a few different ones connected to the host. + +Use the `ftdi` command to read & write the config: + +```sh +# Backup current config first +orb-hil ftdi read --file backup.json + +# Then write new config +orb-hil ftdi write ftdi_config.json +``` + +Here is an example of an FTDI configuration (please set a working serial, like the id of the orb it's connected to): + +```json +{ + "vendor_id": 1027, + "product_id": 24593, + "serial_number_enable": true, + "max_current_ma": 500, + "self_powered": false, + "remote_wakeup": false, + "pull_down_enable": true, + "manufacturer": "FTDI", + "manufacturer_id": "FT", + "description": "FT4232H", + "serial_number": "cafebabe" +} +``` diff --git a/hil/src/boot.rs b/hil/src/boot.rs index 9116fd93..dc4d74a9 100644 --- a/hil/src/boot.rs +++ b/hil/src/boot.rs @@ -1,11 +1,11 @@ use std::time::Duration; -use crate::ftdi::{FtdiGpio, FtdiId, OutputState}; +use crate::ftdi::{FtdiChannel, FtdiGpio, FtdiId, OutputState}; use color_eyre::{eyre::WrapErr as _, Result}; use tracing::{debug, info}; -pub const BUTTON_PIN: crate::ftdi::Pin = FtdiGpio::CTS_PIN; -pub const RECOVERY_PIN: crate::ftdi::Pin = FtdiGpio::RTS_PIN; +pub const BUTTON_PIN: crate::ftdi::Pin = FtdiGpio::DTR_PIN; +pub const RECOVERY_PIN: crate::ftdi::Pin = FtdiGpio::CTS_PIN; pub const NVIDIA_VENDOR_ID: u16 = 0x0955; pub const NVIDIA_USB_ETHERNET: u16 = 0x7035; @@ -20,15 +20,29 @@ pub async fn is_recovery_mode_detected() -> Result { Ok(num_nvidia_devices > 0) } -/// If `device` is `None`, will get the first available device. +/// The default channel used for button/recovery control on the HIL. +pub const DEFAULT_CHANNEL: FtdiChannel = FtdiChannel::C; + +/// If `device` is `None`, will get the first available device, or fall back to +/// the default channel (FT4232H C) if multiple devices are found. #[tracing::instrument] pub async fn reboot(recovery: bool, device: Option<&FtdiId>) -> Result<()> { fn make_ftdi(device: Option) -> Result { let builder = FtdiGpio::builder(); let builder = match &device { Some(FtdiId::Description(desc)) => builder.with_description(desc), - Some(FtdiId::SerialNumber(serial)) => builder.with_serial_number(serial), - None => builder.with_default_device(), + Some(FtdiId::FtdiSerial(serial)) => builder.with_ftdi_serial(serial), + None => match builder.with_default_device() { + Ok(b) => return b.configure().wrap_err("failed to configure ftdi"), + Err(e) => { + tracing::error!("failed to build default ftdi: {}, trying with default channel name", e); + // Fall back to default channel when multiple devices exist + return FtdiGpio::builder() + .with_description(DEFAULT_CHANNEL.description_suffix()) + .and_then(|b| b.configure()) + .wrap_err("failed to create ftdi device with default channel"); + } + }, }; builder .and_then(|b| b.configure()) diff --git a/hil/src/commands/button_ctrl.rs b/hil/src/commands/button_ctrl.rs index 9f287108..2aedb761 100644 --- a/hil/src/commands/button_ctrl.rs +++ b/hil/src/commands/button_ctrl.rs @@ -2,37 +2,112 @@ use clap::Parser; use color_eyre::{eyre::WrapErr as _, Result}; use humantime::parse_duration; use std::time::Duration; -use tracing::info; +use tracing::{debug, info}; -use crate::boot::BUTTON_PIN; -use crate::ftdi::{FtdiGpio, OutputState}; +use crate::boot::{BUTTON_PIN, DEFAULT_CHANNEL}; +use crate::ftdi::{FtdiChannel, FtdiGpio, OutputState}; +/// Control the orb button over the debug board #[derive(Debug, Parser)] pub struct ButtonCtrl { - ///Button press duration (e.g., "1s", "500ms") + /// Button press duration (e.g., "1s", "500ms") #[arg(long, default_value = "1s", value_parser = parse_duration)] press_duration: Duration, + + /// The USB serial number of the FTDI chip (e.g., "FT7ABC12"). + /// Will use the default channel (C) for this chip. + #[arg(long, conflicts_with_all = ["ftdi_serial", "desc"])] + usb_serial: Option, + + /// The FTDI serial number including channel (e.g., "FT7ABC12C"). + /// This is the USB serial + channel letter (A/B/C/D). + #[arg(long, conflicts_with_all = ["usb_serial", "desc"])] + ftdi_serial: Option, + + /// The FTDI description (e.g., "FT4232H C"). + #[arg(long, conflicts_with_all = ["usb_serial", "ftdi_serial"])] + desc: Option, + + /// The channel to use when --usb-serial is provided (A, B, C, or D). + /// Defaults to C. + #[arg(long, default_value = "C", requires = "usb_serial")] + channel: FtdiChannel, } impl ButtonCtrl { pub async fn run(self) -> Result<()> { - fn make_ftdi() -> Result { - FtdiGpio::builder() - .with_default_device() - .and_then(|b| b.configure()) - .wrap_err("failed to create ftdi device") - } + let usb_serial = self.usb_serial.clone(); + let ftdi_serial = self.ftdi_serial.clone(); + let desc = self.desc.clone(); + let channel = self.channel; + + let make_ftdi = move || -> Result { + let builder = FtdiGpio::builder(); + match (usb_serial.as_ref(), ftdi_serial.as_ref(), desc.as_ref()) { + (Some(usb_serial), None, None) => { + debug!( + "using FTDI device with USB serial: {usb_serial}, channel: {:?}", + channel + ); + builder + .with_usb_serial(usb_serial, channel) + .and_then(|b| b.configure()) + .wrap_err("failed to create ftdi device with USB serial") + } + (None, Some(ftdi_serial), None) => { + debug!("using FTDI device with FTDI serial: {ftdi_serial}"); + builder + .with_ftdi_serial(ftdi_serial) + .and_then(|b| b.configure()) + .wrap_err("failed to create ftdi device with FTDI serial") + } + (None, None, Some(desc)) => { + debug!("using FTDI device with description: {desc}"); + builder + .with_description(desc) + .and_then(|b| b.configure()) + .wrap_err("failed to create ftdi device with description") + } + (None, None, None) => { + // Try default device first, fall back to default channel description + match builder.with_default_device() { + Ok(b) => { + b.configure().wrap_err("failed to configure ftdi device") + } + Err(e) => { + debug!("default device selection failed: {e}"); + let desc_suffix = DEFAULT_CHANNEL.description_suffix(); + debug!( + "attempting to find device with description '{desc_suffix}'" + ); + FtdiGpio::builder() + .with_description(desc_suffix) + .and_then(|b| b.configure()) + .wrap_err_with(|| { + format!( + "failed to open FTDI device with description \ + '{desc_suffix}'" + ) + }) + } + } + } + _ => unreachable!(), + } + }; info!( "Holding button for {} seconds", self.press_duration.as_secs_f32() ); + let press_duration = self.press_duration; tokio::task::spawn_blocking(move || -> Result<(), color_eyre::Report> { let mut ftdi = make_ftdi()?; ftdi.set_pin(BUTTON_PIN, OutputState::Low)?; - std::thread::sleep(self.press_duration); + std::thread::sleep(press_duration); ftdi.set_pin(BUTTON_PIN, OutputState::High)?; ftdi.destroy().wrap_err("failed to destroy ftdi")?; + Ok(()) }) .await diff --git a/hil/src/commands/debug_board.rs b/hil/src/commands/debug_board.rs new file mode 100644 index 00000000..bc2b98c0 --- /dev/null +++ b/hil/src/commands/debug_board.rs @@ -0,0 +1,388 @@ +//! Debug board (FTDI FT4232H) device operations. + +use camino::Utf8PathBuf; +use clap::{Parser, Subcommand}; +use color_eyre::{ + eyre::{bail, ensure, eyre, WrapErr as _}, + Result, +}; +use libftd2xx::{Eeprom4232h, EepromStrings, Ft4232h, Ftdi, FtdiEeprom}; +use nusb::MaybeFuture; +use serde::{Deserialize, Serialize}; +use tracing::{debug, info}; + +use crate::ftdi::{detach_all_ftdi_kernel_drivers, strip_channel_suffix, FtdiGpio}; + +/// Debug board (FTDI FT4232H) operations +#[derive(Debug, Parser)] +pub struct DebugBoardCmd { + #[command(subcommand)] + command: DebugBoardSubcommand, +} + +#[derive(Debug, Subcommand)] +enum DebugBoardSubcommand { + /// List all connected FTDI devices/channels + List(ListCmd), + /// Read EEPROM content and dump to a file + Read(ReadCmd), + /// Write EEPROM content from a file + Write(WriteCmd), +} + +/// List all connected FTDI devices +#[derive(Debug, Parser)] +struct ListCmd; + +/// Read EEPROM content to a file or stdout +#[derive(Debug, Parser)] +struct ReadCmd { + /// Output file path (JSON format). If not specified, prints to stdout. + #[arg(long, short)] + file: Option, + + /// The USB serial number of the FTDI chip (e.g., "FT7ABC12"). + /// Note: EEPROM is shared across all channels of a chip, so any channel + /// works for reading/writing EEPROM. + #[arg(long, conflicts_with = "desc")] + usb_serial: Option, + + /// The FTDI description (e.g., "FT4232H A"). + /// Any channel description works since EEPROM is chip-wide. + #[arg(long, conflicts_with = "usb_serial")] + desc: Option, +} + +/// Write EEPROM content from a file +#[derive(Debug, Parser)] +struct WriteCmd { + /// Input file path (JSON format) + input: Utf8PathBuf, + + /// The USB serial number of the FTDI chip (e.g., "FT7ABC12"). + /// Note: EEPROM is shared across all channels of a chip, so any channel + /// works for reading/writing EEPROM. + #[arg(long, conflicts_with = "desc")] + usb_serial: Option, + + /// The FTDI description (e.g., "FT4232H A"). + /// Any channel description works since EEPROM is chip-wide. + #[arg(long, conflicts_with = "usb_serial")] + desc: Option, +} + +/// Serializable representation of EEPROM data for FT4232H. +#[derive(Debug, Serialize, Deserialize)] +struct Ft4232hEepromData { + /// Vendor ID (typically 0x0403 for FTDI) + vendor_id: u16, + /// Product ID (typically 0x6011 for FT4232H) + product_id: u16, + /// Whether the serial number is enabled + serial_number_enable: bool, + /// Maximum bus current in milliamps (0-500) + max_current_ma: u16, + /// Self-powered device + self_powered: bool, + /// Remote wakeup capable + remote_wakeup: bool, + /// Pull-down in suspend enabled + pull_down_enable: bool, + + // String fields + /// Manufacturer string + manufacturer: String, + /// Manufacturer ID + manufacturer_id: String, + /// Product description + description: String, + /// Serial number + serial_number: String, +} + +impl Ft4232hEepromData { + fn from_eeprom(eeprom: &Eeprom4232h, strings: &EepromStrings) -> Self { + let header = eeprom.header(); + Self { + vendor_id: header.vendor_id(), + product_id: header.product_id(), + serial_number_enable: header.serial_number_enable(), + max_current_ma: header.max_current(), + self_powered: header.self_powered(), + remote_wakeup: header.remote_wakeup(), + pull_down_enable: header.pull_down_enable(), + + manufacturer: strings.manufacturer(), + manufacturer_id: strings.manufacturer_id(), + description: strings.description(), + serial_number: strings.serial_number(), + } + } + + fn to_eeprom(&self) -> Result<(Eeprom4232h, EepromStrings)> { + let mut eeprom = Eeprom4232h::default(); + let mut header = eeprom.header(); + + header.set_device_type(libftd2xx::DeviceType::FT4232H); + header.set_vendor_id(self.vendor_id); + header.set_product_id(self.product_id); + header.set_serial_number_enable(self.serial_number_enable); + header.set_max_current(self.max_current_ma); + header.set_self_powered(self.self_powered); + header.set_remote_wakeup(self.remote_wakeup); + header.set_pull_down_enable(self.pull_down_enable); + eeprom.set_header(header); + + let strings = EepromStrings::with_strs( + &self.manufacturer, + &self.manufacturer_id, + &self.description, + &self.serial_number, + ) + .map_err(|e| eyre!("EEPROM strings error: {e:?}"))?; + + Ok((eeprom, strings)) + } +} + +impl DebugBoardCmd { + pub async fn run(self) -> Result<()> { + match self.command { + DebugBoardSubcommand::List(cmd) => cmd.run().await, + DebugBoardSubcommand::Read(cmd) => cmd.run().await, + DebugBoardSubcommand::Write(cmd) => cmd.run().await, + } + } +} + +impl ListCmd { + async fn run(self) -> Result<()> { + tokio::task::spawn_blocking(|| -> Result<()> { + detach_all_ftdi_kernel_drivers(); + + let devices: Vec<_> = FtdiGpio::list_devices() + .wrap_err("failed to list FTDI devices")? + .collect(); + + if devices.is_empty() { + println!("No FTDI devices found."); + return Ok(()); + } + + // Group devices by USB serial (strip channel suffix from FTDI serial) + let mut grouped: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for device in &devices { + let usb_serial = + strip_channel_suffix(&device.serial_number).to_string(); + grouped.entry(usb_serial).or_default().push(device); + } + + let chip_count = grouped.len(); + let channel_count = devices.len(); + println!( + "Found {chip_count} debug board(s) ({channel_count} channel(s) total):\n", + ); + + for (i, (usb_serial, channels)) in grouped.iter().enumerate() { + let first = channels.first().unwrap(); + + println!("Chip {} [USB Serial: {}]:", i + 1, usb_serial); + println!(" Vendor ID: 0x{:04X}", first.vendor_id); + println!(" Product ID: 0x{:04X}", first.product_id); + println!(" Channels:"); + + for channel in channels { + // Extract just the channel letter from description (e.g., "FT4232H A" -> "A") + let channel_letter = + channel.description.chars().last().unwrap_or('?'); + println!( + " {}: FTDI Serial = {}, Description = \"{}\"", + channel_letter, channel.serial_number, channel.description + ); + } + println!(); + } + + Ok(()) + }) + .await + .wrap_err("task panicked")? + } +} + +impl ReadCmd { + async fn run(self) -> Result<()> { + let output_path = self.file.clone(); + let usb_serial = self.usb_serial.clone(); + let desc = self.desc.clone(); + + tokio::task::spawn_blocking(move || -> Result<()> { + let mut ft4232h = open_ft4232h(usb_serial.as_deref(), desc.as_deref())?; + + info!("Reading EEPROM from FT4232H device..."); + let (eeprom, strings) = ft4232h + .eeprom_read() + .map_err(|e| eyre!("failed to read EEPROM: {e:?}"))?; + + let data = Ft4232hEepromData::from_eeprom(&eeprom, &strings); + debug!("EEPROM data: {data:?}"); + + let json = serde_json::to_string_pretty(&data) + .wrap_err("failed to serialize EEPROM data to JSON")?; + + if let Some(output_path) = output_path { + std::fs::write(&output_path, &json) + .wrap_err_with(|| format!("failed to write to {output_path}"))?; + info!("EEPROM content written to {output_path}"); + } else { + println!("{json}"); + } + + Ok(()) + }) + .await + .wrap_err("task panicked")? + } +} + +impl WriteCmd { + async fn run(self) -> Result<()> { + let input_path = self.input.clone(); + let usb_serial = self.usb_serial.clone(); + let desc = self.desc.clone(); + + tokio::task::spawn_blocking(move || -> Result<()> { + let json = std::fs::read_to_string(&input_path) + .wrap_err_with(|| format!("failed to read {input_path}"))?; + + let data: Ft4232hEepromData = serde_json::from_str(&json) + .wrap_err("failed to parse EEPROM data from JSON")?; + + info!("Writing EEPROM to FT4232H device..."); + info!(" Serial number: {}", data.serial_number); + info!(" Description: {}", data.description); + info!(" Manufacturer: {}", data.manufacturer); + + let (eeprom, strings) = data.to_eeprom()?; + + let mut ft4232h = open_ft4232h(usb_serial.as_deref(), desc.as_deref())?; + + ft4232h + .eeprom_program(eeprom, strings) + .map_err(|e| eyre!("failed to program EEPROM: {e:?}"))?; + + info!("EEPROM successfully programmed!"); + info!("Note: You may need to unplug and replug the device for changes to take effect."); + + Ok(()) + }) + .await + .wrap_err("task panicked")? + } +} + +/// Opens an FT4232H device with optional USB serial or description filter. +/// +/// Note: For USB serial, we append 'A' to get the FTDI serial of the first channel, +/// since EEPROM operations work the same on any channel of the same chip. +fn open_ft4232h(usb_serial: Option<&str>, desc: Option<&str>) -> Result { + detach_all_ftdi_kernel_drivers(); + + match (usb_serial, desc) { + (Some(usb_serial), None) => open_ft4232h_by_usb_serial(usb_serial), + (None, Some(desc)) => open_ft4232h_by_description(desc), + (None, None) => open_default_ft4232h(), + (Some(_), Some(_)) => { + bail!("cannot specify both USB serial and description") + } + } +} + +fn open_default_ft4232h() -> Result { + let usb_device_infos: Vec<_> = nusb::list_devices() + .wait() + .wrap_err("failed to enumerate devices")? + .filter(|d| d.vendor_id() == libftd2xx::FTDI_VID) + .collect(); + + if usb_device_infos.is_empty() { + bail!("no FTDI devices found"); + } + if usb_device_infos.len() > 1 { + bail!( + "multiple FTDI devices found, please specify --usb-serial or --desc to select one" + ); + } + + let usb_device_info = usb_device_infos.into_iter().next().unwrap(); + + // Detach kernel drivers if needed + if let Ok(usb_device) = usb_device_info.open().wait() { + for iinfo in usb_device_info.interfaces() { + let _ = usb_device.detach_kernel_driver(iinfo.interface_number()); + } + } + + let ftdi = Ftdi::new().wrap_err("failed to open FTDI device")?; + let ft4232h: Ft4232h = ftdi + .try_into() + .map_err(|e| eyre!("device is not an FT4232H: {e:?}"))?; + + Ok(ft4232h) +} + +fn open_ft4232h_by_usb_serial(usb_serial: &str) -> Result { + ensure!(!usb_serial.is_empty(), "USB serial cannot be empty"); + + let usb_device_info = nusb::list_devices() + .wait() + .wrap_err("failed to enumerate devices")? + .find(|d| d.serial_number() == Some(usb_serial)) + .ok_or_else(|| eyre!("no device with USB serial \"{usb_serial}\" found"))?; + + // Detach kernel drivers if needed + if let Ok(usb_device) = usb_device_info.open().wait() { + for iinfo in usb_device_info.interfaces() { + let _ = usb_device.detach_kernel_driver(iinfo.interface_number()); + } + } + + // Use channel A (append 'A') to get the FTDI serial. + // EEPROM is shared across all channels, so any channel works. + let ftdi_serial = format!("{usb_serial}A"); + let ftdi = Ftdi::with_serial_number(&ftdi_serial).map_err(|e| { + eyre!("failed to open FTDI device with FTDI serial \"{ftdi_serial}\": {e:?}") + })?; + let ft4232h: Ft4232h = ftdi + .try_into() + .map_err(|e| eyre!("device is not an FT4232H: {e:?}"))?; + + Ok(ft4232h) +} + +fn open_ft4232h_by_description(desc: &str) -> Result { + let usb_device_infos: Vec<_> = nusb::list_devices() + .wait() + .wrap_err("failed to enumerate devices")? + .filter(|d| d.vendor_id() == libftd2xx::FTDI_VID) + .collect(); + + // Detach kernel drivers for all FTDI devices + for usb_device_info in &usb_device_infos { + if let Ok(usb_device) = usb_device_info.open().wait() { + for iinfo in usb_device_info.interfaces() { + let _ = usb_device.detach_kernel_driver(iinfo.interface_number()); + } + } + } + + let ftdi = Ftdi::with_description(desc).map_err(|e| { + eyre!("failed to open FTDI device with description \"{desc}\": {e:?}") + })?; + let ft4232h: Ft4232h = ftdi + .try_into() + .map_err(|e| eyre!("device is not an FT4232H: {e:?}"))?; + + Ok(ft4232h) +} diff --git a/hil/src/commands/mod.rs b/hil/src/commands/mod.rs index b0d6066d..a4bd5efd 100644 --- a/hil/src/commands/mod.rs +++ b/hil/src/commands/mod.rs @@ -2,6 +2,7 @@ mod button_ctrl; mod cmd; +mod debug_board; mod fetch_persistent; mod flash; mod login; @@ -12,6 +13,7 @@ mod reboot; pub use self::button_ctrl::ButtonCtrl; pub use self::cmd::Cmd; +pub use self::debug_board::DebugBoardCmd; pub use self::fetch_persistent::FetchPersistent; pub use self::flash::Flash; pub use self::login::Login; diff --git a/hil/src/commands/reboot.rs b/hil/src/commands/reboot.rs index 19e774a5..45bf2ddb 100644 --- a/hil/src/commands/reboot.rs +++ b/hil/src/commands/reboot.rs @@ -1,28 +1,44 @@ use clap::Parser; use color_eyre::{eyre::WrapErr as _, Result}; -use crate::ftdi::FtdiId; +use crate::ftdi::{FtdiChannel, FtdiId}; /// Reboot the orb #[derive(Debug, Parser)] pub struct Reboot { #[arg(short)] recovery: bool, - /// The serial number of the FTDI device to use - #[arg(long, conflicts_with = "desc")] - serial_num: Option, - /// The description of the FTDI device to use - #[arg(long, conflicts_with = "serial_num")] + + /// The USB serial number of the FTDI chip (e.g., "FT7ABC12"). + /// Will use the default channel (C) for this chip. + #[arg(long, conflicts_with_all = ["ftdi_serial", "desc"])] + usb_serial: Option, + + /// The FTDI serial number including channel (e.g., "FT7ABC12C"). + /// This is the USB serial + channel letter (A/B/C/D). + #[arg(long, conflicts_with_all = ["usb_serial", "desc"])] + ftdi_serial: Option, + + /// The FTDI description (e.g., "FT4232H C"). + #[arg(long, conflicts_with_all = ["usb_serial", "ftdi_serial"])] desc: Option, + + /// The channel to use when --usb-serial is provided (A, B, C, or D). + /// Defaults to C. + #[arg(long, default_value = "C", requires = "usb_serial")] + channel: FtdiChannel, } impl Reboot { pub async fn run(self) -> Result<()> { - let device = match (self.serial_num, self.desc) { - (Some(serial), None) => Some(FtdiId::SerialNumber(serial)), - (None, Some(desc)) => Some(FtdiId::Description(desc)), - (None, None) => None, - (Some(_), Some(_)) => unreachable!(), + let device = match (self.usb_serial, self.ftdi_serial, self.desc) { + (Some(usb_serial), None, None) => { + Some(FtdiId::from_usb_serial(&usb_serial, self.channel)) + } + (None, Some(ftdi_serial), None) => Some(FtdiId::FtdiSerial(ftdi_serial)), + (None, None, Some(desc)) => Some(FtdiId::Description(desc)), + (None, None, None) => None, + _ => unreachable!(), }; crate::boot::reboot(self.recovery, device.as_ref()) diff --git a/hil/src/ftdi.rs b/hil/src/ftdi.rs index b257fdc0..087afd1e 100644 --- a/hil/src/ftdi.rs +++ b/hil/src/ftdi.rs @@ -48,23 +48,109 @@ mod builder_states { use builder_states::*; use tracing::{debug, error, warn}; -/// The different supported ways to address a *specific* FTDI device. +/// The 4 channels of an FT4232H chip. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub enum FtdiChannel { + A, + B, + C, + D, +} + +impl FtdiChannel { + pub fn as_char(self) -> char { + match self { + FtdiChannel::A => 'A', + FtdiChannel::B => 'B', + FtdiChannel::C => 'C', + FtdiChannel::D => 'D', + } + } + + pub fn description_suffix(self) -> &'static str { + match self { + FtdiChannel::A => "FT4232H A", + FtdiChannel::B => "FT4232H B", + FtdiChannel::C => "FT4232H C", + FtdiChannel::D => "FT4232H D", + } + } +} + +impl std::str::FromStr for FtdiChannel { + type Err = color_eyre::Report; + + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_str() { + "A" => Ok(FtdiChannel::A), + "B" => Ok(FtdiChannel::B), + "C" => Ok(FtdiChannel::C), + "D" => Ok(FtdiChannel::D), + _ => Err(color_eyre::eyre::eyre!( + "invalid channel: {s}, expected A, B, C, or D" + )), + } + } +} + +/// The different supported ways to address a *specific* FTDI device/channel. +/// +/// # Terminology +/// - **USB serial**: The physical USB device serial number (what `nusb`/`lsusb` sees). +/// One FT4232H chip = one USB serial (e.g., "FT7ABC12"). +/// - **FTDI serial**: The channel-specific serial, which is `{usb_serial}{channel}`. +/// The FTDI library (libftd2xx) sees 4 "devices" per FT4232H: A, B, C, D +/// (e.g., "FT7ABC12A", "FT7ABC12B", "FT7ABC12C", "FT7ABC12D"). +/// - **Description**: The FTDI description which identifies the channel type +/// (e.g., "FT4232H A", "FT4232H B", "FT4232H C", "FT4232H D"). #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum FtdiId { - SerialNumber(String), + /// The FTDI channel serial (USB serial + channel letter, e.g., "FT7ABC12C"). + FtdiSerial(String), + /// The FTDI description (e.g., "FT4232H C"). Description(String), } +impl FtdiId { + /// Creates an FtdiId from a USB serial number and channel. + /// + /// The FTDI serial is the USB serial with the channel letter appended. + pub fn from_usb_serial(usb_serial: &str, channel: FtdiChannel) -> Self { + Self::FtdiSerial(format!("{}{}", usb_serial, channel.as_char())) + } +} + +/// Detaches kernel drivers from all FTDI USB devices. +/// +/// This is necessary because `libftd2xx` functions (like `list_devices()`) may +/// return zeroed/invalid data if kernel drivers are attached or in a bad state. +/// Call this before any libftd2xx operations that enumerate or discover devices. +pub fn detach_all_ftdi_kernel_drivers() { + let Ok(devices) = nusb::list_devices().wait() else { + return; + }; + for usb_device_info in devices.filter(|d| d.vendor_id() == libftd2xx::FTDI_VID) { + if let Ok(usb_device) = usb_device_info.open().wait() { + for iinfo in usb_device_info.interfaces() { + let _ = usb_device.detach_kernel_driver(iinfo.interface_number()); + } + } + } +} + /// Type-state builder pattern for creating a [`FtdiGpio`]. #[derive(Clone, Debug)] pub struct Builder(S); impl Builder { /// Opens the first ftdi device identified. This can change across subsequent calls, - /// if you need a specific device use [`Self::with_serial_number`] instead. + /// if you need a specific device use [`Self::with_ftdi_serial`] or + /// [`Self::with_usb_serial`] instead. /// /// Returns an error if there is more than 1 FTDI device connected. pub fn with_default_device(self) -> Result> { + detach_all_ftdi_kernel_drivers(); + let usb_device_infos: Vec<_> = nusb::list_devices() .wait() .wrap_err("failed to enumerate devices")? @@ -76,15 +162,20 @@ impl Builder { if usb_device_infos.is_empty() || ftdi_device_count == 0 { bail!("no FTDI devices found"); } - if usb_device_infos.len() != 1 || ftdi_device_count != 1 { - bail!("more than one FTDI device found"); + if usb_device_infos.len() != 1 { + bail!("more than one FTDI USB device found"); + } + if ftdi_device_count > 4 { + // More than 4 FTDI channels means multiple physical chips + bail!("more than one FTDI chip detected (more than 4 channels)"); } let usb_device_info = usb_device_infos.into_iter().next_back().unwrap(); // See module-level docs for more info about missing serial numbers. - let serial_num = usb_device_info.serial_number().unwrap_or(""); - if !serial_num.is_empty() && serial_num != "000000000" { - return self.with_serial_number(serial_num); + let usb_serial = usb_device_info.serial_number().unwrap_or(""); + if !usb_serial.is_empty() && usb_serial != "000000000" { + // Use channel C by default when opening via USB serial + return self.with_usb_serial(usb_serial, FtdiChannel::C); } warn!("EEPROM is either blank or missing and there is no serial number"); @@ -96,21 +187,36 @@ impl Builder { Ok(Builder(NeedsConfiguring { device })) } - /// Opens a device with the given serial number. - pub fn with_serial_number(self, serial: &str) -> Result> { - ensure!(!serial.is_empty(), "serial numbers cannot be empty"); + /// Opens a device with the given FTDI serial number. + /// + /// The FTDI serial is the USB serial + channel letter (e.g., "FT7ABC12C"). + /// This is what `libftd2xx` uses internally. + pub fn with_ftdi_serial( + self, + ftdi_serial: &str, + ) -> Result> { + ensure!(!ftdi_serial.is_empty(), "FTDI serial cannot be empty"); ensure!( - serial != "000000000", - "serial numbers cannot be the special zero serial" + ftdi_serial != "000000000", + "FTDI serial cannot be the special zero serial" ); + // The USB serial is the FTDI serial without the last character in case of several + // channels (channel letter). + // Serial is matched to usb_serial OR ftdi_serial to ensure compatibility with + // one-channel FTDI chips + let usb_serial = strip_channel_suffix(ftdi_serial); + let mut last_err = None; let usb_device_info = nusb::list_devices() .wait() .wrap_err("failed to enumerate devices")? - .find(|d| d.serial_number() == Some(serial)) + .find(|d| { + d.serial_number() == Some(usb_serial) + || d.serial_number() == Some(ftdi_serial) + }) .ok_or_else(|| { - eyre!("usb device with matching serial \"{serial}\" not found") + eyre!("usb device with matching serial \"{usb_serial}\" not found") })?; let usb_device = usb_device_info .open() @@ -121,8 +227,8 @@ impl Builder { // libftd2xx to work. // See also https://stackoverflow.com/a/34021765 let _ = usb_device.detach_kernel_driver(iinfo.interface_number()); - match libftd2xx::Ftdi::with_serial_number(serial).wrap_err_with(|| { - format!("failed to open FTDI device with serial number \"{serial}\"") + match libftd2xx::Ftdi::with_serial_number(ftdi_serial).wrap_err_with(|| { + format!("failed to open FTDI device with FTDI serial \"{ftdi_serial}\"") }) { Ok(ftdi) => { return Ok(Builder(NeedsConfiguring { device: ftdi })); @@ -135,12 +241,27 @@ impl Builder { "failed to successfully open any ftdi devices. Wrapping last error", ) } else { - Err(eyre!("faild to find any ftdi devices")) + Err(eyre!("failed to find any ftdi devices")) } } + /// Opens a device with the given USB serial number and channel. + /// + /// This is a convenience method that combines the USB serial with the channel + /// to form the FTDI serial. + pub fn with_usb_serial( + self, + usb_serial: &str, + channel: FtdiChannel, + ) -> Result> { + let ftdi_serial = format!("{}{}", usb_serial, channel.as_char()); + self.with_ftdi_serial(&ftdi_serial) + } + /// Opens a device with the given description. pub fn with_description(self, desc: &str) -> Result> { + detach_all_ftdi_kernel_drivers(); + let ftdi_device = { let mut devices = FtdiGpio::list_devices() .wrap_err("failed to enumerate ftdi devices")? @@ -165,7 +286,8 @@ impl Builder { .filter(|d| { // See module-level docs for more info about missing serial numbers. let sn = d.serial_number().unwrap_or(""); - sn == "000000000" || sn == ftdi_device.serial_number + sn == "000000000" + || sn == strip_channel_suffix(&ftdi_device.serial_number) }); let usb_device = devices.next().ok_or_eyre( @@ -232,8 +354,8 @@ pub struct FtdiGpio { } impl FtdiGpio { - pub const RTS_PIN: Pin = Pin(2); pub const CTS_PIN: Pin = Pin(3); + pub const DTR_PIN: Pin = Pin(4); pub fn list_devices() -> Result> { libftd2xx::list_devices() @@ -274,10 +396,9 @@ impl FtdiGpio { return Ok(()); } - self.device - .set_bit_mode(0, libftd2xx::BitMode::Reset) - .unwrap(); - self.device.close().unwrap(); + self.device.set_bit_mode(0, libftd2xx::BitMode::Reset)?; + self.device.close()?; + let devices: Vec<_> = nusb::list_devices() .wait() .wrap_err("failed to enumerate devices")? @@ -285,8 +406,16 @@ impl FtdiGpio { .filter(|d| d.product_id() == self.device_info.product_id) .filter(|d| { // See module-level docs for more info about missing serial numbers. - let sn = d.serial_number().unwrap_or(""); - sn == "000000000" || sn == self.device_info.serial_number + let usb_serial = d.serial_number().unwrap_or(""); + // FTDI serial = USB serial + channel letter (A/B/C/D) + // Strip the channel letter from FTDI serial for comparison + let ftdi_serial = &self.device_info.serial_number; + let ftdi_serial_base = strip_channel_suffix(ftdi_serial); + tracing::debug!( + "serial: usb={usb_serial:?}, ftdi={ftdi_serial:?}, \ + ftdi_base={ftdi_serial_base:?}" + ); + usb_serial == "000000000" || usb_serial == ftdi_serial_base }) .collect(); @@ -335,6 +464,22 @@ fn read_pins(device: &mut libftd2xx::Ftdi) -> Result { Ok(out_buf[0]) } +/// Strips the channel suffix (A, B, C, D) from an FTDI serial to get the USB serial. +/// +/// FTDI serial = USB serial + channel letter (e.g., "FT7ABC12C" → "FT7ABC12") +pub fn strip_channel_suffix(ftdi_serial: &str) -> &str { + if ftdi_serial.len() < 2 { + return ftdi_serial; + } + let last_char = ftdi_serial.chars().last().unwrap(); + if matches!(last_char, 'A' | 'B' | 'C' | 'D') { + let char_len = last_char.len_utf8(); + &ftdi_serial[..ftdi_serial.len() - char_len] + } else { + ftdi_serial + } +} + impl Drop for FtdiGpio { fn drop(&mut self) { if let Err(err) = self.destroy_helper() { @@ -357,6 +502,7 @@ fn compute_new_state(current_state: u8, pin: Pin, output_state: OutputState) -> #[cfg(test)] mod test { use super::*; + pub const RTS_PIN: Pin = Pin(2); #[derive(Debug)] struct Example { @@ -369,25 +515,25 @@ mod test { fn test_compute_new_state() { let examples = [ Example { - pin: FtdiGpio::RTS_PIN, + pin: RTS_PIN, output: OutputState::Low, original: 0b10111111, expected: 0b10111011, }, Example { - pin: FtdiGpio::RTS_PIN, + pin: RTS_PIN, output: OutputState::Low, original: 0b10111011, expected: 0b10111011, }, Example { - pin: FtdiGpio::RTS_PIN, + pin: RTS_PIN, output: OutputState::High, original: 0b10111011, expected: 0b10111111, }, Example { - pin: FtdiGpio::RTS_PIN, + pin: RTS_PIN, output: OutputState::High, original: 0b10111111, expected: 0b10111111, diff --git a/hil/src/main.rs b/hil/src/main.rs index 8f09a475..4036fe8c 100644 --- a/hil/src/main.rs +++ b/hil/src/main.rs @@ -29,6 +29,7 @@ struct Cli { enum Commands { ButtonCtrl(crate::commands::ButtonCtrl), Cmd(crate::commands::Cmd), + DebugBoard(crate::commands::DebugBoardCmd), FetchPersistent(crate::commands::FetchPersistent), Flash(crate::commands::Flash), Login(crate::commands::Login), @@ -68,6 +69,7 @@ async fn main() -> Result<()> { match args.commands { Commands::ButtonCtrl(c) => c.run().await, Commands::Cmd(c) => c.run().await, + Commands::DebugBoard(c) => c.run().await, Commands::FetchPersistent(c) => c.run().await, Commands::Flash(c) => c.run().await, Commands::Login(c) => c.run().await,