//! Mesh networking: local node discovery over LoRa (Meshtastic) and BLE. //! //! Broadcasts node identity over mesh radio networks for offline peer discovery. //! Uses Meshtastic serial protocol when a compatible radio is connected via USB. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; use tokio::fs; const MESH_CONFIG_FILE: &str = "mesh-config.json"; /// A node discovered via mesh radio. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MeshNode { pub node_id: String, pub did: Option, pub pubkey: Option, pub rssi: Option, pub snr: Option, pub last_heard: String, #[serde(default)] pub hops: u32, #[serde(default)] pub channel: Option, } /// Mesh configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MeshConfig { pub enabled: bool, #[serde(default)] pub device_path: Option, #[serde(default)] pub channel_name: Option, #[serde(default)] pub broadcast_identity: bool, } impl Default for MeshConfig { fn default() -> Self { Self { enabled: false, device_path: None, channel_name: Some("archipelago".to_string()), broadcast_identity: true, } } } pub async fn load_config(data_dir: &Path) -> Result { let path = data_dir.join(MESH_CONFIG_FILE); if !path.exists() { return Ok(MeshConfig::default()); } let content = fs::read_to_string(&path) .await .context("Failed to read mesh config")?; let config: MeshConfig = serde_json::from_str(&content).unwrap_or_default(); Ok(config) } pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> { fs::create_dir_all(data_dir).await.context("Failed to create data dir")?; let content = serde_json::to_string_pretty(config).context("Failed to serialize mesh config")?; fs::write(data_dir.join(MESH_CONFIG_FILE), content) .await .context("Failed to write mesh config")?; Ok(()) } /// Detect Meshtastic-compatible USB devices. /// Meshtastic radios typically appear as USB serial devices (CP210x, CH340, FTDI). pub async fn detect_meshtastic_devices() -> Vec { let mut devices = Vec::new(); // Check for common serial device paths let candidates = [ "/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyACM0", "/dev/ttyACM1", ]; for path in &candidates { if tokio::fs::metadata(path).await.is_ok() { devices.push(path.to_string()); } } // Also scan sysfs for Meshtastic-specific USB VIDs if let Ok(mut entries) = tokio::fs::read_dir("/sys/bus/usb/devices").await { while let Ok(Some(entry)) = entries.next_entry().await { let vid_path = entry.path().join("idVendor"); if let Ok(vid_str) = tokio::fs::read_to_string(&vid_path).await { let vid = vid_str.trim(); // Silicon Labs CP210x (common Meshtastic radio) // CH340 USB-serial // FTDI FT232 if vid == "10c4" || vid == "1a86" || vid == "0403" { let product = tokio::fs::read_to_string(entry.path().join("product")) .await .map(|s| s.trim().to_string()) .unwrap_or_else(|_| "Serial Device".to_string()); devices.push(format!("{} ({})", entry.path().display(), product)); } } } } devices } /// Discover nodes via Meshtastic CLI (meshtastic --nodes). /// Returns nodes that have broadcast their Archipelago identity. pub async fn discover_nodes(device_path: Option<&str>) -> Result> { let mut cmd = tokio::process::Command::new("meshtastic"); cmd.arg("--nodes"); if let Some(dev) = device_path { cmd.arg("--port").arg(dev); } let output = cmd .output() .await .context("Failed to run meshtastic CLI — is it installed?")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); if stderr.contains("No Meshtastic") || stderr.contains("not found") { return Ok(Vec::new()); } anyhow::bail!("meshtastic --nodes failed: {}", stderr); } let stdout = String::from_utf8_lossy(&output.stdout); let mut nodes = Vec::new(); // Parse the meshtastic CLI node list output // Format varies but typically: NodeNum | User | AKA | ... for line in stdout.lines().skip(2) { // Skip header lines let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect(); if parts.len() < 3 { continue; } let node_id = parts.first().unwrap_or(&"").to_string(); if node_id.is_empty() || node_id.starts_with('-') { continue; } nodes.push(MeshNode { node_id: node_id.trim().to_string(), did: None, pubkey: None, rssi: None, snr: None, last_heard: chrono::Utc::now().to_rfc3339(), hops: 0, channel: None, }); } Ok(nodes) } /// Broadcast our node identity over mesh. pub async fn broadcast_identity( did: &str, pubkey: &str, device_path: Option<&str>, ) -> Result<()> { let message = format!("ARCHY:{}:{}", did, pubkey); let mut cmd = tokio::process::Command::new("meshtastic"); cmd.arg("--sendtext").arg(&message); if let Some(dev) = device_path { cmd.arg("--port").arg(dev); } let output = cmd .output() .await .context("Failed to broadcast via meshtastic")?; if !output.status.success() { anyhow::bail!( "meshtastic broadcast failed: {}", String::from_utf8_lossy(&output.stderr) ); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_mesh_config_default() { let config = MeshConfig::default(); assert!(!config.enabled); assert_eq!(config.channel_name, Some("archipelago".to_string())); assert!(config.broadcast_identity); } #[test] fn test_mesh_config_serialization() { let config = MeshConfig { enabled: true, device_path: Some("/dev/ttyUSB0".to_string()), channel_name: Some("test".to_string()), broadcast_identity: false, }; let json = serde_json::to_string(&config).unwrap(); let parsed: MeshConfig = serde_json::from_str(&json).unwrap(); assert!(parsed.enabled); assert_eq!(parsed.device_path, Some("/dev/ttyUSB0".to_string())); } #[test] fn test_mesh_node_serialization() { let node = MeshNode { node_id: "!aabbccdd".to_string(), did: Some("did:key:z123".to_string()), pubkey: Some("pubhex".to_string()), rssi: Some(-85), snr: Some(7.5), last_heard: "2026-03-10T00:00:00Z".to_string(), hops: 1, channel: Some("archipelago".to_string()), }; let json = serde_json::to_string(&node).unwrap(); let parsed: MeshNode = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.node_id, "!aabbccdd"); assert_eq!(parsed.rssi, Some(-85)); } #[tokio::test] async fn test_load_config_default_when_no_file() { let dir = tempfile::tempdir().unwrap(); let config = load_config(dir.path()).await.unwrap(); assert!(!config.enabled); } #[tokio::test] async fn test_save_and_load_config_roundtrip() { let dir = tempfile::tempdir().unwrap(); let config = MeshConfig { enabled: true, device_path: Some("/dev/ttyUSB0".to_string()), channel_name: Some("archy".to_string()), broadcast_identity: true, }; save_config(dir.path(), &config).await.unwrap(); let loaded = load_config(dir.path()).await.unwrap(); assert!(loaded.enabled); assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string())); } }