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:
parent
dfca007949
commit
02b6b52a8c
@ -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 = {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user