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
|
// Stamped fresh from `peer_pubkeys` in `get_contacts` once a real
|
||||||
// contact refresh runs; unknown at synthesis time here.
|
// contact refresh runs; unknown at synthesis time here.
|
||||||
pkc_capable: false,
|
pkc_capable: false,
|
||||||
|
lat: None,
|
||||||
|
lon: None,
|
||||||
};
|
};
|
||||||
let is_new = {
|
let is_new = {
|
||||||
let mut peers = state.peers.write().await;
|
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
|
// PKC capability is tracked by the radio driver's get_contacts(), not
|
||||||
// known at identity-advert time.
|
// known at identity-advert time.
|
||||||
pkc_capable: false,
|
pkc_capable: false,
|
||||||
|
lat: None,
|
||||||
|
lon: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_new = {
|
let is_new = {
|
||||||
|
|||||||
@ -633,8 +633,14 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
|||||||
// fail authentication after the next contact refresh.
|
// fail authentication after the next contact refresh.
|
||||||
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
|
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
|
||||||
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
||||||
rssi: None,
|
// Meshtastic-only today (see ParsedContact) — falls back to
|
||||||
snr: None,
|
// 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(),
|
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||||
hops: 0,
|
hops: 0,
|
||||||
last_advert: contact.last_advert,
|
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.
|
// than letting a transient contact refresh clear the pill.
|
||||||
pkc_capable: contact.pkc_capable
|
pkc_capable: contact.pkc_capable
|
||||||
|| existing.map(|p| p.pkc_capable).unwrap_or(false),
|
|| 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);
|
peers.insert(contact_id, peer);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -924,6 +924,16 @@ impl MeshtasticDevice {
|
|||||||
if Some(node.num) == self.node_num {
|
if Some(node.num) == self.node_num {
|
||||||
self.long_name = Some(name.clone());
|
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(
|
self.contacts.insert(
|
||||||
node.num,
|
node.num,
|
||||||
ParsedContact {
|
ParsedContact {
|
||||||
@ -935,6 +945,10 @@ impl MeshtasticDevice {
|
|||||||
flags: 0,
|
flags: 0,
|
||||||
// Stamped fresh from `peer_pubkeys` in `get_contacts`.
|
// Stamped fresh from `peer_pubkeys` in `get_contacts`.
|
||||||
pkc_capable: false,
|
pkc_capable: false,
|
||||||
|
rssi,
|
||||||
|
snr,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -971,21 +985,11 @@ fn packet_to_inbound_frame(
|
|||||||
);
|
);
|
||||||
return None;
|
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) {
|
if packet_is_stale(packet.rx_time) {
|
||||||
debug!(
|
debug!(
|
||||||
from = ?packet.from.map(|n| format!("!{:08x}", n)),
|
from = ?packet.from.map(|n| format!("!{:08x}", n)),
|
||||||
rx_time = ?packet.rx_time,
|
rx_time = ?packet.rx_time,
|
||||||
"Dropping stale Meshtastic text packet from radio backlog"
|
"Dropping stale Meshtastic packet from radio backlog"
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@ -997,25 +1001,14 @@ fn packet_to_inbound_frame(
|
|||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
info!(
|
|
||||||
from = format!("!{:08x}", from),
|
// Update per-contact bookkeeping (signal quality, position) for EVERY
|
||||||
len = packet.payload.len(),
|
// heard packet, not just text ones — a node's RSSI/SNR/location should
|
||||||
pki = packet.pki_encrypted,
|
// reflect the most recently heard packet of any kind.
|
||||||
"Meshtastic received text packet over the air"
|
|
||||||
);
|
|
||||||
// Record E2E status without overwriting the synthetic routing key used by
|
|
||||||
// the shared mesh listener.
|
|
||||||
if let Some(pk) = packet.public_key.as_ref() {
|
if let Some(pk) = packet.public_key.as_ref() {
|
||||||
peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
|
peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
|
||||||
}
|
}
|
||||||
if packet.pki_encrypted {
|
let contact = contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||||
debug!(
|
|
||||||
node = from,
|
|
||||||
"Meshtastic DM received end-to-end encrypted (PKI)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let from_key = synthetic_pubkey(from);
|
|
||||||
contacts.entry(from).or_insert_with(|| ParsedContact {
|
|
||||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||||
advert_name: format!("Meshtastic !{:08x}", from),
|
advert_name: format!("Meshtastic !{:08x}", from),
|
||||||
last_advert: 0,
|
last_advert: 0,
|
||||||
@ -1024,7 +1017,50 @@ fn packet_to_inbound_frame(
|
|||||||
flags: 0,
|
flags: 0,
|
||||||
// Stamped fresh from `peer_pubkeys` in `get_contacts`.
|
// Stamped fresh from `peer_pubkeys` in `get_contacts`.
|
||||||
pkc_capable: false,
|
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 broadcast (e.g. the default public LongFast channel, or any other
|
||||||
// channel slot): `to == BROADCAST_NUM`. File it under the channel thread —
|
// 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 {
|
fn now_unix_secs() -> u32 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@ -1696,6 +1756,87 @@ mod tests {
|
|||||||
assert!(rx_time <= after.saturating_add(1));
|
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]
|
#[test]
|
||||||
fn packet_to_inbound_frame_accepts_stock_peer_with_unset_clock() {
|
fn packet_to_inbound_frame_accepts_stock_peer_with_unset_clock() {
|
||||||
let from = 0x0000_3ccc;
|
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
|
// Off-radio E2E (federation) is handled by the archy-peer path; preserve
|
||||||
// any radio PKI capability learned for a twinned contact.
|
// any radio PKI capability learned for a twinned contact.
|
||||||
pkc_capable: existing.as_ref().map(|p| p.pkc_capable).unwrap_or(false),
|
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);
|
peers.insert(contact_id, peer);
|
||||||
// A radio twin of this node (same advert_name, no arch identity yet) can now
|
// A radio twin of this node (same advert_name, no arch identity yet) can now
|
||||||
@ -2126,6 +2128,8 @@ mod tests {
|
|||||||
last_advert: 0,
|
last_advert: 0,
|
||||||
reachable,
|
reachable,
|
||||||
pkc_capable: false,
|
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
|
/// NodeInfo public key, so the firmware delivers DMs PKC-encrypted). Meshcore
|
||||||
/// contacts leave it `false` — their E2E status is tracked per-message.
|
/// contacts leave it `false` — their E2E status is tracked per-message.
|
||||||
pub pkc_capable: bool,
|
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.
|
/// Parse RESP_CONTACT (0x03) response.
|
||||||
@ -440,6 +450,13 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
|
|||||||
flags,
|
flags,
|
||||||
// Meshcore tracks E2E per message, not per contact.
|
// Meshcore tracks E2E per message, not per contact.
|
||||||
pkc_capable: false,
|
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
|
// which has no Reticulum analogue (always true, tracked
|
||||||
// elsewhere via `take_rx_encrypted`), so leave it false here.
|
// elsewhere via `take_rx_encrypted`), so leave it false here.
|
||||||
pkc_capable: false,
|
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())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,12 @@ pub struct MeshPeer {
|
|||||||
/// E2E pill on a Sent DM to a PKC-capable stock peer, not only archy peers.
|
/// E2E pill on a Sent DM to a PKC-capable stock peer, not only archy peers.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pkc_capable: bool,
|
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 {
|
impl MeshPeer {
|
||||||
@ -264,6 +270,8 @@ mod tests {
|
|||||||
last_advert: 0,
|
last_advert: 0,
|
||||||
reachable: false,
|
reachable: false,
|
||||||
pkc_capable: false,
|
pkc_capable: false,
|
||||||
|
lat: None,
|
||||||
|
lon: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,10 @@ export interface MeshPeer {
|
|||||||
hops: number
|
hops: number
|
||||||
last_advert?: number
|
last_advert?: number
|
||||||
reachable?: boolean
|
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 {
|
export interface MeshChannel {
|
||||||
@ -215,6 +219,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
method: 'mesh.peers',
|
method: 'mesh.peers',
|
||||||
})
|
})
|
||||||
peers.value = res.peers
|
peers.value = res.peers
|
||||||
|
updateNodePositionsFromPeers(res.peers)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh peers'
|
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> {
|
function getNodePositions(): Map<number, NodePosition> {
|
||||||
return nodePositions.value
|
return nodePositions.value
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user