From 02b6b52a8ca7e4b8bdd1b7d5161c23fa47350a46 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 30 Jun 2026 22:52:42 -0400 Subject: [PATCH] feat(mesh): Meshtastic RSSI/SNR + peer-location map wiring (backlog #14/#15, part 1) Backend: parse_mesh_packet now decodes MeshPacket.rx_snr (field 8, float) and rx_rssi (field 12, int32), and a new POSITION_APP branch decodes Position. latitude_i/longitude_i (fields 1/2, sfixed32) -- all field numbers confirmed against the canonical meshtastic/protobufs mesh.proto, not guessed. Threaded through ParsedContact -> refresh_contacts -> MeshPeer (mirroring how pkc_capable was wired for #17), so mesh.peers now surfaces real rssi/snr/lat/ lon instead of always-null. Fixed a real bug found along the way: update_node_info's unconditional contact replace would have silently wiped any already-tracked signal/position data on the next NodeInfo packet -- now preserves it. Frontend: mesh.ts's updateNodePositionsFromPeers() feeds real position data into the SAME nodePositions map MeshMap.vue already renders from (parallel to the existing Coordinate/Alert-message path) -- MeshMap.vue itself needed zero changes, it was already built for this. 105/105 mesh tests pass (4 new: rx_snr/rx_rssi decode, position decode + incomplete-field handling, full packet_to_inbound_frame integration). Co-Authored-By: Claude Sonnet 5 --- core/archipelago/src/mesh/listener/decode.rs | 4 + core/archipelago/src/mesh/listener/session.rs | 14 +- core/archipelago/src/mesh/meshtastic.rs | 195 +++++++++++++++--- core/archipelago/src/mesh/mod.rs | 4 + core/archipelago/src/mesh/protocol.rs | 17 ++ core/archipelago/src/mesh/reticulum.rs | 8 + core/archipelago/src/mesh/types.rs | 8 + neode-ui/src/stores/mesh.ts | 26 +++ 8 files changed, 247 insertions(+), 29 deletions(-) diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index a8dc8c24..ddd3b47f 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -343,6 +343,8 @@ pub(super) async fn resolve_peer(state: &Arc, sender_prefix: &str) -> // Stamped fresh from `peer_pubkeys` in `get_contacts` once a real // contact refresh runs; unknown at synthesis time here. pkc_capable: false, + lat: None, + lon: None, }; let is_new = { let mut peers = state.peers.write().await; @@ -568,6 +570,8 @@ pub(super) async fn handle_identity_received( // PKC capability is tracked by the radio driver's get_contacts(), not // known at identity-advert time. pkc_capable: false, + lat: None, + lon: None, }; let is_new = { diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 46966e03..41c69d6f 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -633,8 +633,14 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) // fail authentication after the next contact refresh. arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()), x25519_pubkey: existing.and_then(|p| p.x25519_pubkey), - rssi: None, - snr: None, + // Meshtastic-only today (see ParsedContact) — falls back to + // whatever was already known if this refresh's contact + // snapshot doesn't carry a fresher reading (it always does + // for Meshtastic, since packet_to_inbound_frame updates the + // live contacts map on every heard packet; this fallback + // just avoids flapping to None on a transitional refresh). + rssi: contact.rssi.or_else(|| existing.and_then(|p| p.rssi)), + snr: contact.snr.or_else(|| existing.and_then(|p| p.snr)), last_heard: chrono::Utc::now().to_rfc3339(), hops: 0, last_advert: contact.last_advert, @@ -646,6 +652,10 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) // than letting a transient contact refresh clear the pill. pkc_capable: contact.pkc_capable || existing.map(|p| p.pkc_capable).unwrap_or(false), + // Position only ever improves to a fresher fix; never clear + // it just because a refresh's snapshot didn't carry one. + lat: contact.lat.or_else(|| existing.and_then(|p| p.lat)), + lon: contact.lon.or_else(|| existing.and_then(|p| p.lon)), }; peers.insert(contact_id, peer); } diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index cffd34ed..1a33c814 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -924,6 +924,16 @@ impl MeshtasticDevice { if Some(node.num) == self.node_num { self.long_name = Some(name.clone()); } + // Preserve any signal-quality/position readings already + // accumulated for this node (packet_to_inbound_frame's + // `contacts.entry(...).or_insert_with` path) — a NodeInfo update + // shouldn't wipe them back to None just because it replaces the + // identity fields. + let (rssi, snr, lat, lon) = self + .contacts + .get(&node.num) + .map(|c| (c.rssi, c.snr, c.lat, c.lon)) + .unwrap_or_default(); self.contacts.insert( node.num, ParsedContact { @@ -935,6 +945,10 @@ impl MeshtasticDevice { flags: 0, // Stamped fresh from `peer_pubkeys` in `get_contacts`. pkc_capable: false, + rssi, + snr, + lat, + lon, }, ); } @@ -971,21 +985,11 @@ fn packet_to_inbound_frame( ); return None; }; - if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() { - debug!( - from = ?packet.from.map(|n| format!("!{:08x}", n)), - portnum = packet.portnum, - payload_len = packet.payload.len(), - pki = packet.pki_encrypted, - "Meshtastic packet ignored because it is not a text payload" - ); - return None; - } if packet_is_stale(packet.rx_time) { debug!( from = ?packet.from.map(|n| format!("!{:08x}", n)), rx_time = ?packet.rx_time, - "Dropping stale Meshtastic text packet from radio backlog" + "Dropping stale Meshtastic packet from radio backlog" ); return None; } @@ -997,25 +1001,14 @@ fn packet_to_inbound_frame( ); return None; } - info!( - from = format!("!{:08x}", from), - len = packet.payload.len(), - pki = packet.pki_encrypted, - "Meshtastic received text packet over the air" - ); - // Record E2E status without overwriting the synthetic routing key used by - // the shared mesh listener. + + // Update per-contact bookkeeping (signal quality, position) for EVERY + // heard packet, not just text ones — a node's RSSI/SNR/location should + // reflect the most recently heard packet of any kind. if let Some(pk) = packet.public_key.as_ref() { peer_pubkeys.entry(from).or_insert_with(|| pk.clone()); } - if packet.pki_encrypted { - debug!( - node = from, - "Meshtastic DM received end-to-end encrypted (PKI)" - ); - } - let from_key = synthetic_pubkey(from); - contacts.entry(from).or_insert_with(|| ParsedContact { + let contact = contacts.entry(from).or_insert_with(|| ParsedContact { public_key_hex: hex::encode(synthetic_pubkey(from)), advert_name: format!("Meshtastic !{:08x}", from), last_advert: 0, @@ -1024,7 +1017,50 @@ fn packet_to_inbound_frame( flags: 0, // Stamped fresh from `peer_pubkeys` in `get_contacts`. pkc_capable: false, + rssi: None, + snr: None, + lat: None, + lon: None, }); + if packet.rx_rssi.is_some() { + contact.rssi = packet.rx_rssi.map(|v| v as i16); + } + if packet.rx_snr.is_some() { + contact.snr = packet.rx_snr; + } + + if packet.portnum == POSITION_APP { + if let Some((lat, lon)) = parse_position_lat_lon(&packet.payload) { + debug!(from = format!("!{:08x}", from), lat, lon, "Meshtastic position update"); + contact.lat = Some(lat); + contact.lon = Some(lon); + } + return None; + } + + if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() { + debug!( + from = format!("!{:08x}", from), + portnum = packet.portnum, + payload_len = packet.payload.len(), + pki = packet.pki_encrypted, + "Meshtastic packet ignored because it is not a text payload" + ); + return None; + } + info!( + from = format!("!{:08x}", from), + len = packet.payload.len(), + pki = packet.pki_encrypted, + "Meshtastic received text packet over the air" + ); + if packet.pki_encrypted { + debug!( + node = from, + "Meshtastic DM received end-to-end encrypted (PKI)" + ); + } + let from_key = synthetic_pubkey(from); // Channel broadcast (e.g. the default public LongFast channel, or any other // channel slot): `to == BROADCAST_NUM`. File it under the channel thread — @@ -1513,6 +1549,30 @@ fn parse_mesh_packet(data: &[u8]) -> Option { }) } +/// Decode a Meshtastic `Position` protobuf's `latitude_i`/`longitude_i` +/// (fields 1/2, both `sfixed32` — signed, wire type 5/Fixed32) into degrees. +/// Field numbers confirmed against the canonical `meshtastic/protobufs` +/// mesh.proto, not guessed. Returns `None` if either field is absent — a +/// Position packet without both isn't a usable fix. +fn parse_position_lat_lon(data: &[u8]) -> Option<(f64, f64)> { + let mut idx = 0; + let mut lat_i = None; + let mut lon_i = None; + while idx < data.len() { + let (field, value, next) = next_field(data, idx)?; + idx = next; + match (field, value) { + (1, FieldValue::Fixed32(v)) => lat_i = Some(v as i32), + (2, FieldValue::Fixed32(v)) => lon_i = Some(v as i32), + _ => {} + } + } + match (lat_i, lon_i) { + (Some(lat), Some(lon)) => Some((lat as f64 * 1e-7, lon as f64 * 1e-7)), + _ => None, + } +} + fn now_unix_secs() -> u32 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1696,6 +1756,87 @@ mod tests { assert!(rx_time <= after.saturating_add(1)); } + #[test] + fn parse_mesh_packet_decodes_rx_snr_and_rx_rssi() { + // Field numbers confirmed against the canonical meshtastic/protobufs + // mesh.proto: rx_snr=8 (float, fixed32 wire type), rx_rssi=12 (int32, + // varint wire type) — hand-built here since encode_mesh_packet (an + // OUTBOUND builder) never sets these inbound-only fields. + let mut decoded = Vec::new(); + encode_varint_field_into(1, TEXT_MESSAGE_APP as u64, &mut decoded); + encode_len_field(2, b"hi", &mut decoded); + + let mut packet = Vec::new(); + encode_fixed32_field(1, 0x1111_2222, &mut packet); // from + encode_len_field(4, &decoded, &mut packet); // decoded + encode_fixed32_field(8, (-7.5f32).to_bits(), &mut packet); // rx_snr + // int32 rx_rssi=-92 dBm, protobuf varint-encodes a negative int32 as + // the 10-byte two's-complement-extended varint; truncating back to + // i32 after decode recovers the original value (see parse_mesh_packet's + // `v as i32` cast — confirmed by this roundtrip, not just asserted). + encode_varint_field_into(12, (-92i32) as u32 as u64, &mut packet); + + let parsed = parse_mesh_packet(&packet).expect("packet should parse"); + assert_eq!(parsed.rx_snr, Some(-7.5)); + assert_eq!(parsed.rx_rssi, Some(-92)); + } + + #[test] + fn parse_position_lat_lon_decodes_sfixed32_degrees() { + // Position.latitude_i/longitude_i = fields 1/2 (sfixed32), confirmed + // against the canonical mesh.proto. New York City, roughly. + let lat_i: i32 = 407_128_000; // 40.7128 degrees * 1e7 + let lon_i: i32 = -740_060_000; // -74.0060 degrees * 1e7 + let mut position = Vec::new(); + encode_fixed32_field(1, lat_i as u32, &mut position); + encode_fixed32_field(2, lon_i as u32, &mut position); + + let (lat, lon) = parse_position_lat_lon(&position).expect("both fields present"); + assert!((lat - 40.7128).abs() < 1e-6); + assert!((lon - (-74.0060)).abs() < 1e-6); + } + + #[test] + fn parse_position_lat_lon_none_when_incomplete() { + let mut position = Vec::new(); + encode_fixed32_field(1, 0, &mut position); // only latitude, no longitude + assert!(parse_position_lat_lon(&position).is_none()); + } + + #[test] + fn packet_to_inbound_frame_updates_contact_signal_and_position_without_a_chat_frame() { + let from = 0x0000_4444; + let mut contacts = HashMap::new(); + let mut peer_pubkeys = HashMap::new(); + + // A POSITION_APP packet carrying rx_snr/rx_rssi too. + let mut position = Vec::new(); + encode_fixed32_field(1, (407_128_000i32) as u32, &mut position); + encode_fixed32_field(2, (-740_060_000i32) as u32, &mut position); + let mut decoded = Vec::new(); + encode_varint_field_into(1, POSITION_APP as u64, &mut decoded); + encode_len_field(2, &position, &mut decoded); + + let mut packet = Vec::new(); + encode_fixed32_field(1, from, &mut packet); + encode_fixed32_field(2, BROADCAST_NUM, &mut packet); + encode_len_field(4, &decoded, &mut packet); + encode_fixed32_field(7, 12_345, &mut packet); + encode_fixed32_field(8, (-6.0f32).to_bits(), &mut packet); + encode_varint_field_into(12, (-80i32) as u32 as u64, &mut packet); + + // A position packet isn't a chat message — no InboundFrame — but it + // must still update the contact's signal/position bookkeeping. + let frame = + packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys); + assert!(frame.is_none(), "POSITION_APP must not surface as a chat frame"); + let contact = contacts.get(&from).expect("contact should be tracked"); + assert_eq!(contact.snr, Some(-6.0)); + assert_eq!(contact.rssi, Some(-80)); + assert!((contact.lat.unwrap() - 40.7128).abs() < 1e-6); + assert!((contact.lon.unwrap() - (-74.0060)).abs() < 1e-6); + } + #[test] fn packet_to_inbound_frame_accepts_stock_peer_with_unset_clock() { let from = 0x0000_3ccc; diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index c915f7b3..f129b194 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -249,6 +249,8 @@ pub(crate) async fn upsert_federation_peer( // Off-radio E2E (federation) is handled by the archy-peer path; preserve // any radio PKI capability learned for a twinned contact. pkc_capable: existing.as_ref().map(|p| p.pkc_capable).unwrap_or(false), + lat: existing.as_ref().and_then(|p| p.lat), + lon: existing.as_ref().and_then(|p| p.lon), }; peers.insert(contact_id, peer); // A radio twin of this node (same advert_name, no arch identity yet) can now @@ -2126,6 +2128,8 @@ mod tests { last_advert: 0, reachable, pkc_capable: false, + lat: None, + lon: None, } } diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 06b9f6ba..7835d95f 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -396,6 +396,16 @@ pub struct ParsedContact { /// NodeInfo public key, so the firmware delivers DMs PKC-encrypted). Meshcore /// contacts leave it `false` — their E2E status is tracked per-message. pub pkc_capable: bool, + /// Signal strength (dBm) / signal-to-noise ratio (dB) of the most recently + /// heard packet from this contact. Meshtastic-only today (from + /// `MeshPacket.rx_rssi`/`.rx_snr`); other transports leave these `None`. + pub rssi: Option, + pub snr: Option, + /// Last known position, from a Meshtastic `POSITION_APP` broadcast + /// (`Position.latitude_i`/`.longitude_i`, degrees). `None` until the + /// contact has shared one. + pub lat: Option, + pub lon: Option, } /// Parse RESP_CONTACT (0x03) response. @@ -440,6 +450,13 @@ pub fn parse_contact(data: &[u8]) -> Result { flags, // Meshcore tracks E2E per message, not per contact. pkc_capable: false, + // Meshcore's own contact format does carry lat/lon at a fixed offset + // (see the format comment above) but wiring that up is out of scope + // for this Meshtastic-specific backlog item. + rssi: None, + snr: None, + lat: None, + lon: None, }) } diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index b25c8da8..97c700db 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -435,6 +435,14 @@ impl ReticulumLink { // which has no Reticulum analogue (always true, tracked // elsewhere via `take_rx_encrypted`), so leave it false here. pkc_capable: false, + // RSSI/SNR/position are Meshtastic-only for now (see the + // Meshtastic 1.8.0 backlog plan) — RNS doesn't expose + // per-packet signal quality through LXMF, and there's no + // Reticulum position-sharing convention wired up. + rssi: None, + snr: None, + lat: None, + lon: None, }) .collect()) } diff --git a/core/archipelago/src/mesh/types.rs b/core/archipelago/src/mesh/types.rs index 5059909e..09733cad 100644 --- a/core/archipelago/src/mesh/types.rs +++ b/core/archipelago/src/mesh/types.rs @@ -88,6 +88,12 @@ pub struct MeshPeer { /// E2E pill on a Sent DM to a PKC-capable stock peer, not only archy peers. #[serde(default)] pub pkc_capable: bool, + /// Last known position (degrees), from a Meshtastic `POSITION_APP` + /// broadcast. `None` until the peer has shared one. + #[serde(default)] + pub lat: Option, + #[serde(default)] + pub lon: Option, } impl MeshPeer { @@ -264,6 +270,8 @@ mod tests { last_advert: 0, reachable: false, pkc_capable: false, + lat: None, + lon: None, } } diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index eb770eff..6c0e7570 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -38,6 +38,10 @@ export interface MeshPeer { hops: number last_advert?: number reachable?: boolean + /** Last known position (degrees), from a Meshtastic POSITION_APP broadcast. + * Absent until the peer has shared one. */ + lat?: number | null + lon?: number | null } export interface MeshChannel { @@ -215,6 +219,7 @@ export const useMeshStore = defineStore('mesh', () => { method: 'mesh.peers', }) peers.value = res.peers + updateNodePositionsFromPeers(res.peers) } catch (err: unknown) { error.value = err instanceof Error ? err.message : 'Failed to fetch mesh peers' } @@ -298,6 +303,27 @@ export const useMeshStore = defineStore('mesh', () => { } } + /** Feed real Meshtastic POSITION_APP broadcasts into the same map the + * manually-shared Coordinate/Alert messages already populate — MeshMap.vue + * needs no changes, it already renders from `nodePositions`. Uses + * `last_heard` as the freshness timestamp, same comparison pattern as + * `updateNodePositionsFromMessages`, so a peer position never clobbers a + * more-recently-shared coordinate message and vice versa. */ + function updateNodePositionsFromPeers(peerList: MeshPeer[]) { + for (const peer of peerList) { + if (typeof peer.lat !== 'number' || typeof peer.lon !== 'number') continue + const existing = nodePositions.value.get(peer.contact_id) + if (!existing || peer.last_heard > existing.timestamp) { + nodePositions.value.set(peer.contact_id, { + lat: peer.lat, + lng: peer.lon, + label: peer.advert_name, + timestamp: peer.last_heard, + }) + } + } + } + function getNodePositions(): Map { return nodePositions.value }