From 12e7990b10ad7c739d1078f731e10e8a5189560a Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 30 Jun 2026 14:33:30 -0400 Subject: [PATCH] fix(mesh): route Meshtastic public-channel text to the channel thread, not DMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbound Meshtastic text addressed to BROADCAST_NUM (the default public LongFast channel, or any channel slot) was filed into a per-sender 1:1 DM thread, so public-channel messages polluted individual people's DM chats and appeared as if sent directly to the user. packet_to_inbound_frame now detects `to == BROADCAST_NUM` and emits a new synthetic RESP_MESHTASTIC_CHANNEL_TEXT frame ([channel_idx][sender_prefix(6)][text]) that the listener files under the channel thread (contact_id = u32::MAX - idx) while still attributing the message to its real sender. Directed text (to == our node) still routes to the DM thread — a regression test locks that split in. send_channel_text now sets MeshPacket.channel (field 3) so archy actually transmits on channel 0 (public) instead of ignoring the slot. Mesh.vue keeps the synthetic "Meshtastic !xxxx" sender id when that is the best identity available for a stock public-channel device. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/mesh/listener/frames.rs | 59 ++++++++++++-- core/archipelago/src/mesh/meshtastic.rs | 85 +++++++++++++++++--- core/archipelago/src/mesh/protocol.rs | 9 +++ docs/SESSION-1.8.0-OTA-PROGRESS.md | 29 ++++++- neode-ui/src/views/Mesh.vue | 13 +-- 5 files changed, 171 insertions(+), 24 deletions(-) diff --git a/core/archipelago/src/mesh/listener/frames.rs b/core/archipelago/src/mesh/listener/frames.rs index d1deb246..3c568f9e 100644 --- a/core/archipelago/src/mesh/listener/frames.rs +++ b/core/archipelago/src/mesh/listener/frames.rs @@ -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, ) { // DM-via-channel wrapper (text form): the channel text carries an // ASCII "@DM:" 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"); } } diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index c933bc15..85ec7a9b 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -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 { struct ParsedPacket { from: Option, + /// 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, + /// MeshPacket.channel (field 3): the channel index a broadcast arrived on + /// (0 = primary / default public LongFast). + channel: u8, portnum: u32, payload: Vec, #[allow(dead_code)] @@ -1402,6 +1435,8 @@ struct ParsedPacket { fn parse_mesh_packet(data: &[u8]) -> Option { 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 { 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 { } 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] diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 5e18cb34..a394101a 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -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; diff --git a/docs/SESSION-1.8.0-OTA-PROGRESS.md b/docs/SESSION-1.8.0-OTA-PROGRESS.md index ccb3a726..8eee403a 100644 --- a/docs/SESSION-1.8.0-OTA-PROGRESS.md +++ b/docs/SESSION-1.8.0-OTA-PROGRESS.md @@ -10,12 +10,35 @@ Updated: 2026-06-30 identical in off-grid and normal mode. Test bed = `.116` / `.198` / `.228` (all EU_868). Don't touch the federation/FIPS path. -### TL;DR of where we are -The **archy software is correct and deployed.** The blocker is now PROVEN to be at the +### ✅✅✅ SOLVED 2026-06-30 — archy↔archy LoRa WORKS (delivery + E2E pill + identity) +VERIFIED: `.198→.228` directed DM → `.228` row `RECEIVED enc=True peer="Arch Optiplex"`. +All three nodes (.116/.198/.228) now hear each other + stock peer 3ccc. Deployed binary +**`737b16c3235b`** active on all three. Fix source **COMMITTED as `a57ae388`** on `main` +(not yet pushed to gitea-vps2/origin). + +**THE fix (receive stream):** archy ignored `FromRadio.rebooted` (field 8). Every config +write reboots the radio → firmware PhoneAPI resets to `STATE_SEND_NOTHING` and stops +streaming received packets until the client re-sends `want_config`. archy never did → +went deaf to inbound (that's why old messages only arrived after a full restart = fresh +want_config). Fix: handle `FROM_RADIO_REBOOTED` → set `pending_reinit` → re-send +want_config; plus a 10s keepalive heartbeat (insurance vs 15-min idle serial close) and +a pinned `modem_preset=LONG_FAST` so all radios share frequency. Combined with the earlier +E2E send fix (plain TEXT_MESSAGE_APP DM, firmware PKC) this closes archy↔archy LoRa. + +**Open follow-ups:** #A surface received msgs under archy identity in all UI views; #6 +device-onboarding modal; #8 Device-tab settings panel; #7 re-verify .116 in rotation; +#12 make modem_preset authoritative + hot-swap re-binding + RX-stall watchdog; +#14 signal-strength (RSSI/SNR) indicator per contact (from MeshPacket rx_rssi/rx_snr); +#15 map view plotting peer locations where shared (Meshtastic POSITION_APP portnum=3 +lat/lon). See the resume memory `project_session_resume_2026_06_30_lora.md` for the full +task list. + +### (historical) earlier TL;DR — RF-layer suspicion, now RESOLVED by the reboot-recovery fix +The **archy software is correct and deployed.** The blocker was at the **radio/RF layer: the three radios are not hearing each other over the air at all.** No amount of archy code change will fix that until the radios actually RF-link. **Resume by testing the radios directly at home (Meshtastic phone app over Bluetooth) — see "DO THIS -FIRST AT HOME" below.** +FIRST AT HOME" below.** ← this turned out to be the want_config resubscribe bug above. ### What is DONE and deployed (commit pending — see below) - **E2E send fix** (`core/archipelago/src/mesh/mod.rs` `send_message`, ~L1542): archy↔archy diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index f4fe9704..bf561921 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -802,11 +802,14 @@ function senderLabelFor(msg: MeshMessage): string | null { if (!activeChatChannel.value && !archChannelActive.value) return null const live = nameByContactId.value.get(msg.peer_contact_id) if (live && !isPlaceholderName(live)) return live - if (!isPlaceholderName(msg.peer_name)) return msg.peer_name ?? null - // Sender genuinely unknown (e.g. a meshcore channel broadcast, which drops - // the sender, or a text seen before its NodeInfo) — stay honest rather than - // echoing a "Channel N" / synthetic id as if it were a person. - return 'Unknown sender' + // The sender name snapshotted on the row. For a stock public-channel device a + // synthetic "Meshtastic !xxxx" id IS the best identity we have, so keep it — + // only suppress the genuinely contentless group/placeholder labels. + const snap = msg.peer_name + if (snap && !/^Channel \d+$/.test(snap) && snap !== 'dm-via-channel' && snap !== 'Unknown') { + return snap + } + return live || 'Unknown sender' } // Inline contact rename in the chat header. The pencil button toggles an