- DMs now use native meshcore unicast (CMD_SEND_TXT_MSG) instead of @DM2 channel broadcasts: private (E2E-encrypted to the recipient pubkey by firmware), off the public channel, and decodable by stock clients. Plain text (split, not MC-chunked) to non-archipelago contacts; typed envelopes to archy peers. - !ai replies now DM the asker privately (RadioDm) instead of broadcasting on ch0. - Auto contact-import: a heard advert (PUSH_CONTACT_ADVERT/0x80, 32-byte pubkey) is added via CMD_ADD_UPDATE_CONTACT (0x09) so contacts appear without a flood advert. - clear-all now DELETES firmware contacts via CMD_REMOVE_CONTACT (0x0F) instead of blocklisting; blocking filter removed entirely. Wiped contacts return when reachable. - Contact reachability: MeshPeer carries last_advert + reachable (path-based); UI shows a reachability dot. - Peers list: contact search box (filter by name/DID/npub/pubkey) with a clear button. - send_message routes stock contacts as plain native text (fixes garbled envelopes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
532 lines
20 KiB
Rust
532 lines
20 KiB
Rust
// WIP mesh/transport protocol — suppress dead code warnings
|
||
#![allow(dead_code)]
|
||
//! 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::path::Path;
|
||
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> {
|
||
// Check device exists before trying to open (better error message)
|
||
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: {} (check PrivateDevices in systemd, or USB connection)",
|
||
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 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).
|
||
/// Returns whether the firmware routed it via flood (true) or direct (false).
|
||
/// The response frame is `RESP_CODE_SENT | mode | tag[4] | est_timeout[4]`
|
||
/// where mode == 1 means flood and mode == 0 means direct.
|
||
pub async fn send_text(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<bool> {
|
||
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));
|
||
}
|
||
// RESP_CODE_SENT layout: [mode(1)][tag(4)][est_timeout(4)]
|
||
let sent_via_flood = frame.data.first().copied().unwrap_or(0) == 1;
|
||
tracing::info!(
|
||
dest = %hex::encode(dest_pubkey_prefix),
|
||
mode = if sent_via_flood { "flood" } else { "direct" },
|
||
resp_code = frame.code,
|
||
data_len = frame.data.len(),
|
||
"[diag] send_text response"
|
||
);
|
||
Ok(sent_via_flood)
|
||
}
|
||
|
||
/// 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(())
|
||
}
|
||
|
||
/// Send a NATIVE meshcore direct message (CMD_SEND_TXT_MSG) to a contact,
|
||
/// addressed by the first 6 bytes of its public key. Unlike the
|
||
/// `@DM2`-over-channel path, this is a real unicast — it does not appear on
|
||
/// the public channel, and a stock meshcore client receives it as a normal
|
||
/// DM. The contact must already exist in the firmware table (with a path).
|
||
pub async fn send_text_msg(&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!(
|
||
"Direct text send failed: {}",
|
||
protocol::parse_error(&frame.data)
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Clear the stored routing path for a contact so the firmware flood-
|
||
/// routes future messages instead of dropping them when path_len=0.
|
||
pub async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||
self.send_raw(&protocol::build_reset_path(pubkey)).await?;
|
||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||
if frame.code == protocol::RESP_ERR {
|
||
anyhow::bail!("Reset path failed: {}", protocol::parse_error(&frame.data));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Delete a contact from the firmware's persistent contact table.
|
||
pub async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||
self.send_raw(&protocol::build_remove_contact(pubkey))
|
||
.await?;
|
||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||
if frame.code == protocol::RESP_ERR {
|
||
anyhow::bail!(
|
||
"Remove contact failed: {}",
|
||
protocol::parse_error(&frame.data)
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Add/update a contact in the firmware table (CMD_ADD_UPDATE_CONTACT).
|
||
/// Used to import a heard advert so it shows up as a contact immediately.
|
||
pub async fn add_contact(
|
||
&mut self,
|
||
pubkey: &[u8; 32],
|
||
contact_type: u8,
|
||
flags: u8,
|
||
out_path_len: u8,
|
||
name: &str,
|
||
last_advert: u32,
|
||
) -> Result<()> {
|
||
self.send_raw(&protocol::build_add_contact(
|
||
pubkey,
|
||
contact_type,
|
||
flags,
|
||
out_path_len,
|
||
name,
|
||
last_advert,
|
||
))
|
||
.await?;
|
||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||
if frame.code == protocol::RESP_ERR {
|
||
anyhow::bail!("Add contact 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.
|
||
/// /dev/mesh-radio is a stable udev symlink (see 99-mesh-radio.rules).
|
||
const SERIAL_CANDIDATES: &[&str] = &[
|
||
"/dev/mesh-radio",
|
||
"/dev/ttyUSB0",
|
||
"/dev/ttyUSB1",
|
||
"/dev/ttyUSB2",
|
||
"/dev/ttyACM0",
|
||
"/dev/ttyACM1",
|
||
"/dev/ttyACM2",
|
||
];
|
||
|
||
const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"];
|
||
|
||
fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||
let Some(name) = Path::new(path).file_name().and_then(|s| s.to_str()) else {
|
||
return false;
|
||
};
|
||
let by_id = Path::new("/dev/serial/by-id");
|
||
let Ok(entries) = std::fs::read_dir(by_id) else {
|
||
return false;
|
||
};
|
||
for entry in entries.flatten() {
|
||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||
if !SKIP_SERIAL_MODEL_SUBSTRINGS
|
||
.iter()
|
||
.any(|needle| file_name.contains(needle))
|
||
{
|
||
continue;
|
||
}
|
||
if let Ok(target) = std::fs::read_link(entry.path()) {
|
||
if target.file_name().and_then(|s| s.to_str()) == Some(name) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// 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() {
|
||
if likely_non_mesh_serial_device(path) {
|
||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||
continue;
|
||
}
|
||
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
|
||
}
|