fix(mesh): plug-and-play radio detection in all situations

Three root causes from the 2026-08-16 framework-pt incident where a
replugged radio detected but never connected:

- detect_serial_devices scanned a hardcoded ttyUSB0-2/ttyACM0-2 list, so
  a radio enumerating at index 3+ was permanently invisible. Now scans
  /dev for all ttyUSB*/ttyACM* nodes (deterministic order, /dev/mesh-radio
  alias still first and still wins the dedup).
- An operator rnode-rf-settings.json port override silently outranked the
  device_path the user just chose in the detection modal. mesh.configure
  now clears a stale override when a different device is configured
  (symlink-resolved compare keeps /dev/mesh-radio aliases intact).
- Espressif native-USB boards (303a, ESP32-S2/S3/C3 RNodes) had no udev
  rule, so they never got the stable /dev/mesh-radio alias and a persisted
  alias path dangled after a port move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-16 04:38:49 -04:00
co-authored by Claude Fable 5
parent c5cd751bcf
commit 809f7649a4
3 changed files with 71 additions and 13 deletions
+36
View File
@@ -531,6 +531,18 @@ pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> {
Ok(()) Ok(())
} }
/// Two /dev paths refer to the same serial device if their symlink-resolved
/// targets match (e.g. `/dev/mesh-radio` vs the `/dev/ttyUSBn` it points at).
/// Paths that fail to resolve fall back to a plain string comparison.
async fn same_serial_device(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let ra = fs::canonicalize(a).await.unwrap_or_else(|_| PathBuf::from(a));
let rb = fs::canonicalize(b).await.unwrap_or_else(|_| PathBuf::from(b));
ra == rb
}
pub async fn load_ignored_radio_contacts(data_dir: &Path) -> Vec<String> { pub async fn load_ignored_radio_contacts(data_dir: &Path) -> Vec<String> {
let path = data_dir.join(MESH_IGNORED_RADIO_FILE); let path = data_dir.join(MESH_IGNORED_RADIO_FILE);
if !path.exists() { if !path.exists() {
@@ -2268,6 +2280,30 @@ impl MeshService {
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> { pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
save_config(&self.data_dir, &config).await?; save_config(&self.data_dir, &config).await?;
// An operator-set RNode serial-port override (rnode-rf-settings.json)
// outranks `device_path` when the Reticulum session opens the radio.
// When a *different* device path is being configured (hot-swap, or
// "Keep As Is" on a newly detected radio), a stale override pinned to
// the old port would silently veto the choice the user just made —
// clear it so the explicit device selection wins. Same-device aliases
// (/dev/mesh-radio vs its ttyUSBn target) are left alone.
if let Some(new_path) = config.device_path.as_deref() {
let mut rf = rnode_settings::RNodeRfSettings::load(&self.data_dir).await;
if let Some(port) = rf.port.clone() {
if !same_serial_device(&port, new_path).await {
info!(
old_port = %port,
new_path = %new_path,
"Clearing stale RNode serial-port override — configured device path changed"
);
rf.port = None;
if let Err(e) = rf.save(&self.data_dir).await {
warn!("Failed to clear stale RNode port override: {e}");
}
}
}
}
let was_enabled = self.config.enabled; let was_enabled = self.config.enabled;
let needs_session_restart = session_config_changed(&self.config, &config); let needs_session_restart = session_config_changed(&self.config, &config);
self.config = config.clone(); self.config = config.clone();
+30 -13
View File
@@ -515,17 +515,33 @@ impl MeshcoreDevice {
// ─── Device detection ─────────────────────────────────────────────────── // ─── Device detection ───────────────────────────────────────────────────
/// Candidate serial device paths to check on Linux. /// Enumerate candidate serial device paths on Linux.
/// /dev/mesh-radio is a stable udev symlink (see 99-mesh-radio.rules). /// /dev/mesh-radio is a stable udev symlink (see 99-mesh-radio.rules) and is
const SERIAL_CANDIDATES: &[&str] = &[ /// always listed first so it wins the alias dedup in `detect_serial_devices`.
"/dev/mesh-radio", /// The rest is a live scan of /dev for ttyUSB*/ttyACM* nodes — the previous
"/dev/ttyUSB0", /// fixed ttyUSB0-2/ttyACM0-2 list made any radio that enumerated at index 3+
"/dev/ttyUSB1", /// (multi-adapter boxes, replug races) permanently invisible to detection.
"/dev/ttyUSB2", async fn serial_candidate_paths() -> Vec<String> {
"/dev/ttyACM0", let mut candidates = vec!["/dev/mesh-radio".to_string()];
"/dev/ttyACM1", let mut ttys: Vec<(u32, String)> = Vec::new();
"/dev/ttyACM2", if let Ok(mut dir) = tokio::fs::read_dir("/dev").await {
]; while let Ok(Some(entry)) = dir.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
for (group, prefix) in [(0u32, "ttyUSB"), (1 << 16, "ttyACM")] {
if let Some(num) = name.strip_prefix(prefix) {
if let Ok(n) = num.parse::<u32>() {
// ttyUSB* before ttyACM*, each numerically ordered, so
// probe order stays deterministic across boots.
ttys.push((group | n, format!("/dev/{name}")));
}
}
}
}
}
ttys.sort();
candidates.extend(ttys.into_iter().map(|(_, p)| p));
candidates
}
const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"]; const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"];
@@ -570,14 +586,15 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation /// (and DTR/RTS-reset) the exact port the live session was mid-conversation
/// with — a continuous, UI-driven reset loop that only ran while that view /// with — a continuous, UI-driven reset loop that only ran while that view
/// was open (matches the reported "stops when I leave, resumes when I come /// was open (matches the reported "stops when I leave, resumes when I come
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the /// back"). `serial_candidate_paths` lists `/dev/mesh-radio` first, so it wins the
/// dedup and is what's reported when both alias and target are present. /// dedup and is what's reported when both alias and target are present.
/// (Independently re-discovered and fixed on main 2026-07-26 — both sides /// (Independently re-discovered and fixed on main 2026-07-26 — both sides
/// of the 2026-07-28 merge carried an equivalent implementation.) /// of the 2026-07-28 merge carried an equivalent implementation.)
pub async fn detect_serial_devices() -> Vec<String> { pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new(); let mut devices = Vec::new();
let mut seen_real_paths = std::collections::HashSet::new(); let mut seen_real_paths = std::collections::HashSet::new();
for path in SERIAL_CANDIDATES { for path in serial_candidate_paths().await {
let path = path.as_str();
if tokio::fs::metadata(path).await.is_ok() { if tokio::fs::metadata(path).await.is_ok() {
if likely_non_mesh_serial_device(path) { if likely_non_mesh_serial_device(path) {
debug!(path = %path, "Skipping known non-mesh serial device"); debug!(path = %path, "Skipping known non-mesh serial device");
+5
View File
@@ -7,3 +7,8 @@ SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="m
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout" SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
SUBSYSTEM=="tty", ATTRS{idVendor}=="239a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout" SUBSYSTEM=="tty", ATTRS{idVendor}=="239a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
SUBSYSTEM=="tty", ATTRS{idVendor}=="2e8a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout" SUBSYSTEM=="tty", ATTRS{idVendor}=="2e8a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
# Espressif native-USB (ESP32-S2/S3/C3 "USB JTAG/serial debug unit", e.g.
# Heltec V3 / T-Deck class RNode boards plugged in via native USB). Without
# this a replugged radio has no stable alias and a persisted /dev/mesh-radio
# device_path dangles forever (observed live on a fleet node 2026-08-16).
SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"