Merge commit '12e7990b10ad7c739d1078f731e10e8a5189560a' into archy-openwrt

This commit is contained in:
2026-07-01 15:11:26 +00:00
6 changed files with 209 additions and 19 deletions
+52 -7
View File
@@ -138,8 +138,14 @@ pub(super) async fn handle_frame(
match protocol::parse_channel_msg_v3_raw(&frame.data) {
Ok((channel_idx, payload)) => {
if !payload.is_empty() {
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
.await;
handle_channel_payload(
state,
channel_idx,
&payload,
our_x25519_secret,
None,
)
.await;
}
}
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
@@ -151,14 +157,44 @@ pub(super) async fn handle_frame(
match protocol::parse_channel_msg_v1_raw(&frame.data) {
Ok((channel_idx, payload)) => {
if !payload.is_empty() {
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
.await;
handle_channel_payload(
state,
channel_idx,
&payload,
our_x25519_secret,
None,
)
.await;
}
}
Err(e) => warn!("Failed to parse channel message: {}", e),
}
}
// Synthetic Meshtastic channel broadcast that carries its sender:
// `[channel_idx: u8][sender_pubkey_prefix: 6 bytes][text…]`. Resolve the
// sender to a friendly name, then file the message under the channel
// thread attributed to them — this is what makes the default public
// LongFast channel actually show inbound traffic (and who sent it).
protocol::RESP_MESHTASTIC_CHANNEL_TEXT => {
if frame.data.len() > 7 {
let channel_idx = frame.data[0];
let sender_prefix_hex = hex::encode(&frame.data[1..7]);
let payload = frame.data[7..].to_vec();
if !payload.is_empty() {
let (_cid, name) = resolve_peer(state, &sender_prefix_hex).await;
handle_channel_payload(
state,
channel_idx,
&payload,
our_x25519_secret,
Some(name),
)
.await;
}
}
}
protocol::PUSH_LOG_DATA | protocol::PUSH_PATH_UPDATE | protocol::PUSH_RAW_DATA => {
// Internal device logging/path data — safe to ignore
}
@@ -182,6 +218,12 @@ async fn handle_channel_payload(
channel_idx: u8,
payload: &[u8],
our_x25519_secret: &[u8; 32],
// When the transport knows who sent this channel broadcast (Meshtastic
// packets carry the originating node), the plain-text/typed message is filed
// under the channel thread but attributed to this sender name. Meshcore
// channel frames carry no sender, so they pass `None` and fall back to a
// generic "Channel N" label.
sender_name: Option<String>,
) {
// DM-via-channel wrapper (text form): the channel text carries an
// ASCII "@DM:<base64>" token somewhere in the body. We locate the
@@ -390,15 +432,18 @@ async fn handle_channel_payload(
}
}
// Regular channel broadcast (not DM-wrapped)
// Regular channel broadcast (not DM-wrapped). File it under the channel
// thread (contact_id = u32::MAX - idx) but label it with the real sender
// when the transport gave us one (Meshtastic), so the channel view shows who
// said what. Meshcore frames have no sender → generic "Channel N".
let chan_contact_id = u32::MAX - (channel_idx as u32);
let chan_name = format!("Channel {}", channel_idx);
let chan_name = sender_name.unwrap_or_else(|| format!("Channel {}", channel_idx));
if TypedEnvelope::is_typed(payload) {
handle_typed_message(payload, chan_contact_id, &chan_name, state).await;
} else {
let text = String::from_utf8_lossy(payload).to_string();
store_plain_message(state, chan_contact_id, &chan_name, &text).await;
info!(channel = channel_idx, "Received mesh channel message");
info!(channel = channel_idx, sender = %chan_name, "Received mesh channel message");
}
}
+76 -9
View File
@@ -610,9 +610,17 @@ impl MeshtasticDevice {
Ok(())
}
pub async fn send_channel_text(&mut self, _channel: u8, msg: &[u8]) -> Result<()> {
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());
let mut packet = encode_mesh_packet(BROADCAST_NUM, TEXT_MESSAGE_APP, text.as_bytes());
// MeshPacket.channel (field 3, varint) selects which channel slot the
// firmware encrypts and transmits on. 0 = primary (the default public
// LongFast channel); a non-zero slot is our secondary archipelago
// channel. Appended after encode_mesh_packet's fields — protobuf fields
// are order-independent.
if channel != 0 {
encode_varint_field_into(3, channel as u64, &mut packet);
}
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
.await
}
@@ -1002,6 +1010,24 @@ fn packet_to_inbound_frame(
flags: 0,
});
// Channel broadcast (e.g. the default public LongFast channel, or any other
// channel slot): `to == BROADCAST_NUM`. File it under the channel thread —
// NOT a 1:1 DM with the sender — so it shows in the public/channel view,
// while still carrying the sender prefix so the listener can attribute each
// message to who sent it. Without this, every public-channel message was
// scattered into per-sender DM threads and the public channel looked dead.
if packet.to == Some(BROADCAST_NUM) {
let mut data = Vec::with_capacity(7 + packet.payload.len());
data.push(packet.channel); // channel index (0 = primary/public)
data.extend_from_slice(&from_key[..6]); // sender pubkey prefix
data.extend_from_slice(&packet.payload);
return Some(InboundFrame {
code: super::protocol::RESP_MESHTASTIC_CHANNEL_TEXT,
data,
bytes_consumed: 0,
});
}
let mut payload = Vec::with_capacity(15 + packet.payload.len());
payload.push(0); // SNR unknown
payload.extend_from_slice(&[0, 0]); // reserved
@@ -1386,6 +1412,13 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
struct ParsedPacket {
from: Option<u32>,
/// MeshPacket.to (field 2): the destination node, or `BROADCAST_NUM` for a
/// channel broadcast. Distinguishes a directed DM from a public/channel
/// message so each lands in the right thread.
to: Option<u32>,
/// MeshPacket.channel (field 3): the channel index a broadcast arrived on
/// (0 = primary / default public LongFast).
channel: u8,
portnum: u32,
payload: Vec<u8>,
#[allow(dead_code)]
@@ -1402,6 +1435,8 @@ struct ParsedPacket {
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
let mut idx = 0;
let mut from = None;
let mut to = None;
let mut channel = 0u8;
let mut decoded = None;
let mut id = None;
let mut rx_time = None;
@@ -1412,6 +1447,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
idx = next;
match (field, value) {
(1, FieldValue::Fixed32(v)) => from = Some(v),
(2, FieldValue::Fixed32(v)) => to = Some(v),
(3, FieldValue::Varint(v)) => channel = v as u8,
(4, FieldValue::Bytes(b)) => decoded = Some(b),
(6, FieldValue::Fixed32(v)) => id = Some(v),
(7, FieldValue::Fixed32(v)) if v != 0 => rx_time = Some(v),
@@ -1435,6 +1472,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
}
Some(ParsedPacket {
from,
to,
channel,
portnum,
payload,
id,
@@ -1645,13 +1684,41 @@ mod tests {
let frame =
packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys)
.expect("live packet with unset radio clock must not be dropped");
assert_eq!(frame.code, protocol::RESP_CONTACT_MSG_V3);
// A `to == BROADCAST_NUM` text is a channel broadcast (3ccc on public
// LongFast), so it routes to the channel thread, carrying its sender.
assert_eq!(frame.code, protocol::RESP_MESHTASTIC_CHANNEL_TEXT);
assert_eq!(frame.data[0], 0, "no channel field set => primary/public (0)");
assert_eq!(&frame.data[1..7], &[0xcc, 0x3c, 0x00, 0x00, 0x6d, 0x65]);
assert_eq!(&frame.data[7..], b"hello from 3ccc");
assert!(contacts.contains_key(&from));
}
#[test]
fn packet_to_inbound_frame_directed_dm_stays_a_contact_message() {
// A text addressed directly to us (to == our node, not broadcast) must
// remain a 1:1 DM, NOT get rerouted to a channel thread.
let from = 0x0000_3ccc;
let me = 0x1111_1111;
let mut contacts = HashMap::new();
let mut peer_pubkeys = HashMap::new();
let mut decoded = Vec::new();
encode_varint_field_into(1, TEXT_MESSAGE_APP as u64, &mut decoded);
encode_len_field(2, b"direct hello", &mut decoded);
let mut packet = Vec::new();
encode_fixed32_field(1, from, &mut packet);
encode_fixed32_field(2, me, &mut packet); // to == us, directed
encode_len_field(4, &decoded, &mut packet);
encode_fixed32_field(7, 12_345, &mut packet);
let frame =
packet_to_inbound_frame(&packet, Some(me), &mut contacts, &mut peer_pubkeys)
.expect("directed DM must surface");
assert_eq!(frame.code, protocol::RESP_CONTACT_MSG_V3);
let (sender_prefix, payload, _snr) =
protocol::parse_contact_msg_v3_raw(&frame.data).unwrap();
assert_eq!(sender_prefix, "cc3c00006d65");
assert_eq!(payload, b"hello from 3ccc");
assert!(contacts.contains_key(&from));
assert_eq!(payload, b"direct hello");
}
#[test]
@@ -1672,10 +1739,10 @@ mod tests {
let frame =
packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys)
.expect("recent radio backlog must surface in mesh.messages");
let (sender_prefix, payload, _snr) =
protocol::parse_contact_msg_v3_raw(&frame.data).unwrap();
assert_eq!(sender_prefix, "cc3c3e436d65");
assert_eq!(payload, b"recent backlog");
// Broadcast → channel frame: [channel_idx][sender_prefix(6)][text].
assert_eq!(frame.code, protocol::RESP_MESHTASTIC_CHANNEL_TEXT);
assert_eq!(&frame.data[1..7], &[0xcc, 0x3c, 0x3e, 0x43, 0x6d, 0x65]);
assert_eq!(&frame.data[7..], b"recent backlog");
}
#[test]
+9
View File
@@ -69,6 +69,15 @@ pub const RESP_STATS: u8 = 0x18;
/// never emits this code; it lets the shared listener persist the E2E badge
/// without changing the on-wire Meshcore frame format.
pub const RESP_CONTACT_MSG_V3_E2E: u8 = 0x13;
/// Archipelago-internal synthetic response code used by the Meshtastic adapter
/// for CHANNEL broadcast text (e.g. the default public LongFast channel). Unlike
/// the Meshcore `RESP_CHANNEL_MSG_V3` — which carries no sender — a Meshtastic
/// MeshPacket gives us the originating node, so the listener can both file the
/// message under the channel thread AND attribute it to its sender. Frame
/// layout: `[channel_idx: u8][sender_pubkey_prefix: 6 bytes][text…]`. Kept below
/// 0x80 so it is not mistaken for a device push notification; Meshcore never
/// emits it.
pub const RESP_MESHTASTIC_CHANNEL_TEXT: u8 = 0x70;
// --- Push notification codes (device -> host, async, >= 0x80) ---
pub const PUSH_CONTACT_ADVERT: u8 = 0x80;