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:
co-authored by
Claude Fable 5
parent
c5cd751bcf
commit
809f7649a4
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user