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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-30 22:52:42 -04:00
parent dfca007949
commit 02b6b52a8c
8 changed files with 247 additions and 29 deletions

View File

@ -343,6 +343,8 @@ pub(super) async fn resolve_peer(state: &Arc<MeshState>, 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 = {

View File

@ -633,8 +633,14 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
// 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<MeshState>)
// 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);
}

View File

@ -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<ParsedPacket> {
})
}
/// 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;

View File

@ -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,
}
}

View File

@ -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<i16>,
pub snr: Option<f32>,
/// 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<f64>,
pub lon: Option<f64>,
}
/// Parse RESP_CONTACT (0x03) response.
@ -440,6 +450,13 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
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,
})
}

View File

@ -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())
}

View File

@ -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<f64>,
#[serde(default)]
pub lon: Option<f64>,
}
impl MeshPeer {
@ -264,6 +270,8 @@ mod tests {
last_advert: 0,
reachable: false,
pkc_capable: false,
lat: None,
lon: None,
}
}

View File

@ -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<number, NodePosition> {
return nodePositions.value
}