From f392670e2a4393bde791e6739781fbcddb7a5dc2 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 30 Jun 2026 13:04:41 -0400 Subject: [PATCH 1/2] feat(mesh): show sender identity on received channel messages Received messages snapshot peer_name at receive time, so a Meshtastic text that arrived before its sender's NodeInfo was stuck showing the synthetic "Meshtastic !xxxx" id forever, and channel/group bubbles showed no sender at all. Add a per-bubble sender label for received messages in multi-sender views (mesh + Archipelago channels), resolved LIVE from the peer table so it always shows the current archy identity (e.g. "Arch Optiplex") the moment NodeInfo is learned. Falls back to "Unknown sender" rather than echoing a Channel/synthetic placeholder. Co-Authored-By: Claude Opus 4.8 (1M context) --- neode-ui/src/views/Mesh.vue | 42 +++++++++++++++++++++++++ neode-ui/src/views/mesh/mesh-styles.css | 1 + 2 files changed, 43 insertions(+) diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index d1337a2c..f4fe9704 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -768,6 +768,47 @@ function mergedUnreadCount(mp: MergedPeer): number { return total } +// Live contact_id -> friendly display name, rebuilt from the current peer +// table. Received messages snapshot a `peer_name` at receive time, so a text +// that arrived before its sender's NodeInfo is stuck showing a synthetic +// "Meshtastic !xxxx" id forever. Resolving the name live here means the bubble +// always shows the current archy identity (e.g. "Arch Optiplex") the moment it +// is learned — in every view, without rewriting stored rows. +const nameByContactId = computed>(() => { + const out = new Map() + for (const mp of mergedPeers.value) { + for (const cid of mp.contact_ids) out.set(cid, mp.display_name) + } + return out +}) + +// A generic, non-identifying placeholder the backend stamps before the real +// sender is known. We never want these to win over a live archy name. +function isPlaceholderName(name: string | null | undefined): boolean { + if (!name) return true + return /^Meshtastic !?[0-9a-f]{1,8}$/i.test(name) + || /^Channel \d+$/.test(name) + || /^Node #/.test(name) + || name === 'dm-via-channel' + || name === 'Unknown' +} + +// Sender label shown above a RECEIVED bubble. Only meaningful in multi-sender +// views (mesh channels + the Archipelago channel) where the header alone can't +// attribute each message; 1:1 DM threads already name the peer in the header. +// Prefers the live archy identity over the snapshotted name. +function senderLabelFor(msg: MeshMessage): string | null { + if (msg.direction !== 'received') return 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' +} + // Inline contact rename in the chat header. The pencil button toggles an // input bound to renameDraft; commit fires mesh.contacts-save keyed by // DID (or pubkey hex as fallback) so the alias propagates everywhere @@ -1689,6 +1730,7 @@ function isImageMime(mime?: string): boolean { :class="msg.direction" >
+
{{ senderLabelFor(msg) }}
↳ {{ replyTargetPreview(msg) }}
diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css index fea1568d..059c7fcb 100644 --- a/neode-ui/src/views/mesh/mesh-styles.css +++ b/neode-ui/src/views/mesh/mesh-styles.css @@ -144,6 +144,7 @@ .mesh-chat-bubble { max-width: 75%; padding: 10px 14px; border-radius: 16px; word-break: break-word; } .mesh-chat-bubble.sent { background: rgba(251, 146, 60, 0.15); border: 1px solid rgba(251, 146, 60, 0.2); border-bottom-right-radius: 4px; } .mesh-chat-bubble.received { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); border-bottom-left-radius: 4px; } +.mesh-chat-bubble-sender { font-size: 0.7rem; font-weight: 600; color: rgba(251, 146, 60, 0.85); margin-bottom: 3px; } .mesh-chat-bubble-text { color: rgba(255, 255, 255, 0.9); font-size: 0.9rem; line-height: 1.4; } .mesh-chat-bubble-meta { display: flex; align-items: center; gap: 6px; margin-top: 4px; justify-content: flex-end; } .mesh-chat-bubble-time { font-size: 0.65rem; color: rgba(255, 255, 255, 0.3); } From 12e7990b10ad7c739d1078f731e10e8a5189560a Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 30 Jun 2026 14:33:30 -0400 Subject: [PATCH 2/2] 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