diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index c23084f5..0008f57f 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -531,6 +531,18 @@ pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> { 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 { let path = data_dir.join(MESH_IGNORED_RADIO_FILE); if !path.exists() { @@ -2268,6 +2280,30 @@ impl MeshService { pub async fn configure(&mut self, config: MeshConfig) -> Result<()> { 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 needs_session_restart = session_config_changed(&self.config, &config); self.config = config.clone(); diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index 8ff02ea6..6029f842 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -515,17 +515,33 @@ impl MeshcoreDevice { // ─── Device detection ─────────────────────────────────────────────────── -/// Candidate serial device paths to check on Linux. -/// /dev/mesh-radio is a stable udev symlink (see 99-mesh-radio.rules). -const SERIAL_CANDIDATES: &[&str] = &[ - "/dev/mesh-radio", - "/dev/ttyUSB0", - "/dev/ttyUSB1", - "/dev/ttyUSB2", - "/dev/ttyACM0", - "/dev/ttyACM1", - "/dev/ttyACM2", -]; +/// Enumerate candidate serial device paths on Linux. +/// /dev/mesh-radio is a stable udev symlink (see 99-mesh-radio.rules) and is +/// always listed first so it wins the alias dedup in `detect_serial_devices`. +/// The rest is a live scan of /dev for ttyUSB*/ttyACM* nodes — the previous +/// fixed ttyUSB0-2/ttyACM0-2 list made any radio that enumerated at index 3+ +/// (multi-adapter boxes, replug races) permanently invisible to detection. +async fn serial_candidate_paths() -> Vec { + let mut candidates = vec!["/dev/mesh-radio".to_string()]; + let mut ttys: Vec<(u32, String)> = Vec::new(); + 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::() { + // 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"]; @@ -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 /// 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 -/// 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. /// (Independently re-discovered and fixed on main 2026-07-26 — both sides /// of the 2026-07-28 merge carried an equivalent implementation.) pub async fn detect_serial_devices() -> Vec { let mut devices = Vec::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 likely_non_mesh_serial_device(path) { debug!(path = %path, "Skipping known non-mesh serial device"); diff --git a/image-recipe/configs/99-mesh-radio.rules b/image-recipe/configs/99-mesh-radio.rules index dd9beb7a..ca18b26c 100644 --- a/image-recipe/configs/99-mesh-radio.rules +++ b/image-recipe/configs/99-mesh-radio.rules @@ -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}=="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" +# 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"