From cfb304a001a94763dae24b9595272d4ea7c9bc6f Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 17 May 2026 18:07:40 -0400 Subject: [PATCH] feat(mesh): add meshtastic serial radio support --- core/archipelago/src/mesh/listener/session.rs | 137 ++++- core/archipelago/src/mesh/meshtastic.rs | 577 ++++++++++++++++++ core/archipelago/src/mesh/mod.rs | 8 +- core/archipelago/src/mesh/protocol.rs | 1 + image-recipe/configs/99-mesh-radio.rules | 4 +- 5 files changed, 699 insertions(+), 28 deletions(-) create mode 100644 core/archipelago/src/mesh/meshtastic.rs diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 6e572392..27786857 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -1,42 +1,140 @@ //! Mesh session lifecycle: connect, initialize, main loop. +use super::super::meshtastic::MeshtasticDevice; use super::super::serial::MeshcoreDevice; use super::super::types::*; use super::{ frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL, }; -use anyhow::Result; +use anyhow::{Context, Result}; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; -/// Scan all candidate serial ports and open the first Meshcore device found. -async fn auto_detect_and_open() -> Result<(String, MeshcoreDevice, DeviceInfo)> { +enum MeshRadioDevice { + Meshcore(MeshcoreDevice), + Meshtastic(MeshtasticDevice), +} + +impl MeshRadioDevice { + fn device_type(&self) -> DeviceType { + match self { + Self::Meshcore(_) => DeviceType::Meshcore, + Self::Meshtastic(_) => DeviceType::Meshtastic, + } + } + + fn advert_name(&self) -> Option { + match self { + Self::Meshcore(device) => device.advert_name.clone(), + Self::Meshtastic(device) => device.advert_name(), + } + } + + async fn set_advert_name(&mut self, name: &str) -> Result<()> { + match self { + Self::Meshcore(device) => device.set_advert_name(name).await, + Self::Meshtastic(device) => device.set_advert_name(name).await, + } + } + + async fn send_self_advert(&mut self) -> Result<()> { + match self { + Self::Meshcore(device) => device.send_self_advert().await, + Self::Meshtastic(device) => device.send_self_advert().await, + } + } + + async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> { + match self { + Self::Meshcore(device) => device.send_channel_text(channel, payload).await, + Self::Meshtastic(device) => device.send_channel_text(channel, payload).await, + } + } + + async fn get_contacts(&mut self) -> Result> { + match self { + Self::Meshcore(device) => device.get_contacts().await, + Self::Meshtastic(device) => device.get_contacts().await, + } + } + + async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> { + match self { + Self::Meshcore(device) => device.reset_contact_path(pubkey).await, + Self::Meshtastic(device) => device.reset_contact_path(pubkey).await, + } + } + + async fn sync_messages(&mut self) -> Result> { + match self { + Self::Meshcore(device) => device.sync_messages().await, + Self::Meshtastic(device) => device.sync_messages().await, + } + } + + async fn try_recv_frame(&mut self) -> Result> { + match self { + Self::Meshcore(device) => device.try_recv_frame().await, + Self::Meshtastic(device) => device.try_recv_frame().await, + } + } +} + +/// Scan all candidate serial ports and open the first supported mesh device found. +async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> { let paths = super::super::serial::detect_serial_devices().await; if paths.is_empty() { anyhow::bail!("No serial devices found in /dev"); } for path in &paths { - debug!(path = %path, "Probing for Meshcore device"); + debug!(path = %path, "Probing for mesh radio device"); match MeshcoreDevice::open(path).await { Ok(mut dev) => match dev.initialize().await { Ok(info) => { info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect"); - return Ok((path.clone(), dev, info)); + return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info)); } Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"), }, Err(e) => debug!(path = %path, error = %e, "Could not open serial port"), } + match MeshtasticDevice::open(path).await { + Ok(mut dev) => match dev.initialize().await { + Ok(info) => { + info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect"); + return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info)); + } + Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"), + }, + Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"), + } } anyhow::bail!( - "No Meshcore device found on {} candidate ports: {:?}", + "No supported mesh radio found on {} candidate ports: {:?}", paths.len(), paths ) } +async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> { + match MeshcoreDevice::open(path).await { + Ok(mut dev) => match dev.initialize().await { + Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)), + Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"), + }, + Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"), + } + match MeshtasticDevice::open(path).await { + Ok(mut dev) => match dev.initialize().await { + Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)), + Err(e) => Err(e).context("Preferred path is not Meshtastic"), + }, + Err(e) => Err(e).context("Could not open preferred path as Meshtastic"), + } +} + /// ASCII marker for the original DM-via-channel format: /// `@DM:` + base64(`[dest_prefix(6)][inner…]`). No sender info on the wire, /// so the receiver has to guess the sender from its contact table — which @@ -90,7 +188,7 @@ fn our_sender_prefix(state: &Arc) -> [u8; 6] { /// with both the recipient and sender prefixes so attribution works on /// the receiver side. async fn send_dm_via_channel( - device: &mut MeshcoreDevice, + device: &mut MeshRadioDevice, state: &Arc, dest_pubkey_prefix: &[u8; 6], payload: &[u8], @@ -166,7 +264,7 @@ async fn send_dm_via_channel( } /// Fetch the contacts list from the device and update the peer cache. -async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc) { +async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) { match device.get_contacts().await { Ok(contacts) => { // Skip firmware contacts the user has explicitly wiped via @@ -239,7 +337,7 @@ async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc) { /// Drain any queued messages from the device. /// Returns `true` if a write/communication error occurred (for failure tracking). async fn sync_queued_messages( - device: &mut MeshcoreDevice, + device: &mut MeshRadioDevice, state: &Arc, our_x25519_secret: &[u8; 32], ) -> bool { @@ -274,20 +372,11 @@ pub(super) async fn run_mesh_session( ) -> Result<()> { // Detect device — try preferred path first, fall back to auto-detect let (device_path, mut device, device_info) = if let Some(path) = preferred_path { - match MeshcoreDevice::open(path).await { - Ok(mut dev) => match dev.initialize().await { - Ok(info) => (path.to_string(), dev, info), - Err(e) => { - warn!( - "Preferred path {} handshake failed: {} — trying auto-detect", - path, e - ); - auto_detect_and_open().await? - } - }, + match open_preferred_path(path).await { + Ok((dev, info)) => (path.to_string(), dev, info), Err(e) => { warn!( - "Preferred path {} open failed: {} — trying auto-detect", + "Preferred path {} probe failed: {} — trying auto-detect", path, e ); auto_detect_and_open().await? @@ -301,11 +390,11 @@ pub(super) async fn run_mesh_session( { let mut status = state.status.write().await; status.device_connected = true; - status.device_type = DeviceType::Meshcore; + status.device_type = device.device_type(); 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(); + status.self_advert_name = device.advert_name(); } let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info)); @@ -434,7 +523,7 @@ pub(super) async fn run_mesh_session( /// Process a single outbound command from MeshService. async fn handle_send_command( cmd: MeshCommand, - device: &mut MeshcoreDevice, + device: &mut MeshRadioDevice, state: &Arc, consecutive_write_failures: &mut u32, ) { diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs new file mode 100644 index 00000000..9f594cf4 --- /dev/null +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -0,0 +1,577 @@ +//! Async serial driver for Meshtastic devices. +//! +//! Meshtastic uses protobuf payloads over a SLIP-like serial stream. This +//! module implements only the small subset Archipelago needs: connect, +//! discover the local node, send/receive text packets, and provide synthetic +//! contacts to the existing mesh listener. + +use super::protocol::{InboundFrame, ParsedContact}; +use super::types::{DeviceInfo, DeviceType}; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::time::Duration; +use tracing::{debug, info, warn}; + +const BAUD_RATE: u32 = 115200; +const READ_TIMEOUT: Duration = Duration::from_secs(5); +const WRITE_TIMEOUT: Duration = Duration::from_secs(2); +const READ_BUF_SIZE: usize = 512; + +const START1: u8 = 0x94; +const START2: u8 = 0xc3; +const TO_RADIO_MAX: usize = 512; +const BROADCAST_NUM: u32 = 0xffff_ffff; +const TEXT_MESSAGE_APP: u32 = 1; + +const TO_RADIO_PACKET: u64 = 1; +const TO_RADIO_WANT_CONFIG_ID: u64 = 3; +const TO_RADIO_HEARTBEAT: u64 = 7; + +const FROM_RADIO_PACKET: u64 = 2; +const FROM_RADIO_MY_INFO: u64 = 3; +const FROM_RADIO_NODE_INFO: u64 = 4; +const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7; +const FROM_RADIO_REBOOTED: u64 = 8; + +/// Async Meshtastic device handle. +pub struct MeshtasticDevice { + port: serial2_tokio::SerialPort, + read_buf: Vec, + node_num: Option, + user_id: Option, + long_name: Option, + short_name: Option, + contacts: HashMap, + device_path: String, +} + +impl MeshtasticDevice { + pub async fn open(path: &str) -> Result { + match tokio::fs::metadata(path).await { + Ok(meta) => debug!(path = %path, permissions = ?meta.permissions(), "Device node exists"), + Err(e) => anyhow::bail!("Serial device {} not accessible: {}", path, e), + } + + let port = serial2_tokio::SerialPort::open(path, BAUD_RATE).context(format!( + "Failed to open serial port {} (permission denied? device busy?)", + path + ))?; + info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port"); + + Ok(Self { + port, + read_buf: Vec::with_capacity(READ_BUF_SIZE), + node_num: None, + user_id: None, + long_name: None, + short_name: None, + contacts: HashMap::new(), + device_path: path.to_string(), + }) + } + + pub async fn initialize(&mut self) -> Result { + info!(path = %self.device_path, "Starting Meshtastic handshake"); + self.send_to_radio(&encode_want_config()).await?; + + let deadline = tokio::time::Instant::now() + READ_TIMEOUT; + let mut saw_meshtastic_frame = false; + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + match tokio::time::timeout(remaining.min(Duration::from_millis(250)), self.read_from_radio()).await { + Ok(Ok(Some(frame))) => { + saw_meshtastic_frame = true; + self.handle_from_radio(&frame); + if self.node_num.is_some() && self.user_id.is_some() { + break; + } + } + Ok(Ok(None)) | Err(_) => {} + Ok(Err(e)) => return Err(e), + } + } + + if !saw_meshtastic_frame { + anyhow::bail!("No Meshtastic serial API response"); + } + + let node_id = self.node_num.unwrap_or(0); + let firmware_version = self + .long_name + .clone() + .or_else(|| self.user_id.clone()) + .unwrap_or_else(|| "Meshtastic".to_string()); + + info!(node_id, name = %firmware_version, "Meshtastic identity"); + Ok(DeviceInfo { + firmware_version, + node_id, + max_contacts: 200, + device_type: DeviceType::Meshtastic, + }) + } + + pub async fn set_advert_name(&mut self, name: &str) -> Result<()> { + self.long_name = Some(name.to_string()); + Ok(()) + } + + pub async fn send_self_advert(&mut self) -> Result<()> { + self.send_to_radio(&encode_heartbeat()).await + } + + pub async fn send_channel_text(&mut self, _channel: u8, msg: &[u8]) -> Result<()> { + let text = String::from_utf8_lossy(msg); + let packet = encode_mesh_packet(BROADCAST_NUM, TEXT_MESSAGE_APP, text.as_bytes()); + self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet)) + .await + } + + pub async fn get_contacts(&mut self) -> Result> { + Ok(self.contacts.values().cloned().collect()) + } + + pub async fn reset_contact_path(&mut self, _pubkey: &[u8; 32]) -> Result<()> { + Ok(()) + } + + pub async fn sync_messages(&mut self) -> Result> { + Ok(Vec::new()) + } + + pub async fn try_recv_frame(&mut self) -> Result> { + let Some(frame) = self.read_from_radio().await? else { + return Ok(None); + }; + Ok(self.handle_from_radio(&frame)) + } + + pub fn advert_name(&self) -> Option { + self.long_name + .clone() + .or_else(|| self.short_name.clone()) + .or_else(|| self.user_id.clone()) + } + + async fn send_to_radio(&mut self, payload: &[u8]) -> Result<()> { + if payload.len() > TO_RADIO_MAX { + anyhow::bail!("Meshtastic payload too large: {} bytes", payload.len()); + } + let mut frame = Vec::with_capacity(4 + payload.len()); + frame.push(START1); + frame.push(START2); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + frame.extend_from_slice(payload); + tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(&frame)) + .await + .context("Meshtastic serial write timed out")? + .context("Meshtastic serial write failed")?; + Ok(()) + } + + async fn read_from_radio(&mut self) -> Result>> { + if let Some(frame) = decode_serial_frame(&mut self.read_buf) { + return Ok(Some(frame)); + } + + let mut tmp = [0u8; READ_BUF_SIZE]; + match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await { + Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"), + Ok(Ok(n)) => self.read_buf.extend_from_slice(&tmp[..n]), + Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"), + Err(_) => return Ok(None), + } + + Ok(decode_serial_frame(&mut self.read_buf)) + } + + fn handle_from_radio(&mut self, frame: &[u8]) -> Option { + let Some((field, value)) = decode_top_level_variant(frame) else { + return None; + }; + match field { + FROM_RADIO_MY_INFO => { + if let Some((node_num, user_id)) = parse_my_info(value) { + self.node_num = Some(node_num); + if let Some(user_id) = user_id { + self.user_id = Some(user_id); + } + } + None + } + FROM_RADIO_NODE_INFO => { + self.update_node_info(value); + None + } + FROM_RADIO_PACKET => self.packet_to_inbound_frame(value), + FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None, + other => { + debug!(field = other, len = value.len(), "Unhandled Meshtastic FromRadio field"); + None + } + } + } + + fn update_node_info(&mut self, data: &[u8]) { + if let Some(node) = parse_node_info(data) { + let key = synthetic_pubkey(node.num); + let name = node + .long_name + .or(node.short_name) + .or(node.id) + .unwrap_or_else(|| format!("Meshtastic !{:08x}", node.num)); + if Some(node.num) == self.node_num { + self.long_name = Some(name.clone()); + } + self.contacts.insert( + node.num, + ParsedContact { + public_key_hex: hex::encode(key), + advert_name: name, + last_advert: node.last_heard.unwrap_or_default(), + contact_type: 1, + path_len: 0xff, + flags: 0, + }, + ); + } + } + + fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option { + let packet = parse_mesh_packet(data)?; + if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() { + return None; + } + let from = packet.from.unwrap_or(0); + if Some(from) == self.node_num { + return None; + } + let from_key = synthetic_pubkey(from); + self.contacts.entry(from).or_insert_with(|| ParsedContact { + public_key_hex: hex::encode(synthetic_pubkey(from)), + advert_name: format!("Meshtastic !{:08x}", from), + last_advert: 0, + contact_type: 1, + path_len: 0xff, + flags: 0, + }); + + let mut payload = Vec::with_capacity(15 + packet.payload.len()); + payload.push(0); // SNR unknown + payload.extend_from_slice(&[0, 0]); // reserved + payload.extend_from_slice(&from_key[..6]); + payload.push(0xff); // unknown/flood path + payload.push(0); // text type + payload.extend_from_slice(&0u32.to_le_bytes()); + payload.extend_from_slice(&packet.payload); + Some(InboundFrame { + code: super::protocol::RESP_CONTACT_MSG_V3, + data: payload, + bytes_consumed: 0, + }) + } +} + +fn decode_serial_frame(buf: &mut Vec) -> Option> { + let start = buf.windows(2).position(|w| w == [START1, START2])?; + if start > 0 { + buf.drain(..start); + } + if buf.len() < 4 { + return None; + } + let len = u16::from_be_bytes([buf[2], buf[3]]) as usize; + if buf.len() < 4 + len { + return None; + } + let payload = buf[4..4 + len].to_vec(); + buf.drain(..4 + len); + Some(payload) +} + +fn encode_want_config() -> Vec { + encode_to_radio_variant(TO_RADIO_WANT_CONFIG_ID, &encode_varint_field(1, 1)) +} + +fn encode_heartbeat() -> Vec { + encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[]) +} + +fn encode_to_radio_variant(field: u64, bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + encode_len_field(field, bytes, &mut out); + out +} + +fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec { + let mut decoded = Vec::new(); + encode_varint_field_into(1, portnum as u64, &mut decoded); + encode_len_field(2, payload, &mut decoded); + + let mut packet = Vec::new(); + encode_fixed32_field(2, to, &mut packet); + encode_len_field(4, &decoded, &mut packet); + packet +} + +fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> { + let mut idx = 0; + while idx < buf.len() { + let (key, n) = read_varint(&buf[idx..])?; + idx += n; + let field = key >> 3; + match key & 0x07 { + 0 => { + let (_, n) = read_varint(&buf[idx..])?; + idx += n; + } + 2 => { + let (len, n) = read_varint(&buf[idx..])?; + idx += n; + let end = idx.checked_add(len as usize)?; + if end > buf.len() { + return None; + } + if matches!(field, FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO) { + return Some((field, &buf[idx..end])); + } + idx = end; + } + _ => return None, + } + } + None +} + +fn parse_my_info(data: &[u8]) -> Option<(u32, Option)> { + let mut idx = 0; + let mut node_num = None; + let mut user_id = None; + while idx < data.len() { + let (field, value, next) = next_field(data, idx)?; + idx = next; + match (field, value) { + (1, FieldValue::Varint(v)) => node_num = Some(v as u32), + (1, FieldValue::Fixed32(v)) => node_num = Some(v), + (3, FieldValue::Bytes(b)) => user_id = parse_user(b).and_then(|u| u.id), + _ => {} + } + } + node_num.map(|n| (n, user_id)) +} + +struct ParsedNode { + num: u32, + id: Option, + long_name: Option, + short_name: Option, + last_heard: Option, +} + +fn parse_node_info(data: &[u8]) -> Option { + let mut idx = 0; + let mut node = ParsedNode { + num: 0, + id: None, + long_name: None, + short_name: None, + last_heard: None, + }; + while idx < data.len() { + let (field, value, next) = next_field(data, idx)?; + idx = next; + match (field, value) { + (1, FieldValue::Varint(v)) => node.num = v as u32, + (1, FieldValue::Fixed32(v)) => node.num = v, + (2, FieldValue::Bytes(b)) => { + if let Some(user) = parse_user(b) { + node.id = user.id; + node.long_name = user.long_name; + node.short_name = user.short_name; + } + } + (5, FieldValue::Fixed32(v)) => node.last_heard = Some(v), + _ => {} + } + } + if node.num == 0 { + None + } else { + Some(node) + } +} + +struct ParsedUser { + id: Option, + long_name: Option, + short_name: Option, +} + +fn parse_user(data: &[u8]) -> Option { + let mut idx = 0; + let mut user = ParsedUser { + id: None, + long_name: None, + short_name: None, + }; + while idx < data.len() { + let (field, value, next) = next_field(data, idx)?; + idx = next; + match (field, value) { + (1, FieldValue::Bytes(b)) => user.id = string_field(b), + (2, FieldValue::Bytes(b)) => user.long_name = string_field(b), + (3, FieldValue::Bytes(b)) => user.short_name = string_field(b), + _ => {} + } + } + Some(user) +} + +struct ParsedPacket { + from: Option, + portnum: u32, + payload: Vec, +} + +fn parse_mesh_packet(data: &[u8]) -> Option { + let mut idx = 0; + let mut from = None; + let mut decoded = None; + while idx < data.len() { + let (field, value, next) = next_field(data, idx)?; + idx = next; + match (field, value) { + (1, FieldValue::Fixed32(v)) => from = Some(v), + (4, FieldValue::Bytes(b)) => decoded = Some(b), + _ => {} + } + } + let decoded = decoded?; + let mut didx = 0; + let mut portnum = 0; + let mut payload = Vec::new(); + while didx < decoded.len() { + let (field, value, next) = next_field(decoded, didx)?; + didx = next; + match (field, value) { + (1, FieldValue::Varint(v)) => portnum = v as u32, + (2, FieldValue::Bytes(b)) => payload = b.to_vec(), + _ => {} + } + } + Some(ParsedPacket { + from, + portnum, + payload, + }) +} + +enum FieldValue<'a> { + Varint(u64), + Fixed32(u32), + Bytes(&'a [u8]), +} + +fn next_field(buf: &[u8], idx: usize) -> Option<(u64, FieldValue<'_>, usize)> { + let (key, n) = read_varint(&buf[idx..])?; + let field = key >> 3; + let mut pos = idx + n; + match key & 0x07 { + 0 => { + let (v, n) = read_varint(&buf[pos..])?; + pos += n; + Some((field, FieldValue::Varint(v), pos)) + } + 2 => { + let (len, n) = read_varint(&buf[pos..])?; + pos += n; + let end = pos.checked_add(len as usize)?; + if end > buf.len() { + return None; + } + Some((field, FieldValue::Bytes(&buf[pos..end]), end)) + } + 5 => { + let end = pos.checked_add(4)?; + if end > buf.len() { + None + } else { + let value = u32::from_le_bytes([ + buf[pos], + buf[pos + 1], + buf[pos + 2], + buf[pos + 3], + ]); + Some((field, FieldValue::Fixed32(value), end)) + } + } + 1 => { + let end = pos.checked_add(8)?; + if end > buf.len() { + None + } else { + Some((field, FieldValue::Bytes(&buf[pos..end]), end)) + } + } + wire => { + warn!(wire, "Unsupported Meshtastic protobuf wire type"); + None + } + } +} + +fn read_varint(buf: &[u8]) -> Option<(u64, usize)> { + let mut out = 0u64; + for (i, b) in buf.iter().copied().enumerate().take(10) { + out |= ((b & 0x7f) as u64) << (7 * i); + if b & 0x80 == 0 { + return Some((out, i + 1)); + } + } + None +} + +fn encode_varint_field(field: u64, value: u64) -> Vec { + let mut out = Vec::new(); + encode_varint_field_into(field, value, &mut out); + out +} + +fn encode_varint_field_into(field: u64, value: u64, out: &mut Vec) { + write_varint((field << 3) | 0, out); + write_varint(value, out); +} + +fn encode_len_field(field: u64, bytes: &[u8], out: &mut Vec) { + write_varint((field << 3) | 2, out); + write_varint(bytes.len() as u64, out); + out.extend_from_slice(bytes); +} + +fn encode_fixed32_field(field: u64, value: u32, out: &mut Vec) { + write_varint((field << 3) | 5, out); + out.extend_from_slice(&value.to_le_bytes()); +} + +fn write_varint(mut value: u64, out: &mut Vec) { + while value >= 0x80 { + out.push((value as u8 & 0x7f) | 0x80); + value >>= 7; + } + out.push(value as u8); +} + +fn string_field(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes).ok().map(|s| s.to_string()) +} + +fn synthetic_pubkey(node_num: u32) -> [u8; 32] { + let mut out = [0u8; 32]; + out[..4].copy_from_slice(&node_num.to_le_bytes()); + out[4..15].copy_from_slice(b"meshtastic:"); + out +} diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 2f5a7845..c4a35d81 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -1,13 +1,15 @@ -//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery +//! Mesh networking: 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). +//! Supports Meshcore firmware via Companion USB and Meshtastic firmware via +//! the Meshtastic serial API on Heltec, T-Beam, RAK WisBlock, Station G2, +//! and other ESP32/nRF52-based LoRa boards. pub mod alerts; pub mod bitcoin_relay; pub mod crypto; pub mod listener; +pub mod meshtastic; pub mod message_types; pub mod outbox; pub mod protocol; diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index b392216c..6f288c5e 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -323,6 +323,7 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> { } /// Parsed contact from RESP_CONTACT (0x03). +#[derive(Clone)] pub struct ParsedContact { pub public_key_hex: String, pub advert_name: String, diff --git a/image-recipe/configs/99-mesh-radio.rules b/image-recipe/configs/99-mesh-radio.rules index abe66dac..5ab068f7 100644 --- a/image-recipe/configs/99-mesh-radio.rules +++ b/image-recipe/configs/99-mesh-radio.rules @@ -1,6 +1,8 @@ # Stable symlink for USB serial adapters used as mesh radios. # Creates /dev/mesh-radio pointing to the underlying ttyUSB device. -# Supports: CP2102 (Heltec V3), CH340 (T-Beam), FTDI (RAK WisBlock). +# Supports MeshCore and Meshtastic radios using CP2102 (Heltec V3), +# CH340 (T-Beam), FTDI (RAK WisBlock), and USB CDC ACM devices. SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout" SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout" SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout" +SUBSYSTEM=="tty", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"