feat(mesh): native-unicast DMs, contact import/remove, reachability, contact search

- 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>
This commit is contained in:
archipelago
2026-06-18 08:08:52 -04:00
co-authored by Claude Opus 4.8
parent 9f2edf6b7a
commit f0fdc23cc9
15 changed files with 578 additions and 95 deletions
@@ -48,6 +48,11 @@ pub(super) enum AssistReply {
/// direct message, so the answer lands inline in that same conversation
/// (encrypted, peer-addressed) rather than as a separate widget.
ChatText { contact_id: u32 },
/// Plain-text NATIVE direct message back to the asker's radio contact —
/// the bare `!ai` path for a stock meshcore client (e.g. a phone). The
/// answer goes as a real unicast DM (not a public-channel broadcast), so
/// only the asker sees it and a stock client can read it.
RadioDm { dest_prefix: [u8; 6] },
}
/// Entry point: gate the query, run the model, send the answer back via the
@@ -224,6 +229,15 @@ async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, an
let (text, _) = cap_reply(answer);
send_chat_text(state, *contact_id, &text).await;
}
AssistReply::RadioDm { dest_prefix } => {
let text = cap_channel(answer);
let _ = state
.send_cmd(MeshCommand::SendNativeText {
dest_pubkey_prefix: *dest_prefix,
payload: text.into_bytes(),
})
.await;
}
}
}
@@ -246,6 +260,14 @@ async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64,
AssistReply::ChatText { contact_id } => {
send_chat_text(state, *contact_id, &format!("AI: {msg}")).await;
}
AssistReply::RadioDm { dest_prefix } => {
let _ = state
.send_cmd(MeshCommand::SendNativeText {
dest_pubkey_prefix: *dest_prefix,
payload: format!("AI: {msg}").into_bytes(),
})
.await;
}
}
}
+26 -14
View File
@@ -353,28 +353,37 @@ pub(super) async fn store_plain_message(
state.status.write().await.messages_received += 1;
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` on the
// channel is answered by this node's local model when the assistant is on.
// Reply goes back as plain channel text so bare (non-archipelago) clients
// see it. The trust/rate gate lives in run_assist.
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` is answered
// by this node's local model when the assistant is on. The trust/rate gate
// lives in run_assist. The reply goes back as a private NATIVE DM to the
// asker whenever we know its radio pubkey (so it does NOT land on the public
// channel and a stock meshcore client can read it); we only fall back to a
// channel reply if the sender has no resolvable pubkey (rare).
if state.assistant.read().await.enabled {
if let Some(prompt) = strip_ai_trigger(text) {
if !prompt.is_empty() {
let reply = {
let peers = state.peers.read().await;
peers
.get(&contact_id)
.and_then(|p| p.pubkey_hex.clone())
.filter(|h| h.len() >= 12)
.and_then(|h| hex::decode(&h[..12]).ok())
.filter(|b| b.len() == 6)
.map(|b| {
let mut pre = [0u8; 6];
pre.copy_from_slice(&b);
super::assist::AssistReply::RadioDm { dest_prefix: pre }
})
.unwrap_or(super::assist::AssistReply::ChannelText { channel: 0 })
};
let req_id = state.next_id().await;
let prompt = prompt.to_string();
let name = peer_name.to_string();
let st = Arc::clone(state);
tokio::spawn(async move {
super::assist::run_assist(
prompt,
None,
req_id,
contact_id,
name,
super::assist::AssistReply::ChannelText { channel: 0 },
st,
)
.await;
super::assist::run_assist(prompt, None, req_id, contact_id, name, reply, st)
.await;
});
}
}
@@ -480,6 +489,9 @@ pub(super) async fn handle_identity_received(
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
last_advert: 0,
// We just heard this peer's identity advert, so it's reachable.
reachable: true,
};
let is_new = {
@@ -22,8 +22,33 @@ pub(super) async fn handle_frame(
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
info!(
code = frame.code,
data_len = frame.data.len(),
"Contact discovery event — refreshing contacts"
);
// Auto-import: a PUSH_CONTACT_ADVERT (0x80) carries the 32-byte
// pubkey of a node we just heard. If it isn't already a contact,
// add it to the firmware table so it shows up immediately — no
// flood-advert dance required. (PUSH_NEW_CONTACT/0x8A is already
// added by the firmware, so we skip it.)
if frame.code == protocol::PUSH_CONTACT_ADVERT && frame.data.len() >= 32 {
let mut pubkey = [0u8; 32];
pubkey.copy_from_slice(&frame.data[..32]);
let pk_hex = hex::encode(pubkey);
let known = state
.peers
.read()
.await
.values()
.any(|p| p.pubkey_hex.as_deref() == Some(pk_hex.as_str()));
if !known {
let _ = state
.send_cmd(super::MeshCommand::AddContact {
pubkey,
name: String::new(),
})
.await;
}
}
return true; // Signal caller to fetch contacts
}
+18
View File
@@ -63,6 +63,14 @@ pub enum MeshCommand {
dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>,
},
/// Send PLAIN text as one or more native meshcore DMs to a stock client
/// (e.g. a phone). Long text is split into multiple readable plain messages
/// — never MC-chunked — because stock clients can't reassemble archy's
/// chunk framing. Used for chat/AI replies to non-archipelago contacts.
SendNativeText {
dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>,
},
/// Broadcast pre-encoded binary on a mesh channel.
BroadcastChannel {
channel: u8,
@@ -71,6 +79,16 @@ pub enum MeshCommand {
SendAdvert,
/// Re-fetch contact list from the radio device.
RefreshContacts,
/// Delete a contact from the firmware table (clear-all / unreachable wipe).
RemoveContact {
pubkey: [u8; 32],
},
/// Import/add a heard advert as a firmware contact so it shows up without
/// needing a flood advert. Name may be empty (firmware fills from advert).
AddContact {
pubkey: [u8; 32],
name: String,
},
}
/// Shared state for the mesh listener, accessible from RPC handlers.
+154 -25
View File
@@ -53,6 +53,43 @@ impl MeshRadioDevice {
}
}
async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
match self {
Self::Meshcore(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
Self::Meshtastic(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
}
}
async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
match self {
Self::Meshcore(device) => device.remove_contact(pubkey).await,
Self::Meshtastic(device) => device.remove_contact(pubkey).await,
}
}
async fn add_contact(
&mut self,
pubkey: &[u8; 32],
contact_type: u8,
flags: u8,
out_path_len: u8,
name: &str,
last_advert: u32,
) -> Result<()> {
match self {
Self::Meshcore(device) => {
device
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
.await
}
Self::Meshtastic(device) => {
device
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
.await
}
}
}
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
match self {
Self::Meshcore(device) => device.get_contacts().await,
@@ -151,6 +188,7 @@ pub(super) const DM_V1_MARKER: &str = "@DM:";
/// route inbound DMs to the correct contact_id thread.
pub(super) const DM_V2_MARKER: &str = "@DM2:";
#[allow(dead_code)] // legacy @DM2-over-channel wrapper; kept for reference now that DMs are native unicast
fn wrap_dm_for_channel(
dest_pubkey_prefix: &[u8; 6],
sender_arch_prefix: &[u8; 6],
@@ -169,6 +207,7 @@ fn wrap_dm_for_channel(
/// `[0u8; 6]` if the stored hex is malformed (which would only happen if a
/// caller constructed `MeshState` with a bad value — empty string yields
/// all-zero, which won't match any real peer on the receiver side).
#[allow(dead_code)] // was used by the @DM2 wrapper; native unicast doesn't need it
fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
let mut out = [0u8; 6];
if state.our_ed_pubkey_hex.len() >= 12 {
@@ -195,39 +234,42 @@ async fn send_dm_via_channel(
consecutive_write_failures: &mut u32,
) {
use base64::Engine;
let sender_prefix = our_sender_prefix(state);
// First try a single frame with the raw payload directly wrapped.
// This keeps small plain-text messages at minimal overhead.
let single = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, payload);
if single.len() <= 140 {
match device.send_channel_text(0, single.as_bytes()).await {
let _ = state; // native unicast carries no separate sender prefix
// NATIVE meshcore unicast (CMD_SEND_TXT_MSG): a real direct message to the
// contact, NOT a broadcast on the shared public channel. This is the fix
// for the long-standing public-channel pollution — archy used to tunnel
// every DM/relay/receipt as an `@DM2:` blob on channel 0, which (a) every
// mesh participant saw as spam and (b) stock meshcore clients (e.g. a
// phone) couldn't decode. A native DM is private and decodes everywhere.
// The receive side handles these via the existing RESP_CONTACT_MSG path.
//
// Small payloads send in one frame; larger ones are base64 + MC-chunked
// and reassembled by the receiver (try_chunk_reassemble).
if payload.len() <= 140 {
match device.send_text_msg(dest_pubkey_prefix, payload).await {
Ok(()) => {
*consecutive_write_failures = 0;
info!(
dest = %hex::encode(dest_pubkey_prefix),
len = payload.len(),
wire_len = single.len(),
"Sent mesh message (DM via channel)"
"Sent mesh DM (native unicast)"
);
}
Err(e) => {
*consecutive_write_failures += 1;
warn!(
failures = *consecutive_write_failures,
"Failed to send DM via channel: {}", e
"Failed to send native DM: {}", e
);
}
}
return;
}
// Payload too large for one wrap — base64 then MC-chunk. Receiver
// reassembles base64 chunks and routes the decoded bytes back through
// the typed-envelope ladder in handle_channel_payload.
let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let chunk_data_size = 80;
let chunk_data_size = 100;
let chunks: Vec<&str> = encoded
.as_bytes()
.chunks(chunk_data_size)
@@ -239,18 +281,20 @@ async fn send_dm_via_channel(
raw_len = payload.len(),
b64_len = encoded.len(),
chunks = total,
"Sending chunked mesh message (DM via channel)"
"Sending chunked mesh DM (native unicast)"
);
let mut any_err = false;
for (idx, chunk) in chunks.iter().enumerate() {
let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk);
let wrapped = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, frame.as_bytes());
if let Err(e) = device.send_channel_text(0, wrapped.as_bytes()).await {
if let Err(e) = device
.send_text_msg(dest_pubkey_prefix, frame.as_bytes())
.await
{
*consecutive_write_failures += 1;
warn!(
failures = *consecutive_write_failures,
chunk = idx,
"Chunk DM-via-channel send failed: {}",
"Chunk native DM send failed: {}",
e
);
any_err = true;
@@ -263,20 +307,72 @@ async fn send_dm_via_channel(
}
}
/// Send PLAIN text to a stock meshcore client as one or more native DMs.
/// Unlike `send_dm_via_channel`, this never uses MC-chunk framing (stock
/// clients can't reassemble it) — if the text exceeds one LoRa frame it is
/// split into multiple readable plain messages on UTF-8 char boundaries.
async fn send_plain_native_text(
device: &mut MeshRadioDevice,
dest_pubkey_prefix: &[u8; 6],
text: &[u8],
consecutive_write_failures: &mut u32,
) {
// Split on char boundaries so we never break a multi-byte UTF-8 sequence.
const FRAME: usize = 150; // under MAX_MESSAGE_LEN (160), leaves header room
let s = String::from_utf8_lossy(text);
let mut parts: Vec<String> = Vec::new();
let mut cur = String::new();
for ch in s.chars() {
if cur.len() + ch.len_utf8() > FRAME {
parts.push(std::mem::take(&mut cur));
}
cur.push(ch);
}
if !cur.is_empty() || parts.is_empty() {
parts.push(cur);
}
let total = parts.len();
for (idx, part) in parts.iter().enumerate() {
match device
.send_text_msg(dest_pubkey_prefix, part.as_bytes())
.await
{
Ok(()) => {
*consecutive_write_failures = 0;
info!(
dest = %hex::encode(dest_pubkey_prefix),
part = idx + 1,
total,
"Sent plain native DM"
);
}
Err(e) => {
*consecutive_write_failures += 1;
warn!(
failures = *consecutive_write_failures,
"Plain native DM send failed: {}", e
);
break;
}
}
if total > 1 {
tokio::time::sleep(Duration::from_millis(400)).await;
}
}
}
/// Fetch the contacts list from the device and update the peer cache.
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
match device.get_contacts().await {
Ok(contacts) => {
// Skip firmware contacts the user has explicitly wiped via
// mesh.clear-all. MeshCore keeps its own persistent contact
// table the app can't remove from, so we filter on read to
// keep cleared entries out of the chat list.
let blocklist = state.radio_contact_blocklist.read().await.clone();
// Contact blocking is intentionally NOT applied here. A read-time
// blocklist meant a wiped/re-paired contact could never come back
// even when it re-advertised (it broke phone re-pairing after a
// clear). Per-contact blocking will return later as an explicit,
// user-controlled feature; until then every firmware contact is
// surfaced. `radio_contact_blocklist` is retained but unused.
let mut peers = state.peers.write().await;
for (idx, contact) in contacts.iter().enumerate() {
if blocklist.contains(&contact.public_key_hex) {
continue;
}
let contact_id = idx as u32;
let existing = peers.get(&contact_id);
let peer = super::super::types::MeshPeer {
@@ -289,6 +385,10 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
last_advert: contact.last_advert,
// A non-zero path_len means the firmware has a route (direct
// or flood) to this contact — i.e. we can deliver to it.
reachable: contact.path_len != 0,
};
peers.insert(contact_id, peer);
}
@@ -555,6 +655,18 @@ async fn handle_send_command(
)
.await;
}
MeshCommand::SendNativeText {
dest_pubkey_prefix,
payload,
} => {
send_plain_native_text(
device,
&dest_pubkey_prefix,
&payload,
consecutive_write_failures,
)
.await;
}
MeshCommand::SendRaw {
dest_pubkey_prefix,
payload,
@@ -608,5 +720,22 @@ async fn handle_send_command(
MeshCommand::RefreshContacts => {
refresh_contacts(device, state).await;
}
MeshCommand::RemoveContact { pubkey } => {
if let Err(e) = device.remove_contact(&pubkey).await {
warn!(pubkey = %hex::encode(pubkey), "remove_contact failed: {}", e);
} else {
info!(pubkey = %hex::encode(&pubkey[..6]), "Removed firmware contact");
}
}
MeshCommand::AddContact { pubkey, name } => {
// type=1 (chat/user), flags=0, out_path_len=0 (firmware will flood
// until a path is learned). last_advert=0 lets the firmware keep its
// own advert timestamp.
if let Err(e) = device.add_contact(&pubkey, 1, 0, 0, &name, 0).await {
warn!(pubkey = %hex::encode(&pubkey[..6]), "add_contact failed: {}", e);
} else {
info!(pubkey = %hex::encode(&pubkey[..6]), "Imported advert as contact");
}
}
}
}
+26
View File
@@ -150,6 +150,32 @@ impl MeshtasticDevice {
.await
}
/// Meshtastic addresses by numeric node-id, not a meshcore pubkey prefix,
/// so there's no direct unicast mapping here. Best-effort fallback to a
/// channel send keeps the device interface uniform; native unicast is only
/// meaningful on the Meshcore transport.
pub async fn send_text_msg(&mut self, _dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
self.send_channel_text(0, msg).await
}
/// Meshtastic has no meshcore-style contact table; these are no-ops so the
/// device interface stays uniform.
pub async fn remove_contact(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
Ok(())
}
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<()> {
Ok(())
}
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
if self.contacts.is_empty() {
self.send_to_radio(&encode_want_config()).await?;
+58 -23
View File
@@ -82,6 +82,9 @@ pub(crate) async fn upsert_federation_peer(
snr: existing.as_ref().and_then(|p| p.snr),
last_heard: chrono::Utc::now().to_rfc3339(),
hops: existing.as_ref().map(|p| p.hops).unwrap_or(0),
last_advert: existing.as_ref().map(|p| p.last_advert).unwrap_or(0),
// Federation peers are reachable off-radio (Tor/FIPS), so always true.
reachable: true,
};
peers.insert(contact_id, peer);
drop(peers);
@@ -584,6 +587,7 @@ impl MeshService {
let mut interval = tokio::time::interval(Duration::from_secs(30));
interval.tick().await; // skip first
let mut last_announced_height: u64 = 0;
let mut last_announce_at: Option<std::time::Instant> = None;
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
@@ -601,6 +605,18 @@ impl MeshService {
// Poll Bitcoin Core for latest block
match bitcoin_rpc_getblockcount(&client).await {
Ok(height) if height > last_announced_height => {
// Advance the tip baseline immediately so a fast Bitcoin
// catch-up (a new block every poll) doesn't re-fire each tick.
last_announced_height = height;
// Throttle: at most one announcement per ~9 min. Real ~10 min
// blocks still propagate, but a rapid catch-up can no longer
// flood the shared LoRa channel.
if last_announce_at
.map(|t| t.elapsed() < Duration::from_secs(540))
.unwrap_or(false)
{
continue;
}
if let Ok(header) = bitcoin_rpc_getblockheader_by_height(&client, height).await {
// Store in cache
let payload = message_types::BlockHeaderPayload {
@@ -646,30 +662,15 @@ impl MeshService {
}
}
}
// Second pass: any peer if no Archy nodes found
if sent == 0 {
for peer in peers.values() {
if sent >= max_peers { break; }
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let _ = bha_state.send_cmd(
listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire.clone(),
},
).await;
sent += 1;
}
}
}
}
}
// NOTE: intentionally NO fallback to arbitrary
// peers. Block headers go ONLY to known Archy
// (federated) nodes — never to random meshcore
// devices on the shared public channel.
drop(peers);
last_announced_height = height;
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
if sent > 0 {
last_announce_at = Some(std::time::Instant::now());
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
}
}
Err(e) => warn!("Failed to build block announcement: {}", e),
}
@@ -1273,6 +1274,24 @@ impl MeshService {
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
use crate::mesh::message_types::{MeshMessageType, TypedEnvelope};
let seq = self.state.next_send_seq(contact_id).await;
// Stock (non-archipelago) radio contacts — e.g. a phone running the
// MeshCore app — can't decode our typed envelope and would render it as
// garbled bytes. Send them the raw text as a plain native DM instead.
// Archipelago peers still get the typed envelope (seq/reply/reaction
// addressing + encryption).
if !self.is_archy_peer(contact_id).await {
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
self.state
.send_cmd(listener::MeshCommand::SendNativeText {
dest_pubkey_prefix: dest_prefix,
payload: text.as_bytes().to_vec(),
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
return Ok(self
.record_sent_typed(contact_id, "text", text, None, seq)
.await);
}
let envelope =
TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec()).with_seq(seq);
let wire = envelope.to_wire()?;
@@ -1280,6 +1299,22 @@ impl MeshService {
.await
}
/// Whether `contact_id` is an archipelago peer (vs a stock meshcore client).
/// Federation-synthetic ids are always archy; radio contacts count as archy
/// only once we've learned their archipelago identity (DID or x25519 key,
/// from federation seeding or an identity exchange). Stock clients have
/// neither, so we send them plain text rather than typed envelopes.
async fn is_archy_peer(&self, contact_id: u32) -> bool {
if contact_id & 0x8000_0000 != 0 {
return true;
}
let peers = self.state.peers.read().await;
peers
.get(&contact_id)
.map(|p| p.did.is_some() || p.x25519_pubkey.is_some())
.unwrap_or(false)
}
/// Record a Sent MeshMessage for a typed envelope that has already been
/// transmitted by the caller. Used by the RPC layer after sending
/// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card
+46
View File
@@ -30,6 +30,13 @@ pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
/// known" — without this, the firmware silently drops outbound TXT_MSG
/// frames to such contacts.
pub const CMD_RESET_PATH: u8 = 0x0D;
/// CMD_ADD_UPDATE_CONTACT (0x09): add or update a contact in the firmware
/// table. 144-byte frame (see `build_add_contact`).
pub const CMD_ADD_UPDATE_CONTACT: u8 = 0x09;
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]` — delete a contact from the
/// firmware's persistent table (used by clear-all so wiped contacts actually
/// go away and only return when they re-advertise).
pub const CMD_REMOVE_CONTACT: u8 = 0x0F;
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;
@@ -258,6 +265,45 @@ pub fn build_reset_path(pubkey: &[u8; 32]) -> Vec<u8> {
encode_frame(&data)
}
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]`. Removes the contact from
/// the firmware's persistent contact table.
pub fn build_remove_contact(pubkey: &[u8; 32]) -> Vec<u8> {
let mut data = vec![CMD_REMOVE_CONTACT];
data.extend_from_slice(pubkey);
encode_frame(&data)
}
/// CMD_ADD_UPDATE_CONTACT (0x09): add/update a contact. 144-byte body:
/// `[0x09][pub_key:32][type:1][flags:1][out_path_len:1][out_path:64][name:32]
/// [last_advert:4 LE][adv_lat:4 LE][adv_lon:4 LE]`.
/// `name` is zero-padded to 32 bytes (the firmware fills it from the heard
/// advert on its side too, so an empty name still resolves on get-contacts).
pub fn build_add_contact(
pubkey: &[u8; 32],
contact_type: u8,
flags: u8,
out_path_len: u8,
name: &str,
last_advert: u32,
) -> Vec<u8> {
let mut data = Vec::with_capacity(144);
data.push(CMD_ADD_UPDATE_CONTACT);
data.extend_from_slice(pubkey); // 32
data.push(contact_type); // 1
data.push(flags); // 1
data.push(out_path_len); // 1
data.extend_from_slice(&[0u8; 64]); // out_path (64)
let mut name_buf = [0u8; 32];
let nb = name.as_bytes();
let n = nb.len().min(32);
name_buf[..n].copy_from_slice(&nb[..n]);
data.extend_from_slice(&name_buf); // name (32)
data.extend_from_slice(&last_advert.to_le_bytes()); // last_advert (4)
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lat (4)
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lon (4)
encode_frame(&data)
}
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
pub fn build_sync_next_message() -> Vec<u8> {
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
+59
View File
@@ -206,6 +206,24 @@ impl MeshcoreDevice {
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<()> {
@@ -217,6 +235,47 @@ impl MeshcoreDevice {
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>> {
+9
View File
@@ -45,6 +45,15 @@ pub struct MeshPeer {
pub last_heard: String,
/// Number of hops to reach this peer.
pub hops: u8,
/// Firmware advert timestamp (unix secs) of the contact's last advert, or
/// 0 if unknown. Used to gauge reachability/recency in the UI.
#[serde(default)]
pub last_advert: u32,
/// Best-effort "currently reachable" flag: the radio has a route to this
/// contact (or it's a federation/identity peer reachable off-radio). A
/// contact with no path and no recent advert is shown as unreachable.
#[serde(default)]
pub reachable: bool,
}
/// Direction of a mesh message.