fix(mesh): report real RSSI/SNR for Reticulum peers instead of a fake 0
Every Reticulum-heard peer surfaced as rssi=0 — indistinguishable from a real 0 dBm reading and, worse, from "heard over the TCP bridge with no radio involved at all", which made a TCP-fed mesh look like working RF during the 2026-08-16 radio diagnosis. - Sidecar: announce handler now uses the 4-arg RNS dispatch to get the announce packet hash and reports per-announce rssi/snr from Reticulum's packet-stat cache; LXMF deliveries report message.rssi/snr/q (LXMF already populates them on direct RNode hops). All None over TCP or multi-hop — the honest RF-vs-internet discriminator. - Rust: ReticulumPeer caches last_rssi/last_snr from announce and recv events (a TCP-relayed announce never blanks a real RF reading), and get_contacts surfaces them so refresh_contacts propagates real values. - Identity discovery no longer hardcodes rssi 0: unknown is now None end-to-end and logged as such. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
519fa68c72
commit
458444d700
@@ -507,7 +507,13 @@ pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(super) async fn handle_identity_received(
|
pub(super) async fn handle_identity_received(
|
||||||
contact_id: u32,
|
contact_id: u32,
|
||||||
rssi: i16,
|
// None = signal strength unknown at this layer (identity adverts arrive
|
||||||
|
// through the transport-agnostic channel path, which carries no phy
|
||||||
|
// stats). The periodic refresh_contacts pass fills in the real value for
|
||||||
|
// transports that report one; hardcoding 0 here made every discovery
|
||||||
|
// read as "0 dBm" — indistinguishable from a real (if implausible)
|
||||||
|
// reading and from "no radio at all" (2026-08-16).
|
||||||
|
rssi: Option<i16>,
|
||||||
did: &str,
|
did: &str,
|
||||||
ed_pubkey_hex: &str,
|
ed_pubkey_hex: &str,
|
||||||
x25519_pubkey_hex: &str,
|
x25519_pubkey_hex: &str,
|
||||||
@@ -517,7 +523,7 @@ pub(super) async fn handle_identity_received(
|
|||||||
info!(
|
info!(
|
||||||
contact_id,
|
contact_id,
|
||||||
did = %did,
|
did = %did,
|
||||||
rssi,
|
rssi = ?rssi,
|
||||||
"Archipelago peer discovered over mesh"
|
"Archipelago peer discovered over mesh"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -592,7 +598,7 @@ pub(super) async fn handle_identity_received(
|
|||||||
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
|
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
|
||||||
arch_pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
arch_pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||||
x25519_pubkey: Some(x25519_bytes),
|
x25519_pubkey: Some(x25519_bytes),
|
||||||
rssi: Some(rssi),
|
rssi,
|
||||||
snr: None,
|
snr: None,
|
||||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||||
hops: 0,
|
hops: 0,
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ async fn handle_channel_payload(
|
|||||||
let contact_id = super::super::federation_peer_contact_id(&ed_hex);
|
let contact_id = super::super::federation_peer_contact_id(&ed_hex);
|
||||||
handle_identity_received(
|
handle_identity_received(
|
||||||
contact_id,
|
contact_id,
|
||||||
0,
|
None,
|
||||||
&did,
|
&did,
|
||||||
&ed_hex,
|
&ed_hex,
|
||||||
&x_hex,
|
&x_hex,
|
||||||
|
|||||||
@@ -294,6 +294,13 @@ struct ReticulumPeer {
|
|||||||
/// In-memory only (a persisted value would be stale by definition) —
|
/// In-memory only (a persisted value would be stale by definition) —
|
||||||
/// `0` after a restart until the peer re-announces.
|
/// `0` after a restart until the peer re-announces.
|
||||||
last_advert_at: u64,
|
last_advert_at: u64,
|
||||||
|
/// Signal stats of the last announce/message heard from this peer.
|
||||||
|
/// `Some` only for direct RNode (RF) receptions — the sidecar reports
|
||||||
|
/// `null` for TCP interfaces and multi-hop relays, which is exactly the
|
||||||
|
/// RF-vs-internet discriminator the UI needs (a TCP-fed mesh used to
|
||||||
|
/// surface every peer as rssi=0 and look like working RF, 2026-08-16).
|
||||||
|
last_rssi: Option<i16>,
|
||||||
|
last_snr: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
|
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
|
||||||
@@ -619,6 +626,8 @@ impl ReticulumLink {
|
|||||||
// start conservative and let the first real event refresh it.
|
// start conservative and let the first real event refresh it.
|
||||||
reachable: false,
|
reachable: false,
|
||||||
last_advert_at: 0,
|
last_advert_at: 0,
|
||||||
|
last_rssi: None,
|
||||||
|
last_snr: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -854,12 +863,13 @@ 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
|
// Signal stats from the last direct RNode reception (the
|
||||||
// Meshtastic 1.8.0 backlog plan) — RNS doesn't expose
|
// sidecar reports them per announce/message; None over TCP
|
||||||
// per-packet signal quality through LXMF, and there's no
|
// or multi-hop, which is the honest answer there). Position
|
||||||
// Reticulum position-sharing convention wired up.
|
// stays Meshtastic-only — no Reticulum position-sharing
|
||||||
rssi: None,
|
// convention is wired up.
|
||||||
snr: None,
|
rssi: p.last_rssi,
|
||||||
|
snr: p.last_snr,
|
||||||
lat: None,
|
lat: None,
|
||||||
lon: None,
|
lon: None,
|
||||||
arch_pubkey_hex: p.arch_pubkey_hex.clone(),
|
arch_pubkey_hex: p.arch_pubkey_hex.clone(),
|
||||||
@@ -1057,6 +1067,16 @@ impl ReticulumLink {
|
|||||||
|
|
||||||
let announced_name =
|
let announced_name =
|
||||||
pick_announced_name(explicit_name, app_data_text, is_legacy_blob);
|
pick_announced_name(explicit_name, app_data_text, is_legacy_blob);
|
||||||
|
// Per-announce signal stats from the sidecar: real numbers on
|
||||||
|
// a direct RNode reception, null over TCP or multi-hop. Only
|
||||||
|
// overwrite the cached value when the sidecar reports one —
|
||||||
|
// an announce relayed over TCP must not blank out the last
|
||||||
|
// real RF reading.
|
||||||
|
let rssi = ev
|
||||||
|
.get("rssi")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|v| i16::try_from(v).ok());
|
||||||
|
let snr = ev.get("snr").and_then(Value::as_f64).map(|v| v as f32);
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -1072,6 +1092,12 @@ impl ReticulumLink {
|
|||||||
if arch_pubkey_hex.is_some() {
|
if arch_pubkey_hex.is_some() {
|
||||||
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
||||||
}
|
}
|
||||||
|
if rssi.is_some() {
|
||||||
|
p.last_rssi = rssi;
|
||||||
|
}
|
||||||
|
if snr.is_some() {
|
||||||
|
p.last_snr = snr;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.or_insert_with(|| ReticulumPeer {
|
.or_insert_with(|| ReticulumPeer {
|
||||||
dest_hash: hash,
|
dest_hash: hash,
|
||||||
@@ -1080,6 +1106,8 @@ impl ReticulumLink {
|
|||||||
arch_pubkey_hex,
|
arch_pubkey_hex,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
last_advert_at: now,
|
last_advert_at: now,
|
||||||
|
last_rssi: rssi,
|
||||||
|
last_snr: snr,
|
||||||
});
|
});
|
||||||
self.persist_peers();
|
self.persist_peers();
|
||||||
}
|
}
|
||||||
@@ -1099,6 +1127,11 @@ impl ReticulumLink {
|
|||||||
// existing entry is proof of life too: mark it reachable so a
|
// existing entry is proof of life too: mark it reachable so a
|
||||||
// restart-restored (reachable=false) peer that DMs us doesn't
|
// restart-restored (reachable=false) peer that DMs us doesn't
|
||||||
// stay red-dotted until its next announce.
|
// stay red-dotted until its next announce.
|
||||||
|
let rssi = ev
|
||||||
|
.get("rssi")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|v| i16::try_from(v).ok());
|
||||||
|
let snr = ev.get("snr").and_then(Value::as_f64).map(|v| v as f32);
|
||||||
match self.peers.entry(source_hash) {
|
match self.peers.entry(source_hash) {
|
||||||
std::collections::hash_map::Entry::Vacant(e) => {
|
std::collections::hash_map::Entry::Vacant(e) => {
|
||||||
e.insert(ReticulumPeer {
|
e.insert(ReticulumPeer {
|
||||||
@@ -1107,11 +1140,20 @@ impl ReticulumLink {
|
|||||||
arch_pubkey_hex: None,
|
arch_pubkey_hex: None,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
last_advert_at: 0,
|
last_advert_at: 0,
|
||||||
|
last_rssi: rssi,
|
||||||
|
last_snr: snr,
|
||||||
});
|
});
|
||||||
self.persist_peers();
|
self.persist_peers();
|
||||||
}
|
}
|
||||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||||
e.get_mut().reachable = true;
|
let p = e.get_mut();
|
||||||
|
p.reachable = true;
|
||||||
|
if rssi.is_some() {
|
||||||
|
p.last_rssi = rssi;
|
||||||
|
}
|
||||||
|
if snr.is_some() {
|
||||||
|
p.last_snr = snr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,6 +1250,10 @@ impl ReticulumLink {
|
|||||||
arch_pubkey_hex: None,
|
arch_pubkey_hex: None,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
last_advert_at: 0,
|
last_advert_at: 0,
|
||||||
|
// Resource transfers ride an established Link —
|
||||||
|
// the sidecar reports no per-packet phy stats here.
|
||||||
|
last_rssi: None,
|
||||||
|
last_snr: None,
|
||||||
});
|
});
|
||||||
self.persist_peers();
|
self.persist_peers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,14 @@ class ReticulumDaemon:
|
|||||||
"title": message.title_as_string() if hasattr(message, "title_as_string") else "",
|
"title": message.title_as_string() if hasattr(message, "title_as_string") else "",
|
||||||
"app_data": app_data.hex(),
|
"app_data": app_data.hex(),
|
||||||
"stamp": getattr(message, "timestamp", None),
|
"stamp": getattr(message, "timestamp", None),
|
||||||
|
# LXMF populates these from the delivery packet's phy stats on
|
||||||
|
# direct RNode hops; they are None over TCP or multi-hop relay.
|
||||||
|
# That None-vs-number difference is the RF/TCP discriminator
|
||||||
|
# the UI needs (a TCP-fed mesh used to show rssi=0 and look
|
||||||
|
# like working RF, 2026-08-16).
|
||||||
|
"rssi": getattr(message, "rssi", None),
|
||||||
|
"snr": getattr(message, "snr", None),
|
||||||
|
"q": getattr(message, "q", None),
|
||||||
}
|
}
|
||||||
# Native LXMF attachment fields (Sideband/NomadNet/stock clients use
|
# Native LXMF attachment fields (Sideband/NomadNet/stock clients use
|
||||||
# these, NOT our own typed-envelope wire format) — a stock client's
|
# these, NOT our own typed-envelope wire format) — a stock client's
|
||||||
@@ -631,7 +639,12 @@ class _AnnounceHandler:
|
|||||||
self.daemon = daemon
|
self.daemon = daemon
|
||||||
self.receive_path_responses = True
|
self.receive_path_responses = True
|
||||||
|
|
||||||
def received_announce(self, destination_hash, announced_identity, app_data):
|
def received_announce(self, destination_hash, announced_identity, app_data,
|
||||||
|
announce_packet_hash=None):
|
||||||
|
# The 4-arg signature makes RNS.Transport hand us the announce packet
|
||||||
|
# hash (it dispatches by arity), which unlocks per-announce RSSI/SNR
|
||||||
|
# via Reticulum's local packet-stat cache. The default keeps a
|
||||||
|
# hypothetical 3-arg dispatch working.
|
||||||
# Decode what we can here (both the LXMF-standard display name and our
|
# Decode what we can here (both the LXMF-standard display name and our
|
||||||
# appended ARCHY identity blob — see _announce_app_data) so the Rust
|
# appended ARCHY identity blob — see _announce_app_data) so the Rust
|
||||||
# side gets clean typed fields instead of re-implementing msgpack.
|
# side gets clean typed fields instead of re-implementing msgpack.
|
||||||
@@ -661,12 +674,25 @@ class _AnnounceHandler:
|
|||||||
archy_blob = raw.decode("ascii", "ignore")
|
archy_blob = raw.decode("ascii", "ignore")
|
||||||
except Exception:
|
except Exception:
|
||||||
archy_blob = None
|
archy_blob = None
|
||||||
|
rssi = None
|
||||||
|
snr = None
|
||||||
|
try:
|
||||||
|
if announce_packet_hash is not None and self.daemon.reticulum is not None:
|
||||||
|
rssi = self.daemon.reticulum.get_packet_rssi(announce_packet_hash)
|
||||||
|
snr = self.daemon.reticulum.get_packet_snr(announce_packet_hash)
|
||||||
|
except Exception:
|
||||||
|
rssi = None
|
||||||
|
snr = None
|
||||||
self.daemon._emit_threadsafe({
|
self.daemon._emit_threadsafe({
|
||||||
"event": "announce",
|
"event": "announce",
|
||||||
"dest_hash": destination_hash.hex(),
|
"dest_hash": destination_hash.hex(),
|
||||||
"app_data": raw.hex(),
|
"app_data": raw.hex(),
|
||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
"archy_blob": archy_blob,
|
"archy_blob": archy_blob,
|
||||||
|
# None over TCP interfaces / multi-hop; real numbers only on a
|
||||||
|
# direct RNode reception — see the recv-event comment.
|
||||||
|
"rssi": rssi,
|
||||||
|
"snr": snr,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user