backup commit
This commit is contained in:
401
core/archipelago/src/mesh/mod.rs
Normal file
401
core/archipelago/src/mesh/mod.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
|
||||
//! and encrypted messaging between Archipelago nodes.
|
||||
//!
|
||||
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub mod crypto;
|
||||
#[allow(dead_code)]
|
||||
pub mod listener;
|
||||
#[allow(dead_code)]
|
||||
pub mod protocol;
|
||||
#[allow(dead_code)]
|
||||
pub mod serial;
|
||||
#[allow(dead_code)]
|
||||
pub mod types;
|
||||
|
||||
pub use types::*;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use listener::MeshState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tracing::info;
|
||||
|
||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||
|
||||
/// Mesh configuration (persisted to disk).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshConfig {
|
||||
pub enabled: bool,
|
||||
/// Specific device path, or None for auto-detection.
|
||||
#[serde(default)]
|
||||
pub device_path: Option<String>,
|
||||
/// Channel name for broadcasts.
|
||||
#[serde(default)]
|
||||
pub channel_name: Option<String>,
|
||||
/// Whether to periodically broadcast our identity.
|
||||
#[serde(default)]
|
||||
pub broadcast_identity: bool,
|
||||
/// Custom advertised name on the mesh network.
|
||||
#[serde(default)]
|
||||
pub advert_name: Option<String>,
|
||||
/// Off-grid mode: disable Tor/internet, route everything via mesh only.
|
||||
#[serde(default)]
|
||||
pub mesh_only_mode: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for MeshConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
device_path: None,
|
||||
channel_name: Some("archipelago".to_string()),
|
||||
broadcast_identity: true,
|
||||
advert_name: None,
|
||||
mesh_only_mode: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 serial devices that could be mesh radios.
|
||||
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
|
||||
pub async fn detect_devices() -> Vec<String> {
|
||||
serial::detect_serial_devices().await
|
||||
}
|
||||
|
||||
// ─── MeshService ────────────────────────────────────────────────────────
|
||||
|
||||
/// Top-level mesh networking service.
|
||||
/// Manages the background listener, exposes APIs for RPC handlers.
|
||||
pub struct MeshService {
|
||||
state: Arc<MeshState>,
|
||||
config: MeshConfig,
|
||||
data_dir: PathBuf,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
listener_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
cmd_rx: Option<tokio::sync::mpsc::Receiver<listener::MeshCommand>>,
|
||||
// Crypto identity for this node
|
||||
our_did: String,
|
||||
our_ed_pubkey_hex: String,
|
||||
our_x25519_secret: [u8; 32],
|
||||
our_x25519_pubkey_hex: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl MeshService {
|
||||
/// Create a new MeshService. Does not start the listener yet.
|
||||
pub async fn new(
|
||||
data_dir: &Path,
|
||||
signing_key: &SigningKey,
|
||||
did: &str,
|
||||
ed_pubkey_hex: &str,
|
||||
) -> Result<Self> {
|
||||
let config = load_config(data_dir).await?;
|
||||
let channel_name = config
|
||||
.channel_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "archipelago".to_string());
|
||||
|
||||
let (state, _rx, cmd_rx) = MeshState::new(&channel_name);
|
||||
|
||||
// Derive X25519 keys from Ed25519 identity
|
||||
let x25519_secret = crypto::ed25519_secret_to_x25519(signing_key);
|
||||
let x25519_pubkey = crypto::ed25519_pubkey_to_x25519(
|
||||
&signing_key.verifying_key().to_bytes(),
|
||||
)?;
|
||||
let x25519_pubkey_hex = hex::encode(x25519_pubkey);
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
config,
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
shutdown_tx: None,
|
||||
listener_handle: None,
|
||||
cmd_rx: Some(cmd_rx),
|
||||
our_did: did.to_string(),
|
||||
our_ed_pubkey_hex: ed_pubkey_hex.to_string(),
|
||||
our_x25519_secret: x25519_secret,
|
||||
our_x25519_pubkey_hex: x25519_pubkey_hex,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the background mesh listener.
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
if self.listener_handle.is_some() {
|
||||
anyhow::bail!("Mesh listener already running");
|
||||
}
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
let cmd_rx = self.cmd_rx.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Command channel already consumed"))?;
|
||||
|
||||
let handle = listener::spawn_mesh_listener(
|
||||
Arc::clone(&self.state),
|
||||
self.config.device_path.clone(),
|
||||
self.our_did.clone(),
|
||||
self.our_ed_pubkey_hex.clone(),
|
||||
self.our_x25519_secret,
|
||||
self.our_x25519_pubkey_hex.clone(),
|
||||
shutdown_rx,
|
||||
cmd_rx,
|
||||
);
|
||||
self.listener_handle = Some(handle);
|
||||
|
||||
info!("Mesh service started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the background listener.
|
||||
pub async fn stop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
if let Some(handle) = self.listener_handle.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
info!("Mesh service stopped");
|
||||
}
|
||||
|
||||
/// Get current mesh status.
|
||||
pub async fn status(&self) -> MeshStatus {
|
||||
self.state.status.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get list of discovered peers.
|
||||
pub async fn peers(&self) -> Vec<MeshPeer> {
|
||||
self.state.peers.read().await.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get message history.
|
||||
pub async fn messages(&self, limit: Option<usize>) -> Vec<MeshMessage> {
|
||||
let messages = self.state.messages.read().await;
|
||||
let limit = limit.unwrap_or(MAX_MESSAGES_DEFAULT);
|
||||
// Return in chronological order (oldest first) — take last N items
|
||||
let len = messages.len();
|
||||
let skip = if len > limit { len - limit } else { 0 };
|
||||
messages.iter().skip(skip).cloned().collect()
|
||||
}
|
||||
|
||||
/// Send a message to a peer by contact_id.
|
||||
/// Routes through the background listener which owns the serial port.
|
||||
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
|
||||
let status = self.state.status.read().await;
|
||||
if !status.device_connected {
|
||||
anyhow::bail!("No mesh device connected");
|
||||
}
|
||||
drop(status);
|
||||
|
||||
// Look up the peer's public key to get the 6-byte prefix for addressing
|
||||
let peers = self.state.peers.read().await;
|
||||
let peer = peers
|
||||
.get(&contact_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
|
||||
let pubkey_hex = peer
|
||||
.pubkey_hex
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
|
||||
let pubkey_bytes = hex::decode(pubkey_hex)
|
||||
.map_err(|_| anyhow::anyhow!("Invalid peer public key"))?;
|
||||
if pubkey_bytes.len() < 6 {
|
||||
anyhow::bail!("Peer public key too short");
|
||||
}
|
||||
let mut dest_prefix = [0u8; 6];
|
||||
dest_prefix.copy_from_slice(&pubkey_bytes[..6]);
|
||||
drop(peers);
|
||||
|
||||
let payload = text.as_bytes().to_vec();
|
||||
let encrypted = false;
|
||||
|
||||
if payload.len() > protocol::MAX_MESSAGE_LEN {
|
||||
anyhow::bail!(
|
||||
"Message too large for LoRa: {} bytes (max {})",
|
||||
payload.len(),
|
||||
protocol::MAX_MESSAGE_LEN
|
||||
);
|
||||
}
|
||||
|
||||
// Send through the listener's command channel
|
||||
self.state
|
||||
.cmd_tx
|
||||
.send(listener::MeshCommand::SendText {
|
||||
dest_pubkey_prefix: dest_prefix,
|
||||
payload,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||
|
||||
let msg_id = self.state.next_id().await;
|
||||
let peer_name = self
|
||||
.state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.get(&contact_id)
|
||||
.map(|p| p.advert_name.clone());
|
||||
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Sent,
|
||||
peer_contact_id: contact_id,
|
||||
peer_name,
|
||||
plaintext: text.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: false,
|
||||
encrypted,
|
||||
};
|
||||
|
||||
self.state.store_message(msg.clone()).await;
|
||||
{
|
||||
let mut status = self.state.status.write().await;
|
||||
status.messages_sent += 1;
|
||||
}
|
||||
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Broadcast our advertisement over mesh so other nodes can discover us.
|
||||
/// Sends an immediate advert via the listener's command channel.
|
||||
pub async fn broadcast_identity(&self) -> Result<()> {
|
||||
let status = self.state.status.read().await;
|
||||
if !status.device_connected {
|
||||
anyhow::bail!("No mesh device connected. Check USB connection.");
|
||||
}
|
||||
drop(status);
|
||||
|
||||
self.state
|
||||
.cmd_tx
|
||||
.send(listener::MeshCommand::SendAdvert)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||
|
||||
info!("Mesh self-advert broadcast triggered");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update mesh configuration.
|
||||
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
|
||||
save_config(&self.data_dir, &config).await?;
|
||||
|
||||
let was_enabled = self.config.enabled;
|
||||
self.config = config.clone();
|
||||
|
||||
// Update the status to reflect new config
|
||||
{
|
||||
let mut status = self.state.status.write().await;
|
||||
status.enabled = config.enabled;
|
||||
status.channel_name = config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string());
|
||||
}
|
||||
|
||||
// If enabled state changed, start/stop the listener
|
||||
if config.enabled && !was_enabled {
|
||||
self.start()?;
|
||||
} else if !config.enabled && was_enabled {
|
||||
self.stop().await;
|
||||
// Clear connected state
|
||||
let mut status = self.state.status.write().await;
|
||||
status.device_connected = false;
|
||||
status.device_path = None;
|
||||
status.firmware_version = None;
|
||||
status.self_node_id = None;
|
||||
status.peer_count = 0;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subscribe to mesh events.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<MeshEvent> {
|
||||
self.state.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Get a reference to shared state (for RPC handlers).
|
||||
pub fn shared_state(&self) -> Arc<MeshState> {
|
||||
Arc::clone(&self.state)
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_MESSAGES_DEFAULT: usize = 100;
|
||||
|
||||
#[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,
|
||||
advert_name: Some("MyNode".to_string()),
|
||||
};
|
||||
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()));
|
||||
assert_eq!(parsed.advert_name, Some("MyNode".to_string()));
|
||||
}
|
||||
|
||||
#[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,
|
||||
advert_name: None,
|
||||
};
|
||||
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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user