- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`. - Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27. - Removed the `backup.rs` file as it is no longer needed. - Introduced tests for configuration and credential management. - Enhanced the `identity` module to generate W3C compliant DID documents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
266 lines
8.1 KiB
Rust
266 lines
8.1 KiB
Rust
//! 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<String>,
|
|
pub pubkey: Option<String>,
|
|
pub rssi: Option<i32>,
|
|
pub snr: Option<f64>,
|
|
pub last_heard: String,
|
|
#[serde(default)]
|
|
pub hops: u32,
|
|
#[serde(default)]
|
|
pub channel: Option<String>,
|
|
}
|
|
|
|
/// Mesh configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MeshConfig {
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub device_path: Option<String>,
|
|
#[serde(default)]
|
|
pub channel_name: Option<String>,
|
|
#[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<MeshConfig> {
|
|
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<String> {
|
|
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<Vec<MeshNode>> {
|
|
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()));
|
|
}
|
|
}
|