use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::bindings::{parse_vid_pid, SensorHubBinding, SENSOR_HUB_VID_PID};
use crate::probe::serial::{has_indexed_prefix, resolve_usb_id};
pub const TTY_CLASS_DIR: &str = "/sys/class/tty";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsbTty {
pub device: String,
pub vid: u16,
pub pid: u16,
}
pub fn usb_acm_ttys(tty_root: &Path) -> Vec<UsbTty> {
let Ok(rd) = std::fs::read_dir(tty_root) else {
return Vec::new();
};
let mut out: Vec<(u32, UsbTty)> = rd
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if !has_indexed_prefix(&name, "ttyACM") {
return None;
}
let index = name["ttyACM".len()..].parse::<u32>().ok()?;
let id = resolve_usb_id(&entry.path().join("device"))?;
Some((
index,
UsbTty {
device: format!("/dev/{name}"),
vid: id.vid,
pid: id.pid,
},
))
})
.collect();
out.sort_by_key(|(index, _)| *index);
out.into_iter().map(|(_, tty)| tty).collect()
}
pub fn find_acm_by_usb_id(tty_root: &Path, vid: u16, pid: u16) -> Option<String> {
usb_acm_ttys(tty_root)
.into_iter()
.find(|t| t.vid == vid && t.pid == pid)
.map(|t| t.device)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SensorHubPresence {
pub present: bool,
pub device: Option<String>,
pub vid_pid: String,
}
pub fn detect(binding: Option<&SensorHubBinding>, ttys: &[UsbTty]) -> Option<SensorHubPresence> {
let (vid, pid) = parse_vid_pid(SENSOR_HUB_VID_PID)?;
if let Some(b) = binding {
if parse_vid_pid(&b.usb_vid_pid) != Some((vid, pid)) {
tracing::warn!(
usb_vid_pid = %b.usb_vid_pid,
"sensor hub binding names a USB id other than the hub firmware's; ignored"
);
}
}
let device = ttys
.iter()
.find(|t| t.vid == vid && t.pid == pid)
.map(|t| t.device.clone());
if device.is_none() && binding.is_none() {
return None;
}
Some(SensorHubPresence {
present: device.is_some(),
device,
vid_pid: SENSOR_HUB_VID_PID.to_string(),
})
}
pub fn marker_path() -> PathBuf {
ados_protocol::paths::run_path("sensor-hub-present")
}
pub fn sync_marker(path: &Path, present: bool) -> std::io::Result<()> {
if present {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, b"")
} else {
match std::fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
}
}
pub fn publish_presence(presence: Option<&SensorHubPresence>) -> bool {
let present = presence.is_some_and(|h| h.present);
if let Err(e) = sync_marker(&marker_path(), present) {
tracing::warn!(error = %e, "sensor hub presence marker not updated");
}
present
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_tty(root: &Path, node: &str, vid: &str, pid: &str) {
let usb_dev = root.join(node);
std::fs::create_dir_all(usb_dev.join("device")).unwrap();
std::fs::write(usb_dev.join("idVendor"), format!("{vid}\n")).unwrap();
std::fs::write(usb_dev.join("idProduct"), format!("{pid}\n")).unwrap();
}
#[test]
fn the_walk_finds_the_hub_by_vid_pid_in_index_order() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fake_tty(root, "ttyACM0", "1209", "5741");
fake_tty(root, "ttyACM10", "1209", "ad05");
fake_tty(root, "ttyACM2", "1209", "ad05");
fake_tty(root, "ttyUSB0", "1209", "ad05");
std::fs::create_dir_all(root.join("ttyS0")).unwrap();
let ttys = usb_acm_ttys(root);
let devices: Vec<&str> = ttys.iter().map(|t| t.device.as_str()).collect();
assert_eq!(devices, ["/dev/ttyACM0", "/dev/ttyACM2", "/dev/ttyACM10"]);
assert_eq!(
find_acm_by_usb_id(root, 0x1209, 0xAD05).as_deref(),
Some("/dev/ttyACM2")
);
assert_eq!(find_acm_by_usb_id(root, 0x0483, 0x5740), None);
assert!(usb_acm_ttys(&root.join("missing")).is_empty());
}
#[test]
fn presence_follows_the_binding_and_the_bus() {
let hub = UsbTty {
device: "/dev/ttyACM1".into(),
vid: 0x1209,
pid: 0xAD05,
};
let fc = UsbTty {
device: "/dev/ttyACM0".into(),
vid: 0x1209,
pid: 0x5741,
};
assert_eq!(detect(None, std::slice::from_ref(&fc)), None);
let p = detect(None, &[fc.clone(), hub.clone()]).unwrap();
assert!(p.present);
assert_eq!(p.device.as_deref(), Some("/dev/ttyACM1"));
assert_eq!(p.vid_pid, "1209:ad05");
let binding = SensorHubBinding::default();
let p = detect(Some(&binding), std::slice::from_ref(&fc)).unwrap();
assert!(!p.present);
assert_eq!(p.device, None);
let ardupilot = SensorHubBinding {
usb_vid_pid: "1209:5741".into(),
board: None,
};
let p = detect(Some(&ardupilot), std::slice::from_ref(&fc)).unwrap();
assert!(!p.present);
assert_eq!(p.vid_pid, "1209:ad05");
let p = detect(Some(&ardupilot), &[fc, hub]).unwrap();
assert_eq!(p.device.as_deref(), Some("/dev/ttyACM1"));
}
#[test]
fn the_udev_rule_mirrors_the_hub_presence_marker() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../data/udev/99-ados-sensorhub.rules");
let Ok(rule) = std::fs::read_to_string(&path) else {
return;
};
let active: Vec<&str> = rule
.lines()
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
.collect();
let joined = active.join("\n");
let (vid, pid) = SENSOR_HUB_VID_PID.split_once(':').unwrap();
let add = active
.iter()
.find(|l| l.contains("ACTION==\"add\""))
.unwrap();
let remove = active
.iter()
.find(|l| l.contains("ACTION==\"remove\""))
.unwrap();
for line in [add, remove] {
assert!(
line.contains(&format!("ENV{{ID_VENDOR_ID}}==\"{vid}\"")),
"{line}"
);
assert!(
line.contains(&format!("ENV{{ID_MODEL_ID}}==\"{pid}\"")),
"{line}"
);
assert!(line.contains("KERNEL==\"ttyACM*\""), "{line}");
}
assert!(add.contains("touch /run/ados/sensor-hub-present"));
assert!(add.contains("ENV{SYSTEMD_WANTS}+=\"ados-sensorhub.service\""));
assert!(add.contains("TAG+=\"systemd\""));
assert!(remove.contains("rm -f /run/ados/sensor-hub-present"));
assert!(!joined.contains("5741"));
assert!(marker_path().ends_with("sensor-hub-present"));
}
#[test]
fn the_marker_is_created_and_removed() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("run").join("sensor-hub-present");
sync_marker(&marker, true).unwrap();
assert!(marker.exists());
sync_marker(&marker, true).unwrap();
sync_marker(&marker, false).unwrap();
assert!(!marker.exists());
sync_marker(&marker, false).unwrap();
}
}