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(())
}
/// 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> {
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();