backup commit

This commit is contained in:
Dorian
2026-03-17 00:03:08 +00:00
parent 9156eee017
commit 32f89fa8d5
43 changed files with 9514 additions and 308 deletions
+217
View File
@@ -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);
}
}
+656
View File
@@ -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);
}
}
}
+401
View 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()));
}
}
+661
View File
@@ -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(&timestamp.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");
}
}
+378
View File
@@ -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
}
+114
View File
@@ -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],
},
}