Skip to content

#bugfix phantom/false "Direct Connections" and 0 SNR/RSSI node list corru… - #11208

Closed
Folex-Fire wants to merge 2 commits into
meshtastic:developfrom
Folex-Fire:Folex-Fire-patch-0_SNR-0_RSSI
Closed

#bugfix phantom/false "Direct Connections" and 0 SNR/RSSI node list corru…#11208
Folex-Fire wants to merge 2 commits into
meshtastic:developfrom
Folex-Fire:Folex-Fire-patch-0_SNR-0_RSSI

Conversation

@Folex-Fire

@Folex-Fire Folex-Fire commented Jul 25, 2026

Copy link
Copy Markdown

…ption caused by stripped sync/forwarded payloads

Problem Description / Findings: In Meshtastic firmware versions 2.8+, an issue occurs where remote nodes are incorrectly classified as 0-hop "Direct Connections" with 0 SNR and 0 RSSI in the node list, signal graph, and apps. This corrupts local mesh topology tracking.

Root Cause:This behavior is triggered by historical database syncs or specific store-and-forward position payloads received over the air (TRANSPORT_LORA) where via_mqtt is evaluated as false. Because these are stored data records rather than real-time over-the-air origin transmissions, their physical hop and radio headers are stripped or uninitialized. When NodeDB.cpp processes these packets getHopsAway(mp) evaluates mathematically to 0. The logic block if (hopsAway >= 0) accepts the 0 value without verifying physical hardware metrics.The UI and database interpret 0 hops as a direct neighbor, overwriting healthy historical RF signal stats with absolute 0 placeholders, and displays nodes that are not directly connected as a "Good" 0 RSSI 0 SNR direct connection.

The fix enforces a physical validation rule in NodeDB.cpp: a packet can only be evaluated as a 0-hop (Direct) neighbor if the local radio hardware actually measured an active RF signal during arrival. If hopsAway == 0 but both rx_snr and rx_rssi are exactly 0, the packet is flagged as a phantom sync payload and blocked from registering as a local neighbor.

Code Modification Applied (in NodeDB.cpp lines 3500 - 3520):

  // If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
    const int8_t hopsAway = getHopsAway(mp);
    
    // Gating rule: 0-hop payloads must have real hardware metrics to be treated as a direct link.
    // Prevent uninitialized historical sync / forwarded payloads from pretending to be local neighbors.
    bool is_phantom_packet = (hopsAway == 0 && mp.rx_snr == 0.0f && mp.rx_rssi == 0);

    if (hopsAway >= 0 && !is_phantom_packet) {
        info->has_hops_away = true;
        info->hops_away = hopsAway;
    } else if (is_phantom_packet) {
        info->has_hops_away = false; // Protect topology; drop direct neighbor flag
    }
    
    sortMeshDB();

Impact: Preserves Mesh Map Integrity: Stops phantom nodes from hijacking the direct neighbor list and signal graphs.Safe for Low Signals: Genuine weak packets still carry real hardware register readings (e.g., negative RSSI/SNR) and remain perfectly tracked, while mathematical absolute zeros from uninitialized structs are safely isolated.

assisted by Google Gemini

  • [ X] I have tested that my proposed changes behave as described.
  • I have tested that my proposed changes do not cause any obvious regressions on the following devices:
    • Heltec (Lora32) V3
    • LilyGo T-Deck
    • LilyGo T-Beam
    • RAK WisBlock 4631
    • Seeed Studio T-1000E tracker card
    • Other (please specify below)
      rak3401-1watt

Summary by CodeRabbit

Summary by CodeRabbit

  • Bug Fixes
    • Improved hop-distance reporting by ignoring invalid zero-hop packets that lack signal data.
    • Ensures hop-distance state is correctly set when reliable hop metrics are available, and cleared when the packet is identified as non-RF/placeholder information.

…ption caused by stripped sync/forwarded payloads

Problem Description / Findings: In Meshtastic firmware versions 2.8+, an issue occurs where remote nodes are incorrectly classified as 0-hop "Direct Connections" with 0 SNR and 0 RSSI in the node list, signal graph, and apps. This corrupts local mesh topology tracking.

Root Cause:This behavior is triggered by historical database syncs or specific store-and-forward position payloads received over the air (TRANSPORT_LORA) where via_mqtt is evaluated as false. Because these are stored data records rather than real-time over-the-air origin transmissions, their physical hop and radio headers are stripped or uninitialized. When NodeDB.cpp processes these packets getHopsAway(mp) evaluates mathematically to 0. The logic block if (hopsAway >= 0) accepts the 0 value without verifying physical hardware metrics.The UI and database interpret 0 hops as a direct neighbor, overwriting healthy historical RF signal stats with absolute 0 placeholders, and displays nodes that are not directly connected as a "Good" 0 RSSI 0 SNR direct connection.

The fix enforces a physical validation rule in NodeDB.cpp: a packet can only be evaluated as a 0-hop (Direct) neighbor if the local radio hardware actually measured an active RF signal during arrival. If hopsAway == 0 but both rx_snr and rx_rssi are exactly 0, the packet is flagged as a phantom sync payload and blocked from registering as a local neighbor.

Code Modification Applied (in NodeDB.cpp lines 3500 - 3520):  


      // If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
        const int8_t hopsAway = getHopsAway(mp);
        
        // Gating rule: 0-hop payloads must have real hardware metrics to be treated as a direct link.
        // Prevent uninitialized historical sync / forwarded payloads from pretending to be local neighbors.
        bool is_phantom_packet = (hopsAway == 0 && mp.rx_snr == 0.0f && mp.rx_rssi == 0);

        if (hopsAway >= 0 && !is_phantom_packet) {
            info->has_hops_away = true;
            info->hops_away = hopsAway;
        } else if (is_phantom_packet) {
            info->has_hops_away = false; // Protect topology; drop direct neighbor flag
        }
        
        sortMeshDB();
        
        
        
Impact: Preserves Mesh Map Integrity: Stops phantom nodes from hijacking the direct neighbor list and signal graphs.Safe for Low Signals: Genuine weak packets still carry real hardware register readings (e.g., negative RSSI/SNR) and remain perfectly tracked, while mathematical absolute zeros from uninitialized structs are safely isolated.

assisted by Google Gemini
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

NodeDB::updateFrom excludes zero-hop packets with zeroed RF metrics from hop-distance bookkeeping while preserving valid hop-distance updates.

Changes

NodeDB topology update

Layer / File(s) Summary
Filter phantom hop-distance data
src/mesh/NodeDB.cpp
NodeDB::updateFrom identifies phantom packets using zero hops and zero RF metrics, records hop data only for non-phantom packets, and clears has_hops_away for phantom packets.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested labels: bugfix, needs-review

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title matches the core fix: preventing phantom direct connections and zero SNR/RSSI node list corruption.
Description check ✅ Passed The description is detailed, matches the template well, and includes testing attestations, with only minor optional items left out.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/mesh/NodeDB.cpp`:
- Around line 3503-3514: Move the phantom-packet predicate calculation before
the samplePacketForHistogram() call, then skip histogram sampling when the
packet is phantom. Reuse the existing hopsAway value and predicate in the later
has_hops_away gating logic, avoiding duplicate calculations while preserving
sampling for valid packets.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a8d77d8-9ea0-42dc-9f53-dffd22372285

📥 Commits

Reviewing files that changed from the base of the PR and between b46c8c9 and 669b5ed.

📒 Files selected for processing (1)
  • src/mesh/NodeDB.cpp

Comment thread src/mesh/NodeDB.cpp Outdated
Comment on lines +3503 to +3514
// If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
const int8_t hopsAway = getHopsAway(mp);
if (hopsAway >= 0) {

// Gating rule: 0-hop payloads must have real hardware metrics to be treated as a direct link.
// Prevent uninitialized historical sync / forwarded payloads from pretending to be local neighbors.
bool is_phantom_packet = (hopsAway == 0 && mp.rx_snr == 0.0f && mp.rx_rssi == 0);

if (hopsAway >= 0 && !is_phantom_packet) {
info->has_hops_away = true;
info->hops_away = hopsAway;
} else if (is_phantom_packet) {
info->has_hops_away = false; // Protect topology; drop direct neighbor flag

@coderabbitai coderabbitai Bot Jul 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Apply the phantom filter before hop-scaling sampling.

At Lines 3496-3499, samplePacketForHistogram() runs before this gate. Phantom packets therefore contribute a hop-0 sample and can skew variable-hop recommendations even though has_hops_away is later cleared. Compute the predicate earlier and skip histogram sampling for phantom packets, reusing hopsAway.

Proposed fix
+        const int8_t hopsAway = getHopsAway(mp);
+        const bool isPhantomPacket = (hopsAway == 0 && mp.rx_snr == 0.0f && mp.rx_rssi == 0);

 `#if` HAS_VARIABLE_HOPS
         if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && !mp.via_mqtt &&
-            hopScalingModule) {
-            uint8_t hopCount = std::max(int8_t(0), getHopsAway(mp));
+            hopScalingModule && !isPhantomPacket) {
+            uint8_t hopCount = std::max(int8_t(0), hopsAway);
             hopScalingModule->samplePacketForHistogram(mp.from, hopCount);
         }
 `#endif`

-        const int8_t hopsAway = getHopsAway(mp);
-        bool is_phantom_packet = (hopsAway == 0 && mp.rx_snr == 0.0f && mp.rx_rssi == 0);
-
-        if (hopsAway >= 0 && !is_phantom_packet) {
+        if (hopsAway >= 0 && !isPhantomPacket) {
             info->has_hops_away = true;
             info->hops_away = hopsAway;
-        } else if (is_phantom_packet) {
+        } else if (isPhantomPacket) {
             info->has_hops_away = false;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
const int8_t hopsAway = getHopsAway(mp);
if (hopsAway >= 0) {
// Gating rule: 0-hop payloads must have real hardware metrics to be treated as a direct link.
// Prevent uninitialized historical sync / forwarded payloads from pretending to be local neighbors.
bool is_phantom_packet = (hopsAway == 0 && mp.rx_snr == 0.0f && mp.rx_rssi == 0);
if (hopsAway >= 0 && !is_phantom_packet) {
info->has_hops_away = true;
info->hops_away = hopsAway;
} else if (is_phantom_packet) {
info->has_hops_away = false; // Protect topology; drop direct neighbor flag
const int8_t hopsAway = getHopsAway(mp);
const bool isPhantomPacket = (hopsAway == 0 && mp.rx_snr == 0.0f && mp.rx_rssi == 0);
`#if` HAS_VARIABLE_HOPS
if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && !mp.via_mqtt &&
hopScalingModule && !isPhantomPacket) {
uint8_t hopCount = std::max(int8_t(0), hopsAway);
hopScalingModule->samplePacketForHistogram(mp.from, hopCount);
}
`#endif`
// If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
if (hopsAway >= 0 && !isPhantomPacket) {
info->has_hops_away = true;
info->hops_away = hopsAway;
} else if (isPhantomPacket) {
info->has_hops_away = false; // Protect topology; drop direct neighbor flag
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mesh/NodeDB.cpp` around lines 3503 - 3514, Move the phantom-packet
predicate calculation before the samplePacketForHistogram() call, then skip
histogram sampling when the packet is phantom. Reuse the existing hopsAway value
and predicate in the later has_hops_away gating logic, avoiding duplicate
calculations while preserving sampling for valid packets.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a good point!!!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NomDeTom, glad this helped. Filtering before histogram sampling will keep phantom zero-hop records from affecting both direct-neighbor topology and variable-hop recommendations.

🐇👍

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will test out the coderabitai suggestion and report back the results

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

@github-actions

Copy link
Copy Markdown
Contributor

@Folex-Fire, Welcome to Meshtastic!

Thanks for opening your first pull request. We really appreciate it.

We discuss work as a team in discord, please join us in the #firmware channel.
There's a big backlog of patches at the moment. If you have time,
please help us with some code review and testing of other PRs!

Welcome to the team 😄

Formatting
@Folex-Fire Folex-Fire changed the title Fix phantom/false "Direct Connections" and 0 SNR/RSSI node list corru… #BugFix phantom/false "Direct Connections" and 0 SNR/RSSI node list corru… Jul 25, 2026
@Folex-Fire

Copy link
Copy Markdown
Author

Immediately after updating to 2.8 a few weeks ago I noticed this. I updated to the new Android pre release as well and thought initially it was an app issue. Later I went back to the stable version of the app and it still persisted.

My situation seems to be a perfect storm for this oddball . After digging through packets , and mqqt traffic on the local mesh, What I have deduced is happening is : mostly position packets are sent initially from node A over lora, then node B sends it out over mqtt with 0 rssi 0 snr 0 hop and since pretty much my only node in range is node C with up and down kink, it is broadcasting those packets to me as a mqtt= false 0 snr 0 rssi 0 hop packets, the original code would interpret that as a direct connection node and list these in my nodedb and show a bunch of 0 rasi signals on the graph.

After the new firmware with the proposed change I have no issues with this. I did not want to wait forever for the node list to eventually overwrite as nodes become aged , so I did have to reset my node DB, to see immediate results. When doing so I preserved the 1 local node and one non local. To verify the fix, the local node is reporting signal normally again, and the distant non local node is still reporting 0 0, but I have not received any update info packets from it yet.

Screenshot_20260724-143551~2 Screenshot_20260724-133631~2

@thebentern
thebentern requested a review from NomDeTom July 25, 2026 11:42
@thebentern thebentern added the bugfix Pull request that fixes bugs label Jul 25, 2026
@Folex-Fire Folex-Fire changed the title #BugFix phantom/false "Direct Connections" and 0 SNR/RSSI node list corru… #bugfix phantom/false "Direct Connections" and 0 SNR/RSSI node list corru… Jul 25, 2026
@Folex-Fire

Copy link
Copy Markdown
Author

Summary: 0 SNR/RSSI "phantom direct connection" investigation

Started as one bug report (nodes showing 0 SNR / 0 RSSI while flagged as a direct/0-hop
connection). Turned out to be three separate issues, being split into separate
PRs/issues.


Issue 1: NodeDB::updateFrom() mis-flags reprocessed/duplicate position packets as direct

File: NodeDB.cpp, NodeDB::updateFrom()

The original is_phantom_packet guard only catches packets where hop_start == 0 && hop_limit == 0 explicitly. getHopsAway() actually computes hop_start - hop_limit, so
any packet where the two are simply equal but nonzero (e.g. hop_start == hop_limit == 3) also resolves to hopsAway == 0 and can still carry genuine 0.0 SNR / 0 RSSI from
a reprocessed/duplicate packet — but the current condition doesn't catch it because it
hard-requires both fields to literally be zero.

Fix direction: loosen the condition from hop_start == 0 && hop_limit == 0 to
hop_start == hop_limit, so any "computed 0 hops" case is covered, not just the
literal-zero one.

Also traced the call path: nodeDB->updateFrom() is invoked from
FloodingRouter::reprocessPacket(), which only fires on two duplicate-handling paths —
isRepeated retransmissions and perhapsHandleUpgradedPacket()'s duplicate-swap logic —
not the plain first-reception path. Broadcast position packets are the traffic most
likely to hit this because they're flooded and re-heard via multiple relay paths, which
is consistent with the bug being position-packet-specific.


Issue 2: SNR quantization truncation + zero-as-sentinel bug wipes stored SNR on reload

File: NodeDB.cpp, meshtastic_NodeDatabase_callback()

On save:

item.snr_q4 = (int32_t)(item.snr * 4.0f);
item.snr = 0.0f;

On load:

if (node.snr_q4)
    node.snr = node.snr_q4 / 4.0f;
node.snr_q4 = 0;

Two compounding problems:

  1. (int32_t)(...) truncates rather than rounds, so any real SNR in
    (-1.0, 1.0) dB — a very common range — truncates to snr_q4 == 0.
  2. if (node.snr_q4) treats 0 as "nothing was stored," so once truncation produces
    snr_q4 == 0, the reload step permanently skips restoring it — node.snr stays at
    its zero default forever after that point.

Net effect: any node whose last-known SNR was within ~1 dB of zero at save time comes
back reporting snr == 0 on every subsequent load — this is what produced "all nodes in
the DB report 0 SNR" right after a reboot/flash. Same zero-as-sentinel flaw exists in
updateFrom()'s if (mp.rx_snr) info->snr = mp.rx_snr; check.

Fix direction:

  • Quick fix: round instead of truncate — (int32_t)lroundf(item.snr * 4.0f) — shrinks
    the dead zone to the true snr == 0.0 case only.
  • Correct fix: add an explicit NODEINFO_BITFIELD_HAS_SNR_MASK bit (matching the
    existing bitfield pattern already used for VIA_MQTT, IS_FAVORITE, etc.) set
    whenever snr_q4 is written, and check that bit on load instead of relying on
    snr_q4's truthiness. Same pattern should replace the if (mp.rx_snr) check in
    updateFrom().

Issue 3: Fatal decode error in reprocessPacket() on unresolved channel hash

File: FloodingRouter.cpp, FloodingRouter::reprocessPacket()

Observed in logs:

Ignore dupe incoming msg (id=0x3662f62f ... rxSNR=7 rxRSSI=-30 ...)
WARN | No suitable channel found for decoding, hash was 0x0!
WARN | FloodingRouter::reprocessPacket: Fatal decode error (state=1, id=0x3662f62f, from=2614087765), can't check for traceroute

This specific packet has a real, strong signal (SNR 7, RSSI -30) — not a phantom case —
but its channel hash fails to resolve, so perhapsDecode() fails and reprocessPacket()
bails out having never fully decoded the payload. Suspected connection to Issue 1: if a
packet object is reused/pooled and not fully reset between calls, a decode failure on one
packet could leave stale decoded.has_bitfield/rx_snr state that a subsequent packet
processed through the same buffer inherits — which would explain phantom-flagged packets
turning up even for nodes with an otherwise solid, known-good connection.

Next step: check whether the packet pool object backing reprocessPacket() calls is
fully re-zeroed between reuses, particularly after a fatal decode error path.

@Folex-Fire Folex-Fire closed this Jul 25, 2026
@NomDeTom

Copy link
Copy Markdown
Collaborator

@Folex-Fire it's never simple, is it?

@Folex-Fire

Copy link
Copy Markdown
Author

@Folex-Fire it's never simple, is it?

Nope, never is! Especially when it initially looks or sounds simple! I Mostly deal with PostgreSQL and Java still getting used to C So far adding
bool is_phantom_packet = (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && hopsAway == 0 && mp.rx_snr == 0.0f && mp.rx_rssi == 0 && !mp.via_mqtt && mp.hop_start == mp.hop_limit && mp.relay_node == 0 && mp.rx_time != 0);

looks to be working for the position packets but its too early to tell since i have to sit and wait for them. at least i have 2 nodes going and 1 with serial logging going to help see what is happening. got 2 packets so far that should have caused the issue and didn't on my v3, but we will see. one thing at a time.

@Folex-Fire

Copy link
Copy Markdown
Author

hahahah you know what(a big part of) it is?! its the phone API.cpp that was changed so that packets replay as TRANSOPRT_LORA with 0 for all the info because they wanted to be able to load historical position data on re-connection to the phone. so on android it sees all these as 0 hop 0 snr 0 rssi direct nodes and displays them as such. What the heck! wow!
on top of that its not even loading the save nodeinfolite for the node just defaulting to all 0, then when you fix it to load the info it replays it as transport lora anyway so it gets logged as a new packet even if you already have that packet. if you save/update node info or singnal data in the nodedb it is structured in a way that will possibly cause corruption on power loss(periodic saves are risky) so nothing but new nodeinfo is saved anyway, also if the db is corrupted it never properly checks and just loads anyway. the whole thing needs a rewrite.

@NomDeTom

Copy link
Copy Markdown
Collaborator

Hmmm... worth having a look at #11014 - replayed packets are given the same IDs, so at least they're deduped after the first go-around.

@NomDeTom

Copy link
Copy Markdown
Collaborator

The DB does have error checking, and relatively corruption/powerloss resistant methods. We just need to fix this issue of them ending up as phantom links.

@NomDeTom NomDeTom mentioned this pull request Jul 28, 2026
8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Pull request that fixes bugs first-contribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants