backup commit
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
//! Mesh message encryption: X25519 ECDH key agreement + ChaCha20-Poly1305.
|
||||
//!
|
||||
//! Reuses Archipelago's existing Ed25519 identity infrastructure.
|
||||
//! Ed25519 keys are converted to X25519 for Diffie-Hellman key exchange,
|
||||
//! then ChaCha20-Poly1305 encrypts each message with a unique random nonce.
|
||||
|
||||
use anyhow::Result;
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||
use rand::RngCore;
|
||||
|
||||
/// Nonce size for ChaCha20-Poly1305.
|
||||
const NONCE_SIZE: usize = 12;
|
||||
|
||||
/// Auth tag size for ChaCha20-Poly1305.
|
||||
const TAG_SIZE: usize = 16;
|
||||
|
||||
/// Minimum ciphertext size: nonce + at least 1 byte + tag.
|
||||
const MIN_CIPHERTEXT_SIZE: usize = NONCE_SIZE + 1 + TAG_SIZE;
|
||||
|
||||
/// Convert an Ed25519 public key (32 bytes) to an X25519 public key (32 bytes).
|
||||
/// Uses the standard Edwards-to-Montgomery conversion.
|
||||
pub fn ed25519_pubkey_to_x25519(ed_pubkey: &[u8; 32]) -> Result<[u8; 32]> {
|
||||
let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pubkey);
|
||||
let point = compressed
|
||||
.decompress()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid Ed25519 public key: decompression failed"))?;
|
||||
let montgomery = point.to_montgomery();
|
||||
Ok(*montgomery.as_bytes())
|
||||
}
|
||||
|
||||
/// Convert an Ed25519 signing key to an X25519 secret key.
|
||||
/// Applies SHA-512 clamping as per RFC 7748.
|
||||
pub fn ed25519_secret_to_x25519(signing_key: &ed25519_dalek::SigningKey) -> [u8; 32] {
|
||||
// The X25519 secret is derived from the first 32 bytes of SHA-512(ed25519_secret)
|
||||
// with clamping applied. ed25519-dalek's to_scalar() handles this.
|
||||
let hash = <sha2::Sha512 as sha2::Digest>::digest(signing_key.to_bytes());
|
||||
let mut x25519_secret = [0u8; 32];
|
||||
x25519_secret.copy_from_slice(&hash[..32]);
|
||||
// Clamp per RFC 7748
|
||||
x25519_secret[0] &= 248;
|
||||
x25519_secret[31] &= 127;
|
||||
x25519_secret[31] |= 64;
|
||||
x25519_secret
|
||||
}
|
||||
|
||||
/// Perform X25519 Diffie-Hellman key agreement.
|
||||
/// Returns a 32-byte shared secret.
|
||||
pub fn x25519_shared_secret(our_secret: &[u8; 32], their_public: &[u8; 32]) -> [u8; 32] {
|
||||
use curve25519_dalek::montgomery::MontgomeryPoint;
|
||||
use curve25519_dalek::scalar::Scalar;
|
||||
|
||||
let their_point = MontgomeryPoint(*their_public);
|
||||
let our_scalar = Scalar::from_bytes_mod_order(*our_secret);
|
||||
let shared = their_point * our_scalar;
|
||||
*shared.as_bytes()
|
||||
}
|
||||
|
||||
/// Encrypt plaintext with ChaCha20-Poly1305 using a shared secret.
|
||||
/// Output format: [nonce (12 bytes)] + [ciphertext + tag (16 bytes)]
|
||||
///
|
||||
/// Each call generates a fresh random 12-byte nonce via OsRng (CSPRNG).
|
||||
pub fn encrypt(shared_secret: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(shared_secret)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?;
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_SIZE];
|
||||
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut output = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decrypt ciphertext produced by `encrypt()`.
|
||||
/// Input format: [nonce (12 bytes)] + [ciphertext + tag (16 bytes)]
|
||||
pub fn decrypt(shared_secret: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||
if data.len() < MIN_CIPHERTEXT_SIZE {
|
||||
anyhow::bail!(
|
||||
"Ciphertext too short: {} bytes (minimum {})",
|
||||
data.len(),
|
||||
MIN_CIPHERTEXT_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
let nonce = Nonce::from_slice(&data[..NONCE_SIZE]);
|
||||
let ciphertext = &data[NONCE_SIZE..];
|
||||
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(shared_secret)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?;
|
||||
|
||||
cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| anyhow::anyhow!("Decryption failed: invalid key or corrupted message"))
|
||||
}
|
||||
|
||||
/// Maximum plaintext bytes that fit in a single encrypted LoRa message.
|
||||
/// 160 (max LoRa payload) - 12 (nonce) - 16 (tag) = 132 bytes.
|
||||
pub const MAX_ENCRYPTED_PLAINTEXT: usize = 160 - NONCE_SIZE - TAG_SIZE;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let shared_secret = [42u8; 32];
|
||||
let plaintext = b"hello from mesh";
|
||||
|
||||
let ciphertext = encrypt(&shared_secret, plaintext).unwrap();
|
||||
assert!(ciphertext.len() > plaintext.len()); // nonce + tag overhead
|
||||
|
||||
let decrypted = decrypt(&shared_secret, &ciphertext).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_wrong_key() {
|
||||
let secret1 = [1u8; 32];
|
||||
let secret2 = [2u8; 32];
|
||||
|
||||
let ciphertext = encrypt(&secret1, b"secret").unwrap();
|
||||
assert!(decrypt(&secret2, &ciphertext).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_corrupted() {
|
||||
let secret = [42u8; 32];
|
||||
let mut ciphertext = encrypt(&secret, b"test").unwrap();
|
||||
// Flip a byte in the ciphertext (after nonce)
|
||||
let idx = NONCE_SIZE + 1;
|
||||
ciphertext[idx] ^= 0xFF;
|
||||
assert!(decrypt(&secret, &ciphertext).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_too_short() {
|
||||
let secret = [42u8; 32];
|
||||
assert!(decrypt(&secret, &[0u8; 10]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unique_nonces() {
|
||||
let secret = [42u8; 32];
|
||||
let ct1 = encrypt(&secret, b"same").unwrap();
|
||||
let ct2 = encrypt(&secret, b"same").unwrap();
|
||||
// Nonces (first 12 bytes) should differ
|
||||
assert_ne!(&ct1[..NONCE_SIZE], &ct2[..NONCE_SIZE]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ed25519_to_x25519_pubkey() {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let ed_pubkey = signing_key.verifying_key().to_bytes();
|
||||
let x25519 = ed25519_pubkey_to_x25519(&ed_pubkey).unwrap();
|
||||
// Should produce 32 non-zero bytes
|
||||
assert_eq!(x25519.len(), 32);
|
||||
assert!(x25519.iter().any(|&b| b != 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_x25519_key_agreement() {
|
||||
// Generate two Ed25519 keypairs
|
||||
let alice_signing = SigningKey::generate(&mut OsRng);
|
||||
let bob_signing = SigningKey::generate(&mut OsRng);
|
||||
|
||||
// Convert to X25519
|
||||
let alice_secret = ed25519_secret_to_x25519(&alice_signing);
|
||||
let bob_secret = ed25519_secret_to_x25519(&bob_signing);
|
||||
let alice_public = ed25519_pubkey_to_x25519(&alice_signing.verifying_key().to_bytes()).unwrap();
|
||||
let bob_public = ed25519_pubkey_to_x25519(&bob_signing.verifying_key().to_bytes()).unwrap();
|
||||
|
||||
// Both sides should derive the same shared secret
|
||||
let shared_ab = x25519_shared_secret(&alice_secret, &bob_public);
|
||||
let shared_ba = x25519_shared_secret(&bob_secret, &alice_public);
|
||||
assert_eq!(shared_ab, shared_ba);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_encrypt_decrypt_with_key_agreement() {
|
||||
let alice_signing = SigningKey::generate(&mut OsRng);
|
||||
let bob_signing = SigningKey::generate(&mut OsRng);
|
||||
|
||||
let alice_secret = ed25519_secret_to_x25519(&alice_signing);
|
||||
let bob_secret = ed25519_secret_to_x25519(&bob_signing);
|
||||
let alice_public = ed25519_pubkey_to_x25519(&alice_signing.verifying_key().to_bytes()).unwrap();
|
||||
let bob_public = ed25519_pubkey_to_x25519(&bob_signing.verifying_key().to_bytes()).unwrap();
|
||||
|
||||
let shared = x25519_shared_secret(&alice_secret, &bob_public);
|
||||
|
||||
// Alice encrypts
|
||||
let plaintext = b"sats over mesh";
|
||||
let ciphertext = encrypt(&shared, plaintext).unwrap();
|
||||
|
||||
// Bob decrypts with same shared secret
|
||||
let bob_shared = x25519_shared_secret(&bob_secret, &alice_public);
|
||||
let decrypted = decrypt(&bob_shared, &ciphertext).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_encrypted_plaintext_fits() {
|
||||
let secret = [42u8; 32];
|
||||
let plaintext = vec![0xAB; MAX_ENCRYPTED_PLAINTEXT];
|
||||
let ciphertext = encrypt(&secret, &plaintext).unwrap();
|
||||
// Should fit within LoRa max message size (160 bytes)
|
||||
assert!(ciphertext.len() <= 160);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
//! Background mesh listener task.
|
||||
//!
|
||||
//! Runs as a long-lived tokio task that:
|
||||
//! - Maintains the serial connection to the Meshcore device
|
||||
//! - Reads incoming frames and dispatches events
|
||||
//! - Periodically broadcasts our identity advertisement
|
||||
//! - Reconnects on device disconnect
|
||||
//! - Manages peer cache and message store
|
||||
|
||||
use super::crypto;
|
||||
use super::protocol;
|
||||
use super::serial::MeshcoreDevice;
|
||||
use super::types::*;
|
||||
use anyhow::Result;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use tokio::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// How often to broadcast our identity advertisement (seconds).
|
||||
const ADVERT_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How often to poll for queued messages when no push notifications.
|
||||
const SYNC_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Maximum stored messages (circular buffer).
|
||||
const MAX_MESSAGES: usize = 100;
|
||||
|
||||
/// Delay before reconnection attempt after device disconnect.
|
||||
const RECONNECT_DELAY: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Command sent from MeshService to the listener task (which owns the serial port).
|
||||
pub enum MeshCommand {
|
||||
SendText { dest_pubkey_prefix: [u8; 6], payload: Vec<u8> },
|
||||
SendAdvert,
|
||||
}
|
||||
|
||||
/// Shared state for the mesh listener, accessible from RPC handlers.
|
||||
pub struct MeshState {
|
||||
pub peers: RwLock<HashMap<u32, MeshPeer>>,
|
||||
pub messages: RwLock<VecDeque<MeshMessage>>,
|
||||
pub shared_secrets: RwLock<HashMap<u32, [u8; 32]>>,
|
||||
pub status: RwLock<MeshStatus>,
|
||||
pub event_tx: broadcast::Sender<MeshEvent>,
|
||||
pub cmd_tx: mpsc::Sender<MeshCommand>,
|
||||
next_message_id: RwLock<u64>,
|
||||
}
|
||||
|
||||
impl MeshState {
|
||||
pub fn new(channel_name: &str) -> (Arc<Self>, broadcast::Receiver<MeshEvent>, mpsc::Receiver<MeshCommand>) {
|
||||
let (tx, rx) = broadcast::channel(64);
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(32);
|
||||
let state = Arc::new(Self {
|
||||
peers: RwLock::new(HashMap::new()),
|
||||
messages: RwLock::new(VecDeque::new()),
|
||||
shared_secrets: RwLock::new(HashMap::new()),
|
||||
cmd_tx,
|
||||
status: RwLock::new(MeshStatus {
|
||||
enabled: true,
|
||||
device_type: DeviceType::Unknown,
|
||||
device_path: None,
|
||||
device_connected: false,
|
||||
firmware_version: None,
|
||||
self_node_id: None,
|
||||
self_advert_name: None,
|
||||
peer_count: 0,
|
||||
channel_name: channel_name.to_string(),
|
||||
messages_sent: 0,
|
||||
messages_received: 0,
|
||||
}),
|
||||
event_tx: tx,
|
||||
next_message_id: RwLock::new(1),
|
||||
});
|
||||
(state, rx, cmd_rx)
|
||||
}
|
||||
|
||||
pub async fn next_id(&self) -> u64 {
|
||||
let mut id = self.next_message_id.write().await;
|
||||
let current = *id;
|
||||
*id += 1;
|
||||
current
|
||||
}
|
||||
|
||||
pub async fn store_message(&self, msg: MeshMessage) {
|
||||
let mut messages = self.messages.write().await;
|
||||
messages.push_back(msg);
|
||||
if messages.len() > MAX_MESSAGES {
|
||||
messages.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_peer_count(&self) {
|
||||
let count = self.peers.read().await.len();
|
||||
self.status.write().await.peer_count = count;
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the background mesh listener task.
|
||||
///
|
||||
/// This task manages the full lifecycle:
|
||||
/// 1. Detect and connect to Meshcore device
|
||||
/// 2. Initialize and set advert name
|
||||
/// 3. Main loop: read frames, dispatch events, periodic adverts
|
||||
/// 4. Reconnect on disconnect
|
||||
pub fn spawn_mesh_listener(
|
||||
state: Arc<MeshState>,
|
||||
device_path: Option<String>,
|
||||
our_did: String,
|
||||
our_ed_pubkey_hex: String,
|
||||
our_x25519_secret: [u8; 32],
|
||||
our_x25519_pubkey_hex: String,
|
||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: mpsc::Receiver<MeshCommand>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut shutdown = shutdown;
|
||||
let mut cmd_rx = cmd_rx;
|
||||
loop {
|
||||
if *shutdown.borrow() {
|
||||
info!("Mesh listener shutting down");
|
||||
return;
|
||||
}
|
||||
|
||||
match run_mesh_session(
|
||||
&state,
|
||||
device_path.as_deref(),
|
||||
&our_did,
|
||||
&our_ed_pubkey_hex,
|
||||
&our_x25519_secret,
|
||||
&our_x25519_pubkey_hex,
|
||||
&mut shutdown,
|
||||
&mut cmd_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("Mesh session ended cleanly");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Mesh session error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to disconnected
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.device_connected = false;
|
||||
status.device_path = None;
|
||||
}
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceDisconnected);
|
||||
|
||||
// Wait before reconnecting
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(RECONNECT_DELAY) => {},
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() { return; }
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a single mesh session (connect, initialize, main loop).
|
||||
async fn run_mesh_session(
|
||||
state: &Arc<MeshState>,
|
||||
preferred_path: Option<&str>,
|
||||
our_did: &str,
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
our_x25519_pubkey_hex: &str,
|
||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||
) -> Result<()> {
|
||||
// Detect device
|
||||
let device_path = if let Some(path) = preferred_path {
|
||||
path.to_string()
|
||||
} else {
|
||||
let paths = super::serial::detect_serial_devices().await;
|
||||
if paths.is_empty() {
|
||||
anyhow::bail!("No serial devices found");
|
||||
}
|
||||
match super::serial::probe_for_meshcore(&paths).await {
|
||||
Some((path, _)) => path,
|
||||
None => anyhow::bail!("No Meshcore device found on available serial ports"),
|
||||
}
|
||||
};
|
||||
|
||||
// Open and initialize
|
||||
let mut device = MeshcoreDevice::open(&device_path).await?;
|
||||
let device_info = device.initialize().await?;
|
||||
|
||||
// Update status
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.device_connected = true;
|
||||
status.device_type = DeviceType::Meshcore;
|
||||
status.device_path = Some(device_path.clone());
|
||||
status.firmware_version = Some(device_info.firmware_version.clone());
|
||||
status.self_node_id = Some(device_info.node_id);
|
||||
status.self_advert_name = device.advert_name.clone();
|
||||
}
|
||||
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
|
||||
|
||||
// Set advert name to something identifiable
|
||||
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
|
||||
let advert_name = format!("Archy-{}", short_did);
|
||||
if let Err(e) = device.set_advert_name(&advert_name).await {
|
||||
warn!("Failed to set advert name: {}", e);
|
||||
}
|
||||
|
||||
// Broadcast our advertisement so other nodes can discover us
|
||||
if let Err(e) = device.send_self_advert().await {
|
||||
warn!("Failed to send initial advert: {}", e);
|
||||
}
|
||||
|
||||
// Fetch existing contacts from the device
|
||||
refresh_contacts(&mut device, state).await;
|
||||
|
||||
// Sync any queued messages from before we connected
|
||||
sync_queued_messages(&mut device, state, our_x25519_secret).await;
|
||||
|
||||
// Main loop
|
||||
let mut advert_timer = tokio::time::interval(ADVERT_INTERVAL);
|
||||
let mut sync_timer = tokio::time::interval(SYNC_INTERVAL);
|
||||
advert_timer.tick().await; // skip first immediate tick
|
||||
sync_timer.tick().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Check for incoming frames
|
||||
frame_result = device.try_recv_frame() => {
|
||||
match frame_result {
|
||||
Ok(Some(frame)) => {
|
||||
let should_action = handle_frame(
|
||||
&frame,
|
||||
state,
|
||||
our_x25519_secret,
|
||||
).await;
|
||||
if should_action {
|
||||
// Contact discovery or messages waiting — sync both
|
||||
refresh_contacts(&mut device, state).await;
|
||||
sync_queued_messages(&mut device, state, our_x25519_secret).await;
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No complete frame yet, that's fine
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Serial read error: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic advertisement broadcast + contact refresh
|
||||
_ = advert_timer.tick() => {
|
||||
debug!("Periodic self-advert broadcast");
|
||||
if let Err(e) = device.send_self_advert().await {
|
||||
warn!("Failed to send advert: {}", e);
|
||||
}
|
||||
refresh_contacts(&mut device, state).await;
|
||||
}
|
||||
|
||||
// Process send commands from MeshService
|
||||
Some(cmd) = cmd_rx.recv() => {
|
||||
match cmd {
|
||||
MeshCommand::SendText { dest_pubkey_prefix, payload } => {
|
||||
if let Err(e) = device.send_text(&dest_pubkey_prefix, &payload).await {
|
||||
warn!("Failed to send text via mesh: {}", e);
|
||||
} else {
|
||||
info!(dest = %hex::encode(dest_pubkey_prefix), len = payload.len(), "Sent mesh message");
|
||||
}
|
||||
}
|
||||
MeshCommand::SendAdvert => {
|
||||
if let Err(e) = device.send_self_advert().await {
|
||||
warn!("Failed to send advert: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic message sync
|
||||
_ = sync_timer.tick() => {
|
||||
sync_queued_messages(&mut device, state, our_x25519_secret).await;
|
||||
}
|
||||
|
||||
// Shutdown signal
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
info!("Mesh listener received shutdown signal");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single inbound frame from the device.
|
||||
/// Returns `true` if contacts should be refreshed from the device.
|
||||
async fn handle_frame(
|
||||
frame: &protocol::InboundFrame,
|
||||
state: &Arc<MeshState>,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) -> bool {
|
||||
match frame.code {
|
||||
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
||||
info!(code = frame.code, "Contact discovery event — refreshing contacts");
|
||||
return true; // Signal caller to fetch contacts
|
||||
}
|
||||
|
||||
protocol::PUSH_ACK => {
|
||||
debug!("Message delivery confirmed");
|
||||
// Could track which message was ACKed from frame.data
|
||||
}
|
||||
|
||||
protocol::PUSH_MESSAGES_WAITING => {
|
||||
info!("Device has messages waiting — will sync");
|
||||
return true; // Signal caller to sync immediately
|
||||
}
|
||||
|
||||
protocol::RESP_CONTACT_MSG_V3 => {
|
||||
// Direct message received (v3 format)
|
||||
match protocol::parse_contact_msg_v3(&frame.data) {
|
||||
Ok((sender_prefix, text, _snr)) => {
|
||||
if !text.is_empty() {
|
||||
let peer_name = {
|
||||
let peers = state.peers.read().await;
|
||||
peers.values()
|
||||
.find(|p| p.pubkey_hex.as_ref().map(|k| k.starts_with(&sender_prefix)).unwrap_or(false))
|
||||
.map(|p| (p.contact_id, p.advert_name.clone()))
|
||||
};
|
||||
let (contact_id, name) = peer_name.unwrap_or((0, sender_prefix.clone()));
|
||||
|
||||
let msg_id = state.next_id().await;
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Received,
|
||||
peer_contact_id: contact_id,
|
||||
peer_name: Some(name),
|
||||
plaintext: text,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: true,
|
||||
encrypted: false,
|
||||
};
|
||||
state.store_message(msg.clone()).await;
|
||||
state.status.write().await.messages_received += 1;
|
||||
info!(from = %sender_prefix, "Received mesh DM (v3)");
|
||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse v3 message: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
protocol::RESP_CONTACT_MSG => {
|
||||
// Direct message received (v1 format)
|
||||
match protocol::parse_contact_msg_v1(&frame.data) {
|
||||
Ok((sender_prefix, text)) => {
|
||||
if !text.is_empty() {
|
||||
let peer_name = {
|
||||
let peers = state.peers.read().await;
|
||||
peers.values()
|
||||
.find(|p| p.pubkey_hex.as_ref().map(|k| k.starts_with(&sender_prefix)).unwrap_or(false))
|
||||
.map(|p| (p.contact_id, p.advert_name.clone()))
|
||||
};
|
||||
let (contact_id, name) = peer_name.unwrap_or((0, sender_prefix.clone()));
|
||||
|
||||
let msg_id = state.next_id().await;
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Received,
|
||||
peer_contact_id: contact_id,
|
||||
peer_name: Some(name),
|
||||
plaintext: text,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: true,
|
||||
encrypted: false,
|
||||
};
|
||||
state.store_message(msg.clone()).await;
|
||||
state.status.write().await.messages_received += 1;
|
||||
info!(from = %sender_prefix, "Received mesh DM (v1)");
|
||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse v1 message: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
protocol::RESP_CHANNEL_MSG_V3 => {
|
||||
// Channel broadcast received (v3)
|
||||
match protocol::parse_channel_msg_v3(&frame.data) {
|
||||
Ok((channel_idx, text)) => {
|
||||
if !text.is_empty() {
|
||||
let msg_id = state.next_id().await;
|
||||
let chan_contact_id = -((channel_idx as i32) + 1);
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Received,
|
||||
peer_contact_id: chan_contact_id as u32,
|
||||
peer_name: Some(format!("Channel {}", channel_idx)),
|
||||
plaintext: text,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: true,
|
||||
encrypted: false,
|
||||
};
|
||||
state.store_message(msg.clone()).await;
|
||||
state.status.write().await.messages_received += 1;
|
||||
info!(channel = channel_idx, "Received mesh channel message (v3)");
|
||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
protocol::RESP_CHANNEL_MSG => {
|
||||
// Channel broadcast received (v1)
|
||||
match protocol::parse_channel_msg_v1(&frame.data) {
|
||||
Ok((channel_idx, text)) => {
|
||||
if !text.is_empty() {
|
||||
let msg_id = state.next_id().await;
|
||||
let chan_contact_id = -((channel_idx as i32) + 1);
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Received,
|
||||
peer_contact_id: chan_contact_id as u32,
|
||||
peer_name: Some(format!("Channel {}", channel_idx)),
|
||||
plaintext: text,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: true,
|
||||
encrypted: false,
|
||||
};
|
||||
state.store_message(msg.clone()).await;
|
||||
state.status.write().await.messages_received += 1;
|
||||
info!(channel = channel_idx, "Received mesh channel message");
|
||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse channel message: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
protocol::PUSH_LOG_DATA | protocol::PUSH_PATH_UPDATE | protocol::PUSH_RAW_DATA => {
|
||||
// Internal device logging/path data — safe to ignore
|
||||
}
|
||||
|
||||
_ => {
|
||||
if protocol::is_push_notification(frame.code) {
|
||||
debug!(code = frame.code, "Unhandled push notification");
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Handle a received identity broadcast from a peer.
|
||||
async fn handle_identity_received(
|
||||
contact_id: u32,
|
||||
rssi: i16,
|
||||
did: &str,
|
||||
ed_pubkey_hex: &str,
|
||||
x25519_pubkey_hex: &str,
|
||||
state: &Arc<MeshState>,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) {
|
||||
info!(
|
||||
contact_id,
|
||||
did = %did,
|
||||
rssi,
|
||||
"Archipelago peer discovered over mesh"
|
||||
);
|
||||
|
||||
// Decode X25519 public key
|
||||
let x25519_bytes = match hex::decode(x25519_pubkey_hex) {
|
||||
Ok(b) if b.len() == 32 => {
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&b);
|
||||
arr
|
||||
}
|
||||
_ => {
|
||||
warn!("Invalid X25519 public key from peer");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Derive shared secret for encrypted messaging
|
||||
let shared_secret = crypto::x25519_shared_secret(our_x25519_secret, &x25519_bytes);
|
||||
state
|
||||
.shared_secrets
|
||||
.write()
|
||||
.await
|
||||
.insert(contact_id, shared_secret);
|
||||
|
||||
// Update peer record
|
||||
let peer = MeshPeer {
|
||||
contact_id,
|
||||
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
x25519_pubkey: Some(x25519_bytes),
|
||||
rssi: Some(rssi),
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0,
|
||||
};
|
||||
|
||||
let is_new = {
|
||||
let mut peers = state.peers.write().await;
|
||||
let is_new = !peers.contains_key(&contact_id);
|
||||
peers.insert(contact_id, peer.clone());
|
||||
is_new
|
||||
};
|
||||
state.update_peer_count().await;
|
||||
|
||||
let event = if is_new {
|
||||
MeshEvent::PeerDiscovered(peer)
|
||||
} else {
|
||||
MeshEvent::PeerUpdated(peer)
|
||||
};
|
||||
let _ = state.event_tx.send(event);
|
||||
let _ = state.event_tx.send(MeshEvent::IdentityReceived {
|
||||
contact_id,
|
||||
did: did.to_string(),
|
||||
pubkey_hex: ed_pubkey_hex.to_string(),
|
||||
x25519_pubkey: x25519_bytes,
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle a received message (direct or channel).
|
||||
async fn handle_received_message(
|
||||
contact_id: u32,
|
||||
payload: &[u8],
|
||||
rssi: i16,
|
||||
is_channel: bool,
|
||||
state: &Arc<MeshState>,
|
||||
_our_x25519_secret: &[u8; 32],
|
||||
) {
|
||||
// Try to decrypt if we have a shared secret for this contact
|
||||
let shared_secrets = state.shared_secrets.read().await;
|
||||
let (plaintext, encrypted) = if let Some(secret) = shared_secrets.get(&contact_id) {
|
||||
match crypto::decrypt(secret, payload) {
|
||||
Ok(pt) => (String::from_utf8_lossy(&pt).to_string(), true),
|
||||
Err(_) => {
|
||||
// Not encrypted or wrong key — treat as plaintext
|
||||
(String::from_utf8_lossy(payload).to_string(), false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(String::from_utf8_lossy(payload).to_string(), false)
|
||||
};
|
||||
drop(shared_secrets);
|
||||
|
||||
// Update peer last_heard
|
||||
{
|
||||
let mut peers = state.peers.write().await;
|
||||
if let Some(peer) = peers.get_mut(&contact_id) {
|
||||
peer.last_heard = chrono::Utc::now().to_rfc3339();
|
||||
peer.rssi = Some(rssi);
|
||||
}
|
||||
}
|
||||
|
||||
let peer_name = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.get(&contact_id)
|
||||
.map(|p| p.advert_name.clone());
|
||||
|
||||
let msg_id = state.next_id().await;
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Received,
|
||||
peer_contact_id: contact_id,
|
||||
peer_name,
|
||||
plaintext: plaintext.clone(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: true,
|
||||
encrypted,
|
||||
};
|
||||
|
||||
state.store_message(msg.clone()).await;
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.messages_received += 1;
|
||||
}
|
||||
|
||||
info!(
|
||||
contact_id,
|
||||
encrypted,
|
||||
channel = is_channel,
|
||||
"Received mesh message"
|
||||
);
|
||||
|
||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||
}
|
||||
|
||||
/// Drain any queued messages from the device.
|
||||
async fn sync_queued_messages(
|
||||
device: &mut MeshcoreDevice,
|
||||
state: &Arc<MeshState>,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) {
|
||||
match device.sync_messages().await {
|
||||
Ok(frames) => {
|
||||
for frame in &frames {
|
||||
handle_frame(frame, state, our_x25519_secret).await;
|
||||
}
|
||||
if !frames.is_empty() {
|
||||
info!(count = frames.len(), "Synced queued mesh messages");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Message sync: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the contacts list from the device and update the peer cache.
|
||||
async fn refresh_contacts(
|
||||
device: &mut MeshcoreDevice,
|
||||
state: &Arc<MeshState>,
|
||||
) {
|
||||
match device.get_contacts().await {
|
||||
Ok(contacts) => {
|
||||
let mut peers = state.peers.write().await;
|
||||
for (idx, contact) in contacts.iter().enumerate() {
|
||||
let contact_id = idx as u32;
|
||||
let existing = peers.get(&contact_id);
|
||||
let peer = MeshPeer {
|
||||
contact_id,
|
||||
advert_name: contact.advert_name.clone(),
|
||||
did: existing.and_then(|p| p.did.clone()),
|
||||
pubkey_hex: Some(contact.public_key_hex.clone()),
|
||||
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0,
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
}
|
||||
drop(peers);
|
||||
state.update_peer_count().await;
|
||||
if !contacts.is_empty() {
|
||||
info!(count = contacts.len(), "Refreshed mesh contacts");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to fetch contacts: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
//! Meshcore binary frame protocol: constants, encoding, decoding, command builders.
|
||||
//!
|
||||
//! Frame format (USB serial):
|
||||
//! - Outbound (host -> device): `<` (0x3C) + 2-byte LE length + frame data
|
||||
//! - Inbound (device -> host): `>` (0x3E) + 2-byte LE length + frame data
|
||||
//! - Baud: 115200, 8N1
|
||||
//! - Max message payload: 160 bytes
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
// --- Frame markers ---
|
||||
pub const OUTBOUND_MARKER: u8 = 0x3C; // '<' (host -> device)
|
||||
pub const INBOUND_MARKER: u8 = 0x3E; // '>' (device -> host)
|
||||
|
||||
// --- Commands (host -> device) ---
|
||||
pub const CMD_APP_START: u8 = 0x01;
|
||||
pub const CMD_SEND_TXT_MSG: u8 = 0x02;
|
||||
pub const CMD_SEND_CHANNEL_TXT_MSG: u8 = 0x03;
|
||||
pub const CMD_GET_CONTACTS: u8 = 0x04;
|
||||
pub const CMD_GET_DEVICE_TIME: u8 = 0x05;
|
||||
pub const CMD_SET_DEVICE_TIME: u8 = 0x06;
|
||||
pub const CMD_SEND_SELF_ADVERT: u8 = 0x07;
|
||||
pub const CMD_SET_ADVERT_NAME: u8 = 0x08;
|
||||
pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
|
||||
pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B;
|
||||
pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C;
|
||||
pub const CMD_SET_TUNING_PARAMS: u8 = 0x15;
|
||||
pub const CMD_DEVICE_QUERY: u8 = 0x16;
|
||||
pub const CMD_GET_CHANNEL: u8 = 0x1F;
|
||||
pub const CMD_SET_CHANNEL: u8 = 0x20;
|
||||
pub const CMD_GET_STATS: u8 = 0x38;
|
||||
|
||||
// --- Response codes (device -> host, synchronous) ---
|
||||
pub const RESP_OK: u8 = 0x00;
|
||||
pub const RESP_ERR: u8 = 0x01;
|
||||
pub const RESP_CONTACT_START: u8 = 0x02;
|
||||
pub const RESP_CONTACT: u8 = 0x03;
|
||||
pub const RESP_CONTACT_END: u8 = 0x04;
|
||||
pub const RESP_SELF_INFO: u8 = 0x05;
|
||||
pub const RESP_SENT: u8 = 0x06;
|
||||
pub const RESP_CONTACT_MSG: u8 = 0x07;
|
||||
pub const RESP_CHANNEL_MSG: u8 = 0x08;
|
||||
pub const RESP_CURRENT_TIME: u8 = 0x09;
|
||||
pub const RESP_NO_MORE_MESSAGES: u8 = 0x0A;
|
||||
pub const RESP_CONTACT_URI: u8 = 0x0B;
|
||||
pub const RESP_BATTERY: u8 = 0x0C;
|
||||
pub const RESP_DEVICE_INFO: u8 = 0x0D;
|
||||
pub const RESP_CONTACT_MSG_V3: u8 = 0x10;
|
||||
pub const RESP_CHANNEL_MSG_V3: u8 = 0x11;
|
||||
pub const RESP_CHANNEL_INFO: u8 = 0x12;
|
||||
pub const RESP_STATS: u8 = 0x18;
|
||||
|
||||
// --- Push notification codes (device -> host, async, >= 0x80) ---
|
||||
pub const PUSH_CONTACT_ADVERT: u8 = 0x80;
|
||||
pub const PUSH_PATH_UPDATE: u8 = 0x81;
|
||||
pub const PUSH_ACK: u8 = 0x82;
|
||||
pub const PUSH_MESSAGES_WAITING: u8 = 0x83;
|
||||
pub const PUSH_RAW_DATA: u8 = 0x84;
|
||||
pub const PUSH_LOG_DATA: u8 = 0x88;
|
||||
pub const PUSH_NEW_CONTACT: u8 = 0x8A;
|
||||
|
||||
// --- Error codes ---
|
||||
pub const ERR_UNSUPPORTED_CMD: u8 = 0x01;
|
||||
pub const ERR_NOT_FOUND: u8 = 0x02;
|
||||
pub const ERR_TABLE_FULL: u8 = 0x03;
|
||||
pub const ERR_BAD_STATE: u8 = 0x04;
|
||||
pub const ERR_FILE_IO: u8 = 0x05;
|
||||
pub const ERR_ILLEGAL_ARG: u8 = 0x06;
|
||||
|
||||
/// Maximum payload size for a single LoRa message.
|
||||
pub const MAX_MESSAGE_LEN: usize = 160;
|
||||
|
||||
/// Minimum frame size: marker (1) + length (2) + command/response (1) = 4 bytes.
|
||||
const MIN_FRAME_SIZE: usize = 4;
|
||||
|
||||
/// Protocol version we advertise during handshake.
|
||||
const PROTOCOL_VERSION: u8 = 3;
|
||||
|
||||
// ─── Frame encoding ─────────────────────────────────────────────────────
|
||||
|
||||
/// Encode a command frame for sending to the device.
|
||||
/// Returns: `>` + 2-byte LE length + data
|
||||
pub fn encode_frame(data: &[u8]) -> Vec<u8> {
|
||||
let len = data.len() as u16;
|
||||
let mut frame = Vec::with_capacity(3 + data.len());
|
||||
frame.push(OUTBOUND_MARKER);
|
||||
frame.extend_from_slice(&len.to_le_bytes());
|
||||
frame.extend_from_slice(data);
|
||||
frame
|
||||
}
|
||||
|
||||
/// Result of parsing one inbound frame from the device.
|
||||
#[derive(Debug)]
|
||||
pub struct InboundFrame {
|
||||
/// Response or push notification code (first byte of payload).
|
||||
pub code: u8,
|
||||
/// Remaining payload after the code byte.
|
||||
pub data: Vec<u8>,
|
||||
/// Total bytes consumed from the buffer (for advancing read position).
|
||||
pub bytes_consumed: usize,
|
||||
}
|
||||
|
||||
/// Try to parse one inbound frame from a buffer.
|
||||
/// Returns `None` if the buffer doesn't contain a complete frame yet.
|
||||
pub fn decode_frame(buf: &[u8]) -> Option<InboundFrame> {
|
||||
if buf.len() < MIN_FRAME_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the inbound marker
|
||||
let start = buf.iter().position(|&b| b == INBOUND_MARKER)?;
|
||||
let remaining = &buf[start..];
|
||||
|
||||
if remaining.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let len = u16::from_le_bytes([remaining[1], remaining[2]]) as usize;
|
||||
let total = 3 + len; // marker + 2 length bytes + payload
|
||||
|
||||
if remaining.len() < total {
|
||||
return None; // incomplete frame
|
||||
}
|
||||
|
||||
if len == 0 {
|
||||
return None; // empty payload is invalid
|
||||
}
|
||||
|
||||
let payload = &remaining[3..total];
|
||||
let code = payload[0];
|
||||
let data = payload[1..].to_vec();
|
||||
|
||||
Some(InboundFrame {
|
||||
code,
|
||||
data,
|
||||
bytes_consumed: start + total,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Command builders ───────────────────────────────────────────────────
|
||||
|
||||
/// CMD_DEVICE_QUERY (0x16): Query device capabilities and negotiate protocol version.
|
||||
pub fn build_device_query() -> Vec<u8> {
|
||||
encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION])
|
||||
}
|
||||
|
||||
/// CMD_APP_START (0x01): Initialize communication session.
|
||||
/// Format matches official meshcore_py: [0x01][version][padded_name]
|
||||
/// The official library sends: b"\x01\x03 mccli"
|
||||
pub fn build_app_start(app_name: &str) -> Vec<u8> {
|
||||
let mut data = vec![CMD_APP_START, PROTOCOL_VERSION];
|
||||
// Pad name to 6 chars minimum (matching official library behavior)
|
||||
let name_bytes = app_name.as_bytes();
|
||||
let padded_len = name_bytes.len().max(6);
|
||||
let len = padded_len.min(32);
|
||||
// Pad with spaces if name is shorter than 6 chars
|
||||
for i in 0..len {
|
||||
if i < name_bytes.len() {
|
||||
data.push(name_bytes[i]);
|
||||
} else {
|
||||
data.push(b' ');
|
||||
}
|
||||
}
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_SET_DEVICE_TIME (0x06): Sync device clock with Unix timestamp.
|
||||
pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
|
||||
let mut data = vec![CMD_SET_DEVICE_TIME];
|
||||
data.extend_from_slice(&(unix_secs as u32).to_le_bytes());
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
|
||||
pub fn build_set_advert_name(name: &str) -> Vec<u8> {
|
||||
let mut data = vec![CMD_SET_ADVERT_NAME];
|
||||
let name_bytes = name.as_bytes();
|
||||
let len = name_bytes.len().min(32);
|
||||
data.extend_from_slice(&name_bytes[..len]);
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_SEND_TXT_MSG (0x02): Send a text message to a specific contact.
|
||||
/// Destination is the first 6 bytes of the contact's public key (hex decoded).
|
||||
/// Format: 0x02 + 0x00 (txt_type) + attempt(1B) + timestamp(4B LE) + dest_prefix(6B) + text
|
||||
pub fn build_send_text(dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<Vec<u8>> {
|
||||
if msg.len() > MAX_MESSAGE_LEN {
|
||||
anyhow::bail!(
|
||||
"Message too large for LoRa: {} bytes (max {})",
|
||||
msg.len(),
|
||||
MAX_MESSAGE_LEN
|
||||
);
|
||||
}
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as u32;
|
||||
|
||||
let mut data = vec![CMD_SEND_TXT_MSG, 0x00, 0x00]; // cmd + txt_type=0 + attempt=0
|
||||
data.extend_from_slice(×tamp.to_le_bytes());
|
||||
data.extend_from_slice(dest_pubkey_prefix);
|
||||
data.extend_from_slice(msg);
|
||||
Ok(encode_frame(&data))
|
||||
}
|
||||
|
||||
/// CMD_SEND_CHANNEL_TXT_MSG (0x03): Broadcast a text message on a channel.
|
||||
pub fn build_send_channel_text(channel: u8, msg: &[u8]) -> Result<Vec<u8>> {
|
||||
if msg.len() > MAX_MESSAGE_LEN {
|
||||
anyhow::bail!(
|
||||
"Message too large for LoRa: {} bytes (max {})",
|
||||
msg.len(),
|
||||
MAX_MESSAGE_LEN
|
||||
);
|
||||
}
|
||||
let mut data = vec![CMD_SEND_CHANNEL_TXT_MSG, channel];
|
||||
data.extend_from_slice(msg);
|
||||
Ok(encode_frame(&data))
|
||||
}
|
||||
|
||||
/// CMD_GET_CONTACTS (0x04): Request the contact list from the device.
|
||||
pub fn build_get_contacts() -> Vec<u8> {
|
||||
encode_frame(&[CMD_GET_CONTACTS])
|
||||
}
|
||||
|
||||
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
|
||||
pub fn build_sync_next_message() -> Vec<u8> {
|
||||
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
|
||||
}
|
||||
|
||||
/// CMD_SEND_SELF_ADVERT (0x07): Broadcast our advertisement to the mesh.
|
||||
pub fn build_send_self_advert() -> Vec<u8> {
|
||||
encode_frame(&[CMD_SEND_SELF_ADVERT])
|
||||
}
|
||||
|
||||
/// CMD_GET_STATS (0x38): Request device statistics.
|
||||
pub fn build_get_stats() -> Vec<u8> {
|
||||
encode_frame(&[CMD_GET_STATS])
|
||||
}
|
||||
|
||||
// ─── Response parsers ───────────────────────────────────────────────────
|
||||
|
||||
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
||||
/// Returns firmware version string and device capabilities.
|
||||
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
|
||||
// Device info format varies by firmware version.
|
||||
// Minimum: firmware version string (null-terminated) + max_contacts (u16 LE)
|
||||
if data.is_empty() {
|
||||
anyhow::bail!("Empty device info response");
|
||||
}
|
||||
|
||||
// Find null terminator for version string, or use all data as version
|
||||
let version_end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
|
||||
let version = String::from_utf8_lossy(&data[..version_end]).to_string();
|
||||
|
||||
let max_contacts = if data.len() > version_end + 2 {
|
||||
u16::from_le_bytes([data[version_end + 1], data[version_end + 2]])
|
||||
} else {
|
||||
100 // default
|
||||
};
|
||||
|
||||
Ok((version, max_contacts))
|
||||
}
|
||||
|
||||
/// Parse RESP_SELF_INFO (0x05) response.
|
||||
/// Returns (node_id, advert_name).
|
||||
pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
if data.len() < 4 {
|
||||
anyhow::bail!("Self info response too short: {} bytes", data.len());
|
||||
}
|
||||
|
||||
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
|
||||
// Name follows after fixed fields — find it by scanning for printable ASCII
|
||||
let name_start = 4;
|
||||
let name = if data.len() > name_start {
|
||||
let name_end = data[name_start..]
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.map(|p| name_start + p)
|
||||
.unwrap_or(data.len());
|
||||
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok((node_id, name))
|
||||
}
|
||||
|
||||
/// Parsed contact from RESP_CONTACT (0x03).
|
||||
pub struct ParsedContact {
|
||||
pub public_key_hex: String,
|
||||
pub advert_name: String,
|
||||
pub last_advert: u32,
|
||||
pub contact_type: u8,
|
||||
}
|
||||
|
||||
/// Parse RESP_CONTACT (0x03) response.
|
||||
/// Format: 32B pubkey + 1B type + 1B flags + 1B path_len + 64B path + 32B name + 4B last_advert + 4B lat + 4B lon + 4B lastmod
|
||||
pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
|
||||
if data.len() < 34 {
|
||||
anyhow::bail!("Contact response too short: {} bytes (need >= 34)", data.len());
|
||||
}
|
||||
|
||||
let public_key_hex = hex::encode(&data[0..32]);
|
||||
let contact_type = data[32];
|
||||
// flags at data[33], path_len at data[34]
|
||||
// path at data[35..99] (64 bytes)
|
||||
// name at data[99..131] (32 bytes)
|
||||
let name_start = 99.min(data.len());
|
||||
let name_end = (name_start + 32).min(data.len());
|
||||
let advert_name = if data.len() > name_start {
|
||||
String::from_utf8_lossy(&data[name_start..name_end])
|
||||
.trim_end_matches('\0')
|
||||
.to_string()
|
||||
} else {
|
||||
format!("{}...", &public_key_hex[..8])
|
||||
};
|
||||
|
||||
// last_advert at data[131..135]
|
||||
let last_advert = if data.len() >= 135 {
|
||||
u32::from_le_bytes([data[131], data[132], data[133], data[134]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(ParsedContact {
|
||||
public_key_hex,
|
||||
advert_name,
|
||||
last_advert,
|
||||
contact_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse RESP_CONTACT_MSG_V3 (0x10) - private message.
|
||||
/// Format: SNR(1B) + reserved(2B) + pubkey_prefix(6B) + path_len(1B) + txt_type(1B) + timestamp(4B) + [sig(4B) if txt_type==2] + text
|
||||
/// Returns (sender_pubkey_prefix_hex, text, snr).
|
||||
pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> {
|
||||
if data.len() < 15 {
|
||||
anyhow::bail!("Contact message too short: {} bytes", data.len());
|
||||
}
|
||||
let snr = data[0] as i8;
|
||||
// data[1..3] reserved
|
||||
let pubkey_prefix = hex::encode(&data[3..9]);
|
||||
// data[9] = path_len
|
||||
let txt_type = data[10];
|
||||
// data[11..15] = timestamp
|
||||
let text_start = if txt_type == 2 { 19 } else { 15 }; // skip 4-byte signature if txt_type==2
|
||||
let text = if data.len() > text_start {
|
||||
String::from_utf8_lossy(&data[text_start..]).to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok((pubkey_prefix, text, snr))
|
||||
}
|
||||
|
||||
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
|
||||
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
|
||||
/// Returns (channel_idx, text).
|
||||
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
|
||||
if data.len() < 7 {
|
||||
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
||||
}
|
||||
let channel_idx = data[0];
|
||||
// data[1] = path_len, data[2] = txt_type
|
||||
// data[3..7] = timestamp
|
||||
let text = if data.len() > 7 {
|
||||
String::from_utf8_lossy(&data[7..]).trim_end_matches('\0').to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok((channel_idx, text))
|
||||
}
|
||||
|
||||
/// Parse RESP_CONTACT_MSG (0x07) - v1 private message.
|
||||
/// Format: pubkey_prefix(6B) + path_len(1B) + txt_type(1B) + timestamp(4B) + [sig(4B) if txt_type==2] + text
|
||||
/// Returns (sender_pubkey_prefix_hex, text).
|
||||
pub fn parse_contact_msg_v1(data: &[u8]) -> Result<(String, String)> {
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("Contact message v1 too short: {} bytes", data.len());
|
||||
}
|
||||
let pubkey_prefix = hex::encode(&data[0..6]);
|
||||
// data[6] = path_len, data[7] = txt_type
|
||||
let txt_type = data[7];
|
||||
// data[8..12] = timestamp
|
||||
let text_start = if txt_type == 2 { 16 } else { 12 };
|
||||
let text = if data.len() > text_start {
|
||||
String::from_utf8_lossy(&data[text_start..]).to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok((pubkey_prefix, text))
|
||||
}
|
||||
|
||||
/// Parse RESP_CHANNEL_MSG (0x08) - v1 channel message.
|
||||
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
|
||||
pub fn parse_channel_msg_v1(data: &[u8]) -> Result<(u8, String)> {
|
||||
if data.len() < 7 {
|
||||
anyhow::bail!("Channel message v1 too short: {} bytes", data.len());
|
||||
}
|
||||
let channel_idx = data[0];
|
||||
// data[1] = path_len, data[2] = txt_type
|
||||
// data[3..7] = timestamp
|
||||
let text = if data.len() > 7 {
|
||||
String::from_utf8_lossy(&data[7..]).trim_end_matches('\0').to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok((channel_idx, text))
|
||||
}
|
||||
|
||||
/// Parse RESP_ERR (0x01). Returns descriptive error string.
|
||||
pub fn parse_error(data: &[u8]) -> String {
|
||||
if data.is_empty() {
|
||||
return "Unknown device error".to_string();
|
||||
}
|
||||
match data[0] {
|
||||
ERR_UNSUPPORTED_CMD => "Unsupported command".to_string(),
|
||||
ERR_NOT_FOUND => "Not found".to_string(),
|
||||
ERR_TABLE_FULL => "Contact table full".to_string(),
|
||||
ERR_BAD_STATE => "Bad device state".to_string(),
|
||||
ERR_FILE_IO => "Device file I/O error".to_string(),
|
||||
ERR_ILLEGAL_ARG => "Illegal argument".to_string(),
|
||||
code => format!("Device error code 0x{:02x}", code),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a response code is a push notification (async event from device).
|
||||
pub fn is_push_notification(code: u8) -> bool {
|
||||
code >= 0x80
|
||||
}
|
||||
|
||||
// ─── Archipelago identity wire format ───────────────────────────────────
|
||||
|
||||
/// Prefix for Archipelago identity broadcasts over mesh channel.
|
||||
pub const ARCHY_IDENTITY_PREFIX: &str = "ARCHY:1:";
|
||||
|
||||
/// Encode an Archipelago identity announcement for channel broadcast.
|
||||
/// Compact format: `ARCHY:2:{ed25519_pubkey_hex}:{x25519_pubkey_hex}`
|
||||
/// DID is omitted to fit within 160-byte LoRa limit — receiver reconstructs did:key from ed25519 pubkey.
|
||||
/// Total: 8 + 64 + 1 + 64 = 137 bytes (fits in 160).
|
||||
pub fn encode_identity_broadcast(_did: &str, ed_pubkey_hex: &str, x25519_pubkey_hex: &str) -> String {
|
||||
format!("ARCHY:2:{}:{}", ed_pubkey_hex, x25519_pubkey_hex)
|
||||
}
|
||||
|
||||
/// Try to parse an Archipelago identity from a received channel message.
|
||||
/// Returns (did, ed25519_pubkey_hex, x25519_pubkey_hex) if valid.
|
||||
///
|
||||
/// Supports two formats:
|
||||
/// - v2 (compact): `ARCHY:2:{ed25519_hex_64}:{x25519_hex_64}` — DID reconstructed from ed25519
|
||||
/// - v1 (legacy): `ARCHY:1:{did}:{ed25519_hex_64}:{x25519_hex_64}`
|
||||
pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> {
|
||||
// Try v2 compact format first
|
||||
if let Some(rest) = msg.strip_prefix("ARCHY:2:") {
|
||||
let parts: Vec<&str> = rest.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let ed_pubkey = parts[0];
|
||||
let x25519_pubkey = parts[1];
|
||||
if ed_pubkey.len() != 64 || x25519_pubkey.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
if !ed_pubkey.chars().all(|c| c.is_ascii_hexdigit())
|
||||
|| !x25519_pubkey.chars().all(|c| c.is_ascii_hexdigit())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Reconstruct DID from ed25519 pubkey
|
||||
let did = crate::identity::did_key_from_pubkey_hex(ed_pubkey).ok()?;
|
||||
return Some((did, ed_pubkey.to_string(), x25519_pubkey.to_string()));
|
||||
}
|
||||
|
||||
// Try v1 legacy format
|
||||
let rest = msg.strip_prefix(ARCHY_IDENTITY_PREFIX)?;
|
||||
let last_colon = rest.rfind(':')?;
|
||||
let x25519_pubkey = &rest[last_colon + 1..];
|
||||
if x25519_pubkey.len() != 64 || !x25519_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let before_x25519 = &rest[..last_colon];
|
||||
let second_last_colon = before_x25519.rfind(':')?;
|
||||
let ed_pubkey = &before_x25519[second_last_colon + 1..];
|
||||
if ed_pubkey.len() != 64 || !ed_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let did = &before_x25519[..second_last_colon];
|
||||
if !did.starts_with("did:key:z") {
|
||||
return None;
|
||||
}
|
||||
Some((did.to_string(), ed_pubkey.to_string(), x25519_pubkey.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encode_frame() {
|
||||
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
|
||||
assert_eq!(frame[0], OUTBOUND_MARKER);
|
||||
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 2);
|
||||
assert_eq!(frame[3], CMD_DEVICE_QUERY);
|
||||
assert_eq!(frame[4], PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_frame_complete() {
|
||||
// Simulate an inbound frame: < + len(2) + [RESP_OK]
|
||||
let buf = vec![INBOUND_MARKER, 0x01, 0x00, RESP_OK];
|
||||
let frame = decode_frame(&buf).expect("should parse");
|
||||
assert_eq!(frame.code, RESP_OK);
|
||||
assert!(frame.data.is_empty());
|
||||
assert_eq!(frame.bytes_consumed, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_frame_with_data() {
|
||||
// < + len(5) + [RESP_SELF_INFO, 0x01, 0x02, 0x03, 0x04]
|
||||
let buf = vec![INBOUND_MARKER, 0x05, 0x00, RESP_SELF_INFO, 0x01, 0x02, 0x03, 0x04];
|
||||
let frame = decode_frame(&buf).expect("should parse");
|
||||
assert_eq!(frame.code, RESP_SELF_INFO);
|
||||
assert_eq!(frame.data, vec![0x01, 0x02, 0x03, 0x04]);
|
||||
assert_eq!(frame.bytes_consumed, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_frame_incomplete() {
|
||||
let buf = vec![INBOUND_MARKER, 0x05, 0x00, RESP_OK]; // says 5 bytes but only 1
|
||||
assert!(decode_frame(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_frame_no_marker() {
|
||||
let buf = vec![0xFF, 0x01, 0x00, RESP_OK];
|
||||
assert!(decode_frame(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_frame_skips_garbage() {
|
||||
// Garbage bytes before the actual frame
|
||||
let buf = vec![0xFF, 0xAA, INBOUND_MARKER, 0x01, 0x00, RESP_OK];
|
||||
let frame = decode_frame(&buf).expect("should skip garbage");
|
||||
assert_eq!(frame.code, RESP_OK);
|
||||
assert_eq!(frame.bytes_consumed, 6); // 2 garbage + 4 frame
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_device_query() {
|
||||
let frame = build_device_query();
|
||||
assert_eq!(frame[0], OUTBOUND_MARKER);
|
||||
assert_eq!(frame[3], CMD_DEVICE_QUERY);
|
||||
assert_eq!(frame[4], PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_app_start() {
|
||||
let frame = build_app_start("Archipelago");
|
||||
assert_eq!(frame[3], CMD_APP_START);
|
||||
let name = &frame[4..];
|
||||
assert_eq!(std::str::from_utf8(name).unwrap(), "Archipelago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_set_device_time() {
|
||||
let ts: u64 = 1710600000;
|
||||
let frame = build_set_device_time(ts);
|
||||
assert_eq!(frame[3], CMD_SET_DEVICE_TIME);
|
||||
let time_bytes = &frame[4..8];
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([time_bytes[0], time_bytes[1], time_bytes[2], time_bytes[3]]),
|
||||
ts as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_send_text() {
|
||||
let frame = build_send_text(42, b"hello").unwrap();
|
||||
assert_eq!(frame[3], CMD_SEND_TXT_MSG);
|
||||
let cid = u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]]);
|
||||
assert_eq!(cid, 42);
|
||||
assert_eq!(&frame[8..], b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_send_text_too_large() {
|
||||
let big = vec![0u8; MAX_MESSAGE_LEN + 1];
|
||||
assert!(build_send_text(1, &big).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_send_channel_text() {
|
||||
let frame = build_send_channel_text(0, b"test").unwrap();
|
||||
assert_eq!(frame[3], CMD_SEND_CHANNEL_TXT_MSG);
|
||||
assert_eq!(frame[4], 0); // channel 0
|
||||
assert_eq!(&frame[5..], b"test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_broadcast_roundtrip() {
|
||||
let did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
|
||||
let ed_pub = "a".repeat(64);
|
||||
let x25519_pub = "b".repeat(64);
|
||||
|
||||
let encoded = encode_identity_broadcast(did, &ed_pub, &x25519_pub);
|
||||
assert!(encoded.starts_with(ARCHY_IDENTITY_PREFIX));
|
||||
|
||||
let (parsed_did, parsed_ed, parsed_x) = parse_identity_broadcast(&encoded).unwrap();
|
||||
assert_eq!(parsed_did, did);
|
||||
assert_eq!(parsed_ed, ed_pub);
|
||||
assert_eq!(parsed_x, x25519_pub);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_broadcast_invalid() {
|
||||
assert!(parse_identity_broadcast("not an identity").is_none());
|
||||
assert!(parse_identity_broadcast("ARCHY:1:bad").is_none());
|
||||
assert!(parse_identity_broadcast("ARCHY:1:did:key:z123:short:short").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_error_codes() {
|
||||
assert_eq!(parse_error(&[ERR_NOT_FOUND]), "Not found");
|
||||
assert_eq!(parse_error(&[ERR_TABLE_FULL]), "Contact table full");
|
||||
assert_eq!(parse_error(&[]), "Unknown device error");
|
||||
assert!(parse_error(&[0xFF]).contains("0xff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_push_notification() {
|
||||
assert!(is_push_notification(PUSH_NEW_CONTACT));
|
||||
assert!(is_push_notification(PUSH_ACK));
|
||||
assert!(is_push_notification(0x80));
|
||||
assert!(!is_push_notification(RESP_OK));
|
||||
assert!(!is_push_notification(RESP_DEVICE_INFO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_self_info() {
|
||||
let mut data = vec![0x2A, 0x00, 0x00, 0x00]; // node_id = 42
|
||||
data.extend_from_slice(b"TestNode\0");
|
||||
let (id, name) = parse_self_info(&data).unwrap();
|
||||
assert_eq!(id, 42);
|
||||
assert_eq!(name, "TestNode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_self_info_too_short() {
|
||||
assert!(parse_self_info(&[0x01, 0x02]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_received_message() {
|
||||
let mut data = vec![0x05, 0x00, 0x00, 0x00]; // contact_id = 5
|
||||
data.extend_from_slice(&(-75i16).to_le_bytes()); // rssi = -75
|
||||
data.extend_from_slice(b"hello mesh");
|
||||
let (cid, payload, rssi) = parse_received_message(&data).unwrap();
|
||||
assert_eq!(cid, 5);
|
||||
assert_eq!(rssi, -75);
|
||||
assert_eq!(payload, b"hello mesh");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Async serial driver for Meshcore devices.
|
||||
//!
|
||||
//! Handles opening the serial port, reading/writing frames,
|
||||
//! and the initialization handshake sequence.
|
||||
|
||||
use super::protocol::{self, InboundFrame};
|
||||
use super::types::DeviceInfo;
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Serial port configuration for Meshcore Companion USB.
|
||||
const BAUD_RATE: u32 = 115200;
|
||||
|
||||
/// Timeout for reading a response frame from the device.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Timeout for writing a frame to the device.
|
||||
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Buffer size for serial reads.
|
||||
const READ_BUF_SIZE: usize = 512;
|
||||
|
||||
/// Application name sent during handshake.
|
||||
const APP_NAME: &str = "Archipelago";
|
||||
|
||||
/// Async Meshcore device handle.
|
||||
pub struct MeshcoreDevice {
|
||||
port: serial2_tokio::SerialPort,
|
||||
read_buf: Vec<u8>,
|
||||
pub node_id: Option<u32>,
|
||||
pub advert_name: Option<String>,
|
||||
pub device_info: Option<DeviceInfo>,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl MeshcoreDevice {
|
||||
/// Open a serial port and verify it's a Meshcore device.
|
||||
pub async fn open(path: &str) -> Result<Self> {
|
||||
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE)
|
||||
.context(format!("Failed to open serial port {}", path))?;
|
||||
|
||||
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
read_buf: Vec::with_capacity(READ_BUF_SIZE),
|
||||
node_id: None,
|
||||
advert_name: None,
|
||||
device_info: None,
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the Meshcore initialization handshake.
|
||||
/// Matches the official meshcore_py library sequence:
|
||||
/// 1. CMD_APP_START -> RESP_SELF_INFO (this is the first command, not device_query)
|
||||
/// 2. CMD_SET_DEVICE_TIME (sync clock)
|
||||
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
|
||||
info!("Starting Meshcore handshake on {}", self.device_path);
|
||||
|
||||
// Step 1: App start (the official library sends this first)
|
||||
self.send_raw(&protocol::build_app_start(APP_NAME)).await?;
|
||||
|
||||
let frame = self
|
||||
.recv_frame_timeout(READ_TIMEOUT)
|
||||
.await
|
||||
.context("No response to APP_START — is this a Meshcore Companion USB device?")?;
|
||||
|
||||
info!(code = frame.code, data_len = frame.data.len(), "Got response to APP_START");
|
||||
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!("App start failed: {}", protocol::parse_error(&frame.data));
|
||||
}
|
||||
|
||||
// The response could be SELF_INFO or something else depending on firmware version
|
||||
let (node_id, name) = if frame.code == protocol::RESP_SELF_INFO {
|
||||
protocol::parse_self_info(&frame.data)
|
||||
.context("Failed to parse self info")?
|
||||
} else {
|
||||
// Try to parse whatever we got
|
||||
info!(code = frame.code, "Unexpected response code, trying to parse as self info");
|
||||
protocol::parse_self_info(&frame.data)
|
||||
.unwrap_or((0, String::new()))
|
||||
};
|
||||
|
||||
info!(node_id, name = %name, "Meshcore identity");
|
||||
|
||||
self.node_id = Some(node_id);
|
||||
self.advert_name = Some(name.clone());
|
||||
|
||||
// Step 2: Sync device clock
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
self.send_raw(&protocol::build_set_device_time(now)).await?;
|
||||
// Time set response is best-effort — don't fail if it times out
|
||||
match self.recv_frame_timeout(Duration::from_secs(2)).await {
|
||||
Ok(frame) if frame.code == protocol::RESP_OK => {
|
||||
debug!("Device clock synced");
|
||||
}
|
||||
Ok(frame) => {
|
||||
warn!(code = frame.code, "Unexpected response to SET_DEVICE_TIME");
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("No response to SET_DEVICE_TIME (continuing anyway)");
|
||||
}
|
||||
}
|
||||
|
||||
let info = DeviceInfo {
|
||||
firmware_version: name.clone(),
|
||||
node_id,
|
||||
max_contacts: 100,
|
||||
device_type: super::types::DeviceType::Meshcore,
|
||||
};
|
||||
self.device_info = Some(info.clone());
|
||||
|
||||
info!("Meshcore initialization complete on {}", self.device_path);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Set the advertised name on the mesh network.
|
||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
self.send_raw(&protocol::build_set_advert_name(name)).await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!("Set advert name failed: {}", protocol::parse_error(&frame.data));
|
||||
}
|
||||
self.advert_name = Some(name.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast our advertisement to the mesh.
|
||||
pub async fn send_self_advert(&mut self) -> Result<()> {
|
||||
self.send_raw(&protocol::build_send_self_advert()).await?;
|
||||
// Response is RESP_OK or RESP_SENT
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!("Self advert failed: {}", protocol::parse_error(&frame.data));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a text message to a contact by their public key prefix (first 6 bytes).
|
||||
pub async fn send_text(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||
let frame_data = protocol::build_send_text(dest_pubkey_prefix, msg)?;
|
||||
self.send_raw(&frame_data).await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!("Send text failed: {}", protocol::parse_error(&frame.data));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast a text message on a channel.
|
||||
pub async fn send_channel_text(&mut self, channel: u8, msg: &[u8]) -> Result<()> {
|
||||
let frame_data = protocol::build_send_channel_text(channel, msg)?;
|
||||
self.send_raw(&frame_data).await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!(
|
||||
"Channel broadcast failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the list of known contacts from the device.
|
||||
/// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<protocol::ParsedContact>> {
|
||||
self.send_raw(&protocol::build_get_contacts()).await?;
|
||||
|
||||
let mut contacts = Vec::new();
|
||||
loop {
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
match frame.code {
|
||||
protocol::RESP_CONTACT_START => {
|
||||
// Contains the count of contacts to follow
|
||||
let count = if frame.data.len() >= 4 {
|
||||
u32::from_le_bytes([frame.data[0], frame.data[1], frame.data[2], frame.data[3]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
debug!(count, "Contact list start");
|
||||
}
|
||||
protocol::RESP_CONTACT => {
|
||||
match protocol::parse_contact(&frame.data) {
|
||||
Ok(contact) => contacts.push(contact),
|
||||
Err(e) => warn!("Failed to parse contact: {}", e),
|
||||
}
|
||||
}
|
||||
protocol::RESP_CONTACT_END => {
|
||||
debug!(count = contacts.len(), "Contact list complete");
|
||||
break;
|
||||
}
|
||||
protocol::RESP_OK => break,
|
||||
protocol::RESP_ERR => {
|
||||
anyhow::bail!("Get contacts failed: {}", protocol::parse_error(&frame.data));
|
||||
}
|
||||
_ => {
|
||||
debug!(code = frame.code, "Unexpected response during contact list");
|
||||
// Don't break — might be a push notification interspersed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
/// Retrieve queued messages from the device.
|
||||
/// Returns raw frames (code + data) for the listener to parse.
|
||||
pub async fn sync_messages(&mut self) -> Result<Vec<protocol::InboundFrame>> {
|
||||
self.send_raw(&protocol::build_sync_next_message()).await?;
|
||||
|
||||
let mut frames = Vec::new();
|
||||
loop {
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
match frame.code {
|
||||
// All message types (v1 and v3)
|
||||
protocol::RESP_CONTACT_MSG | protocol::RESP_CONTACT_MSG_V3
|
||||
| protocol::RESP_CHANNEL_MSG | protocol::RESP_CHANNEL_MSG_V3 => {
|
||||
frames.push(frame);
|
||||
// Request next message
|
||||
self.send_raw(&protocol::build_sync_next_message()).await?;
|
||||
}
|
||||
protocol::RESP_NO_MORE_MESSAGES => break,
|
||||
protocol::RESP_OK => break,
|
||||
protocol::RESP_ERR => {
|
||||
anyhow::bail!(
|
||||
"Sync messages failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Push notifications can arrive during sync — skip them
|
||||
if protocol::is_push_notification(frame.code) {
|
||||
continue;
|
||||
}
|
||||
debug!(code = frame.code, "Unexpected response during message sync");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
/// Write raw bytes to the serial port.
|
||||
pub async fn send_raw(&mut self, data: &[u8]) -> Result<()> {
|
||||
tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(data))
|
||||
.await
|
||||
.context("Serial write timed out")?
|
||||
.context("Serial write failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try to read and parse one complete inbound frame.
|
||||
/// Returns the frame if one is available, or reads more data from serial.
|
||||
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
||||
// First check if we already have a complete frame in the buffer
|
||||
if let Some(frame) = protocol::decode_frame(&self.read_buf) {
|
||||
let consumed = frame.bytes_consumed;
|
||||
let result = frame;
|
||||
self.read_buf.drain(..consumed);
|
||||
return Ok(Some(result));
|
||||
}
|
||||
|
||||
// Try to read more data (non-blocking via small timeout)
|
||||
let mut tmp = [0u8; READ_BUF_SIZE];
|
||||
match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await {
|
||||
Ok(Ok(n)) if n > 0 => {
|
||||
self.read_buf.extend_from_slice(&tmp[..n]);
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
|
||||
// Try parsing again with new data
|
||||
if let Some(frame) = protocol::decode_frame(&self.read_buf) {
|
||||
let consumed = frame.bytes_consumed;
|
||||
let result = frame;
|
||||
self.read_buf.drain(..consumed);
|
||||
return Ok(Some(result));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Read one complete inbound frame with timeout.
|
||||
pub async fn recv_frame_timeout(&mut self, timeout: Duration) -> Result<InboundFrame> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
// Check buffer for a complete frame
|
||||
if let Some(frame) = protocol::decode_frame(&self.read_buf) {
|
||||
let consumed = frame.bytes_consumed;
|
||||
let result = frame;
|
||||
self.read_buf.drain(..consumed);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Read more data from serial
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
anyhow::bail!("Timeout waiting for serial frame");
|
||||
}
|
||||
|
||||
let mut tmp = [0u8; READ_BUF_SIZE];
|
||||
match tokio::time::timeout(remaining.min(Duration::from_millis(100)), self.port.read(&mut tmp))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(0)) => anyhow::bail!("Serial port closed"),
|
||||
Ok(Ok(n)) => {
|
||||
self.read_buf.extend_from_slice(&tmp[..n]);
|
||||
}
|
||||
Ok(Err(e)) => return Err(e).context("Serial read error"),
|
||||
Err(_) => continue, // timeout on this read, try again if deadline not reached
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the device path this handle is connected to.
|
||||
pub fn path(&self) -> &str {
|
||||
&self.device_path
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Device detection ───────────────────────────────────────────────────
|
||||
|
||||
/// Candidate serial device paths to check on Linux.
|
||||
const SERIAL_CANDIDATES: &[&str] = &[
|
||||
"/dev/ttyUSB0",
|
||||
"/dev/ttyUSB1",
|
||||
"/dev/ttyUSB2",
|
||||
"/dev/ttyACM0",
|
||||
"/dev/ttyACM1",
|
||||
"/dev/ttyACM2",
|
||||
];
|
||||
|
||||
/// Scan for serial devices that could be Meshcore radios.
|
||||
/// Returns paths to existing serial device files.
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
devices
|
||||
}
|
||||
|
||||
/// Try to open and handshake with each detected serial device.
|
||||
/// Returns the first device that responds as Meshcore.
|
||||
pub async fn probe_for_meshcore(paths: &[String]) -> Option<(String, DeviceInfo)> {
|
||||
for path in paths {
|
||||
debug!(path = %path, "Probing for Meshcore device");
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut device) => {
|
||||
match device.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device");
|
||||
// Drop the device so the listener can open it
|
||||
drop(device);
|
||||
return Some((path.clone(), info));
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(path = %path, error = %e, "Not a Meshcore device");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(path = %path, error = %e, "Could not open serial port");
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Shared types for mesh networking subsystem.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Device firmware type, detected via protocol handshake.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DeviceType {
|
||||
Meshcore,
|
||||
Meshtastic,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DeviceType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Meshcore => write!(f, "meshcore"),
|
||||
Self::Meshtastic => write!(f, "meshtastic"),
|
||||
Self::Unknown => write!(f, "unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A peer discovered via mesh radio.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshPeer {
|
||||
/// Meshcore contact ID (uint32).
|
||||
pub contact_id: u32,
|
||||
/// Advertised name on the mesh network.
|
||||
pub advert_name: String,
|
||||
/// Archipelago DID (did:key:z...) if identity was received.
|
||||
pub did: Option<String>,
|
||||
/// Ed25519 public key hex if identity was received.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// X25519 public key (32 bytes) for key agreement.
|
||||
#[serde(skip)]
|
||||
pub x25519_pubkey: Option<[u8; 32]>,
|
||||
/// Last received signal strength (dBm).
|
||||
pub rssi: Option<i16>,
|
||||
/// Signal-to-noise ratio.
|
||||
pub snr: Option<f32>,
|
||||
/// When we last heard from this peer.
|
||||
pub last_heard: String,
|
||||
/// Number of hops to reach this peer.
|
||||
pub hops: u8,
|
||||
}
|
||||
|
||||
/// Direction of a mesh message.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageDirection {
|
||||
Sent,
|
||||
Received,
|
||||
}
|
||||
|
||||
/// A mesh message (sent or received).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshMessage {
|
||||
pub id: u64,
|
||||
pub direction: MessageDirection,
|
||||
/// Meshcore contact ID of the peer.
|
||||
pub peer_contact_id: u32,
|
||||
/// Peer name (for display).
|
||||
pub peer_name: Option<String>,
|
||||
/// Decrypted plaintext content.
|
||||
pub plaintext: String,
|
||||
pub timestamp: String,
|
||||
/// Whether delivery was confirmed via ACK.
|
||||
pub delivered: bool,
|
||||
/// Whether the message was end-to-end encrypted.
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
/// Overall mesh subsystem status.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshStatus {
|
||||
pub enabled: bool,
|
||||
pub device_type: DeviceType,
|
||||
pub device_path: Option<String>,
|
||||
pub device_connected: bool,
|
||||
pub firmware_version: Option<String>,
|
||||
pub self_node_id: Option<u32>,
|
||||
pub self_advert_name: Option<String>,
|
||||
pub peer_count: usize,
|
||||
pub channel_name: String,
|
||||
pub messages_sent: u64,
|
||||
pub messages_received: u64,
|
||||
}
|
||||
|
||||
/// Information returned from device during initialization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
pub firmware_version: String,
|
||||
pub node_id: u32,
|
||||
pub max_contacts: u16,
|
||||
pub device_type: DeviceType,
|
||||
}
|
||||
|
||||
/// Events emitted by the mesh listener for other components to consume.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MeshEvent {
|
||||
DeviceConnected(DeviceInfo),
|
||||
DeviceDisconnected,
|
||||
PeerDiscovered(MeshPeer),
|
||||
PeerUpdated(MeshPeer),
|
||||
MessageReceived(MeshMessage),
|
||||
MessageDelivered { message_id: u64 },
|
||||
IdentityReceived {
|
||||
contact_id: u32,
|
||||
did: String,
|
||||
pubkey_hex: String,
|
||||
x25519_pubkey: [u8; 32],
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user