From 458444d700616e8f7d1b7e25fb0994852e15bea0 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 16 Aug 2026 04:52:04 -0400 Subject: [PATCH] fix(mesh): report real RSSI/SNR for Reticulum peers instead of a fake 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/archipelago/src/mesh/listener/decode.rs | 12 +++- core/archipelago/src/mesh/listener/frames.rs | 2 +- core/archipelago/src/mesh/reticulum.rs | 60 +++++++++++++++++--- reticulum-daemon/reticulum_daemon.py | 28 ++++++++- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index ef91638f..e4a5a1bb 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -507,7 +507,13 @@ pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> { #[allow(dead_code)] pub(super) async fn handle_identity_received( 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, did: &str, ed_pubkey_hex: &str, x25519_pubkey_hex: &str, @@ -517,7 +523,7 @@ pub(super) async fn handle_identity_received( info!( contact_id, did = %did, - rssi, + rssi = ?rssi, "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. arch_pubkey_hex: Some(ed_pubkey_hex.to_string()), x25519_pubkey: Some(x25519_bytes), - rssi: Some(rssi), + rssi, snr: None, last_heard: chrono::Utc::now().to_rfc3339(), hops: 0, diff --git a/core/archipelago/src/mesh/listener/frames.rs b/core/archipelago/src/mesh/listener/frames.rs index 2dc6f266..1ea05e67 100644 --- a/core/archipelago/src/mesh/listener/frames.rs +++ b/core/archipelago/src/mesh/listener/frames.rs @@ -421,7 +421,7 @@ async fn handle_channel_payload( let contact_id = super::super::federation_peer_contact_id(&ed_hex); handle_identity_received( contact_id, - 0, + None, &did, &ed_hex, &x_hex, diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index fadf12a6..c8abc035 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -294,6 +294,13 @@ struct ReticulumPeer { /// In-memory only (a persisted value would be stale by definition) — /// `0` after a restart until the peer re-announces. 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, + last_snr: Option, } /// 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. reachable: false, last_advert_at: 0, + last_rssi: None, + last_snr: None, }, ); } @@ -854,12 +863,13 @@ 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, + // Signal stats from the last direct RNode reception (the + // sidecar reports them per announce/message; None over TCP + // or multi-hop, which is the honest answer there). Position + // stays Meshtastic-only — no Reticulum position-sharing + // convention is wired up. + rssi: p.last_rssi, + snr: p.last_snr, lat: None, lon: None, arch_pubkey_hex: p.arch_pubkey_hex.clone(), @@ -1057,6 +1067,16 @@ impl ReticulumLink { let announced_name = 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() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -1072,6 +1092,12 @@ impl ReticulumLink { if arch_pubkey_hex.is_some() { 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 { dest_hash: hash, @@ -1080,6 +1106,8 @@ impl ReticulumLink { arch_pubkey_hex, reachable: true, last_advert_at: now, + last_rssi: rssi, + last_snr: snr, }); self.persist_peers(); } @@ -1099,6 +1127,11 @@ impl ReticulumLink { // existing entry is proof of life too: mark it reachable so a // restart-restored (reachable=false) peer that DMs us doesn't // 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) { std::collections::hash_map::Entry::Vacant(e) => { e.insert(ReticulumPeer { @@ -1107,11 +1140,20 @@ impl ReticulumLink { arch_pubkey_hex: None, reachable: true, last_advert_at: 0, + last_rssi: rssi, + last_snr: snr, }); self.persist_peers(); } 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, reachable: true, 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(); } diff --git a/reticulum-daemon/reticulum_daemon.py b/reticulum-daemon/reticulum_daemon.py index 55c2806b..654adb4d 100644 --- a/reticulum-daemon/reticulum_daemon.py +++ b/reticulum-daemon/reticulum_daemon.py @@ -295,6 +295,14 @@ class ReticulumDaemon: "title": message.title_as_string() if hasattr(message, "title_as_string") else "", "app_data": app_data.hex(), "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 # these, NOT our own typed-envelope wire format) — a stock client's @@ -631,7 +639,12 @@ class _AnnounceHandler: self.daemon = daemon 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 # appended ARCHY identity blob — see _announce_app_data) so the Rust # side gets clean typed fields instead of re-implementing msgpack. @@ -661,12 +674,25 @@ class _AnnounceHandler: archy_blob = raw.decode("ascii", "ignore") except Exception: 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({ "event": "announce", "dest_hash": destination_hash.hex(), "app_data": raw.hex(), "display_name": display_name, "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, })