use std::path::Path;
use ados_protocol::hwcaps::{Probed, SerialPort, UsbId};
#[cfg(target_os = "linux")]
const TTY_CLASS_DIR: &str = "/sys/class/tty";
const TTY_PREFIXES: [&str; 2] = ["ttyACM", "ttyUSB"];
mod fc_vendors {
pub const GENERIC: u16 = 0x1209;
pub const CP210X: u16 = 0x10C4;
pub const STM: u16 = 0x0483;
pub const ARDUINO: u16 = 0x2341;
pub const FTDI: u16 = 0x0403;
pub const CH340: u16 = 0x1A86;
pub const ESPRESSIF: u16 = 0x303A;
}
#[cfg(target_os = "linux")]
pub fn probe_serial_ports(crsf_pin: Option<&str>) -> Probed<Vec<SerialPort>> {
use ados_protocol::hwcaps::{AbsenceReason, Evidence};
let ports = enumerate_from_root(Path::new(TTY_CLASS_DIR), crsf_pin);
if ports.is_empty() {
return Probed::absent(AbsenceReason::NodeMissing);
}
Probed::present(ports, Evidence::SysfsPath(TTY_CLASS_DIR.to_string()))
}
#[cfg(not(target_os = "linux"))]
pub fn probe_serial_ports(_crsf_pin: Option<&str>) -> Probed<Vec<SerialPort>> {
Probed::NotProbed
}
#[allow(dead_code)]
fn enumerate_from_root(tty_root: &Path, crsf_pin: Option<&str>) -> Vec<SerialPort> {
let pin_node = crsf_pin
.map(|p| p.trim().trim_start_matches("/dev/"))
.unwrap_or("");
let mut ports: Vec<SerialPort> = Vec::new();
let Ok(rd) = std::fs::read_dir(tty_root) else {
return ports;
};
for entry in rd.flatten() {
let name = entry.file_name();
let node = name.to_string_lossy();
if !TTY_PREFIXES.iter().any(|p| has_indexed_prefix(&node, p)) {
continue;
}
let usb = resolve_usb_id(&entry.path().join("device"));
let fc_score = if !pin_node.is_empty() && node == pin_node {
0
} else {
score_fc(&node, usb.as_ref())
};
ports.push(SerialPort {
path: format!("/dev/{node}"),
usb,
fc_score,
});
}
ports.sort_by(|a, b| {
b.fc_score
.cmp(&a.fc_score)
.then_with(|| a.path.cmp(&b.path))
});
ports
}
#[allow(dead_code)]
pub(crate) fn has_indexed_prefix(name: &str, prefix: &str) -> bool {
match name.strip_prefix(prefix) {
Some(rest) => !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()),
None => false,
}
}
#[allow(dead_code)]
pub(crate) fn resolve_usb_id(device_link: &Path) -> Option<UsbId> {
use std::path::PathBuf;
let start = std::fs::canonicalize(device_link).unwrap_or_else(|_| device_link.to_path_buf());
let mut dir: Option<PathBuf> = Some(start);
while let Some(d) = dir {
if let (Some(vid), Some(pid)) = (
read_hex16(&d.join("idVendor")),
read_hex16(&d.join("idProduct")),
) {
return Some(UsbId { vid, pid });
}
dir = d.parent().map(Path::to_path_buf);
if dir
.as_deref()
.is_some_and(|p| p == Path::new("/sys") || p == Path::new("/"))
{
break;
}
}
None
}
#[allow(dead_code)]
fn read_hex16(p: &Path) -> Option<u16> {
let s = std::fs::read_to_string(p).ok()?;
u16::from_str_radix(s.trim(), 16).ok()
}
#[allow(dead_code)]
fn score_fc(node: &str, usb: Option<&UsbId>) -> u8 {
let mut score: u8 = if node.starts_with("ttyACM") {
50
} else if node.starts_with("ttyUSB") {
30
} else {
0
};
if let Some(id) = usb {
let known = matches!(
id.vid,
fc_vendors::GENERIC
| fc_vendors::CP210X
| fc_vendors::STM
| fc_vendors::ARDUINO
| fc_vendors::FTDI
| fc_vendors::CH340
| fc_vendors::ESPRESSIF
);
if known {
score = score.saturating_add(40);
} else {
score = score.saturating_add(5);
}
}
score.min(100)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acm_outscores_usb_for_same_vendor() {
let stm = UsbId {
vid: fc_vendors::STM,
pid: 0x5740,
};
assert!(score_fc("ttyACM0", Some(&stm)) > score_fc("ttyUSB0", Some(&stm)));
}
#[test]
fn known_vendor_outscores_unknown_on_same_node() {
let known = UsbId {
vid: fc_vendors::CP210X,
pid: 0xEA60,
};
let unknown = UsbId {
vid: 0xABCD,
pid: 0x1234,
};
assert!(score_fc("ttyACM0", Some(&known)) > score_fc("ttyACM0", Some(&unknown)));
assert!(score_fc("ttyACM0", Some(&unknown)) > score_fc("ttyACM0", None));
}
#[test]
fn ch340_and_espressif_are_known_bridge_vendors() {
for vid in [fc_vendors::CH340, fc_vendors::ESPRESSIF] {
let id = UsbId { vid, pid: 0x0001 };
assert_eq!(score_fc("ttyUSB0", Some(&id)), 70);
}
}
#[test]
fn score_is_bounded_and_kind_ranked() {
let generic = UsbId {
vid: fc_vendors::GENERIC,
pid: 0x5741,
};
assert_eq!(score_fc("ttyACM0", Some(&generic)), 90);
assert!(score_fc("ttyACM0", None) > score_fc("ttyUSB0", None));
assert_eq!(score_fc("ttyS0", None), 0);
}
#[test]
fn indexed_prefix_rejects_bare_and_named_nodes() {
assert!(has_indexed_prefix("ttyACM0", "ttyACM"));
assert!(has_indexed_prefix("ttyUSB12", "ttyUSB"));
assert!(!has_indexed_prefix("ttyACM", "ttyACM"));
assert!(!has_indexed_prefix("ttyACMx", "ttyACM"));
assert!(!has_indexed_prefix("ttyprintk", "ttyUSB"));
}
#[test]
fn enumerate_parses_fake_sysfs_layout() {
let tmp = tempfile::tempdir().unwrap();
let tty = tmp.path();
let acm_dev = tty.join("ttyACM0/device");
std::fs::create_dir_all(acm_dev.join("iface")).unwrap();
std::fs::write(acm_dev.join("idVendor"), "0483\n").unwrap();
std::fs::write(acm_dev.join("idProduct"), "5740\n").unwrap();
let usb_dev = tty.join("ttyUSB0/device");
std::fs::create_dir_all(&usb_dev).unwrap();
std::fs::write(usb_dev.join("idVendor"), "abcd\n").unwrap();
std::fs::write(usb_dev.join("idProduct"), "0001\n").unwrap();
std::fs::create_dir_all(tty.join("ttyACM1/device")).unwrap();
std::fs::create_dir_all(tty.join("tty")).unwrap();
std::fs::create_dir_all(tty.join("ttyprintk")).unwrap();
let ports = enumerate_from_root(tty, None);
assert_eq!(ports.len(), 3, "expected 3 ports, got {ports:?}");
assert_eq!(ports[0].path, "/dev/ttyACM0");
assert_eq!(
ports[0].usb,
Some(UsbId {
vid: 0x0483,
pid: 0x5740
})
);
let acm1 = ports.iter().find(|p| p.path == "/dev/ttyACM1").unwrap();
assert!(acm1.usb.is_none());
for win in ports.windows(2) {
assert!(win[0].fc_score >= win[1].fc_score);
}
}
#[test]
fn pinned_crsf_device_scores_zero_but_stays_enumerated() {
let tmp = tempfile::tempdir().unwrap();
let tty = tmp.path();
let acm_dev = tty.join("ttyACM0/device");
std::fs::create_dir_all(&acm_dev).unwrap();
std::fs::write(acm_dev.join("idVendor"), "0483\n").unwrap();
std::fs::write(acm_dev.join("idProduct"), "5740\n").unwrap();
let usb_dev = tty.join("ttyUSB0/device");
std::fs::create_dir_all(&usb_dev).unwrap();
std::fs::write(usb_dev.join("idVendor"), "10c4\n").unwrap();
std::fs::write(usb_dev.join("idProduct"), "ea60\n").unwrap();
let ports = enumerate_from_root(tty, Some("/dev/ttyUSB0"));
assert_eq!(ports.len(), 2);
assert_eq!(ports[0].path, "/dev/ttyACM0");
assert_eq!(ports[1].path, "/dev/ttyUSB0");
assert_eq!(
ports[1].fc_score, 0,
"pinned port must never be a candidate"
);
assert_eq!(
ports[1].usb,
Some(UsbId {
vid: 0x10C4,
pid: 0xEA60
})
);
let ports = enumerate_from_root(tty, Some("ttyUSB0"));
assert_eq!(ports[1].fc_score, 0);
let ports = enumerate_from_root(tty, Some("/dev/ttyUSB9"));
assert!(ports.iter().all(|p| p.fc_score > 0));
}
#[test]
fn missing_tty_root_enumerates_empty() {
let tmp = tempfile::tempdir().unwrap();
let nope = tmp.path().join("does-not-exist");
assert!(enumerate_from_root(&nope, None).is_empty());
}
}