Skip to content

Feature: Repeated sending for DIRECT-route packets with echo-cancellation (improves delivery, reduces floods)#2670

Open
usrflo wants to merge 4 commits into
meshcore-dev:devfrom
usrflo:feature/repeated-sending-2
Open

Feature: Repeated sending for DIRECT-route packets with echo-cancellation (improves delivery, reduces floods)#2670
usrflo wants to merge 4 commits into
meshcore-dev:devfrom
usrflo:feature/repeated-sending-2

Conversation

@usrflo

@usrflo usrflo commented Jun 2, 2026

Copy link
Copy Markdown

Relates to #1342.

Motivation

Direct messages over multi-hop paths fail silently when a single hop misses a packet due to temporary radio interference or channel congestion. The sending node then falls back to a flood — which is both unreliable under load and consumes significant airtime. In my tests most incoming paths with a length of 3 hops couldn't be reused in back traces: a far too low number that should be increased by an error correction.

What this PR does

This branch adds conditional repeated sending for DIRECT-route packets at the repeater level:

  • After forwarding a DIRECT packet, a repeater schedules up to max_resend_attempts re-transmissions (default: 2, configurable 0–3 via set max.resend).
  • Each pending retransmit is cancelled immediately when the repeater overhears the downstream relay successfully forwarding the same packet (echo-detection via hash comparison in the outbound queue).
  • The RX loop was changed to drain all pending packets per loop() call, minimising the race window between echo detection and retransmit timer expiry.
  • ACK packets use a dedicated deduplication table (_acks[] in SimpleMeshTables) to keep deduplication cheap (4-byte compare, no SHA-256) and to prevent ACK entries from evicting long-lived flood-packet hashes.

Comparison with PR #2367 (HALO)

This implementation shares the same goal as #2367 — improve direct-path reliability — but uses a different strategy. Both branches were applied to mcsim (including fixes to make the simulator timing-accurate for retransmit scenarios, see simulator commits) and run against the same standardised test scenario.

Test topology: 3 repeaters between Alice and Bob, all links of marginal/bad quality
Test: 10 random seeds × 20 DMs each = 200 deliveries total

Branch Avg delivered Avg TX (direct) Avg collisions
halo-direct-path-retries (#2367) ~15.9 / 20 ~420 ~15
repeated-sending-2 (this PR) ~19.4 / 20 ~187 ~3

Key advantages of this approach:

  • Higher delivery rate (~97 % vs ~80 % in the test scenario)
  • 55 % fewer TX packets — echo-cancellation prevents unnecessary retransmits before they fire
  • ~80 % fewer collisions — significantly less channel saturation
  • Simpler and more resource-efficient — no SNR-gated retry logic, no neighbor-table lookup; just overhear and cancel

Delivery rate vs. resend attempts (mcsim, retry_showcase topology)

max_resend_attempts Delivery rate
0 (stock MeshCore) 71 %
1 91 %
2 (default) 95.5 %
3 97 %

A value of 2–3 is a good compromise between reliable delivery and channel efficiency.

CLI

No new commands are needed — max_resend_attempts is already exposed:

get max.resend # current value (default: 2)
set max.resend <0–3> # 0 = disabled

@usrflo

usrflo commented Jun 2, 2026

Copy link
Copy Markdown
Author

Some information about the 3 commits:

  1. the first commit MESHCORE_SIMULATOR patch contains common changes to use "mcsim" (see https://github.com/Brent-A/mcsim and https://github.com/usrflo/mcsim) or any other simulation that requires simulation hooks. This commit is not required for this feature but is a requirement to run simulated comparisons.
  2. the second commit Implemented repeated sending for error correction contains changes to the MeshCore implementation (dev branch)
  3. the third commit Added comments to explain changes related to the repeated-sending feature is meant for the code review. This commit might be removed if this pull request is accepted.

@cwichura

cwichura commented Jun 3, 2026

Copy link
Copy Markdown

This seems very similar to #2367.

@usrflo

usrflo commented Jun 12, 2026

Copy link
Copy Markdown
Author

After mcsim-simulation runs and internal tests on real hardware I released firmware packages named "ufo_0.1" for repeaters, companions and room-servers at https://github.com/usrflo/MeshCore/releases. This firmware is based on MeshCore-dev v1.16.0 with application of this pull request.

It would be great to get some feedback on this feature. @wlockwood?

@usrflo

usrflo commented Jun 19, 2026

Copy link
Copy Markdown
Author

To recognize propagated TRACE packages a special detection was required as TRACE packages differ in using path and body fields. In the simulation with a relatively stable chain of repeaters (TOPOLOGY, BEHAVIOR) I got the following results:

Without repeated sending (max.resend 0)
averages over 10 seeds:
sent per run: 8.0
received total per run: 6.2
collisions per run: 0.0
overall delivery rate: 77.5%

With repeated sending (max.resend 2)
averages over 10 seeds:
sent per run: 8.0
received total per run: 8.0
collisions per run: 3.6
overall delivery rate: 100.0%

@usrflo
usrflo marked this pull request as draft June 26, 2026 10:58
@usrflo
usrflo marked this pull request as ready for review June 26, 2026 20:57
@usrflo
usrflo marked this pull request as draft June 30, 2026 19:17
@usrflo
usrflo marked this pull request as ready for review July 11, 2026 12:33
@usrflo

usrflo commented Jul 11, 2026

Copy link
Copy Markdown
Author

After the last bugfix I tested the firmware on the following hardware device chain:
C1 -> R1 -> R2 -> R3 -> C2 with weak links (-80 / -90 / -100 RSSI) between each of these nodes (strongly isolated devices with weak antennas). A successful delivery was counted when C2 sent out an ACK.

Without repeated sending: 81 of 100 packages were delivered
With repeated sending: 96 of 100 packages were delivered 🚀

Side note:
These tests were executed with

  1. the noise_floor calculation fix from https://github.com/usrflo/MeshCore/tree/fix/noise-floor-ratchet, see Fix/noise floor ratchet, adapt noise to the real floor with recovery in both directions #2933
  2. the non blocking waiting for silence before TX from https://github.com/usrflo/MeshCore/tree/feature/quiet-dwell, see README-quiet-dwell.md

See the according firmware release ufo_0.5 at https://github.com/usrflo/MeshCore/releases

diagram-resend chart-delivery

To give an idea about the measurement: I used 2 observers to check the details on the two separated locations.
The SNR/RSSI values in the following excerpt are those of the observer. The DUP at the end of the line marks a repeated package sending.

One sample:

13:03:24.523  Δ=  7.508s  Direct·TXT_MSG sender=MeshL1New origin=MeshL1New dest=WL1_C2  path=ND1_R1→BB1_R4→SB1_R2  hop 1/4  SNR= +3.2 RSSI= -72  871CD76C34937AEC ⚠noi
┃ ⚠ INTERFERENCE END    noise      13:03:18  dur=6.5s peak=+40  pkts=0 rx_errs=0
13:03:25.299  Δ=  0.776s  Direct·TXT_MSG sender=ND1_R1 origin=MeshL1New dest=WL1_C2  path=BB1_R4→SB1_R2  hop 2/4  SNR= +9.5 RSSI= -59  871CD76C34937AEC
13:03:26.329  Δ=  1.030s  Direct·TXT_MSG sender=ND1_R1 origin=MeshL1New dest=WL1_C2  path=BB1_R4→SB1_R2  hop 2/4  SNR=+13.2 RSSI= -59  871CD76C34937AEC DUP ⟳1.030s
13:03:26.847  Δ=  0.518s  Direct·TXT_MSG sender=BB1_R4 origin=MeshL1New dest=WL1_C2  path=SB1_R2  hop 3/4  SNR=+13.2 RSSI= -75  871CD76C34937AEC
13:03:27.398  Δ=  0.551s  Direct·TXT_MSG sender=SB1_R2 origin=MeshL1New dest=WL1_C2  path=∅  hop 4/4  SNR=+12.0 RSSI= -49  871CD76C34937AEC
13:03:27.915  Δ=  0.517s  Direct·ACK   sender=direct    path=∅  hop 1/1  SNR=+11.8 RSSI= -76  70E804018D91907B
┃ ⚠ INTERFERENCE START  contention 13:03:33  nf=-108 rssi=-76 margin=+32 (dev_ms=8340913)
  └─ chain MeshL1New → ND1_R1 → BB1_R4 → SB1_R2 → WL1_C2  | 6 RX | SNR +3.2..+13.2 RSSI -76..-49 | TXT_MSG

@usrflo

usrflo commented Jul 18, 2026

Copy link
Copy Markdown
Author

Today's merges were a setback; I will try to find the reason. So long this pull request is set back to draft.

@usrflo
usrflo marked this pull request as draft July 18, 2026 21:22
@usrflo

usrflo commented Jul 19, 2026

Copy link
Copy Markdown
Author

Good numbers again, I fixed the timing issues of the last commits.

The following problems are addressed in the changes of the last days:

  • schedule the sending after noise detection to 3 different wait buckets based on the residual path length (mod 3) of the direct message; so multiple nodes nearby don't send at the same time after noise detection
  • don't resend packages if the sending queue becomes filled up too much (prevent congestion)
  • appropriate timing for package cancellations before a resend. This depends on the airtime and constant wait intervals for the processing on the nodes.

Last results with the test chain Companion1->Rep1->Rep2->Rep3->Companion2, 50 direct messages in a 10 second interval, up to 2 package repetitions (default). Every ACK is counted as success:
a) sending over very weak links (-70 ... -100 RSSI) with an interferer node (an advert every 6 seconds): 46 ACKs
b) sending over very weak links (-70 ... -100 RSSI) without an interferer node: 49 ACKs
c) sending over stable links: 50 ACKs

@usrflo
usrflo marked this pull request as ready for review July 19, 2026 13:35
usrflo and others added 3 commits July 21, 2026 12:41
- Added max_resend_attempts preference to control the number of resend attempts for direct packets.
- Enhanced the Dispatcher class to manage packet resends, including a new resendPacket method and logic to handle retransmission delays.
- Updated the Packet class to include sending_attempts and final_hop_ack_resend flags for tracking retransmission state.
- Modified the Mesh class to cancel pending final-hop resends upon receiving ACKs, preventing unnecessary retransmissions.
- Introduced a dedicated ACK deduplication mechanism in SimpleMeshTables to handle multiple ACKs efficiently.
- Updated CommonCLI to support configuration of max_resend_attempts via CLI commands.
- Improved packet hash calculation and comparison logic to support the new resend functionality.
The noise-floor calibration sampled only RSSI values below the current
floor + threshold, a one-way ratchet: it accepted ever-lower samples but
never recovered upward, so _noise_floor drifted to the -120 clamp and
stayed there. That left the RSSI-margin LBT (isChannelActive with
interference_threshold, plus isResendChannelActive / isChannelNoisy on
the feature branches that consume _noise_floor) permanently over-sensitive
— resends and dwell-gated TX deferred even on a quiet channel.

Replace the ratcheted block mean with the median of the 64-sample block:
- accepts every idle (!isReceivingPacket) sample — no downward bias;
- median rejects transient interference spikes (high and low outliers) and
  recovers in BOTH directions;
- _noise_floor is written only after a full block, so the previous value
  stays valid while the next block is sampled — no reset-to-0 and thus no
  permissive LBT window (margin = RSSI - 0) during reconvergence.

resetAGC no longer forces _noise_floor = 0 (the stuck-ratchet workaround);
it only discards the in-progress block so a fresh one is measured after the
analog frontend reset.

Verified: Heltec_v3_repeater firmware build (compiles RadioLibWrappers.cpp
against real RadioLib).

Co-Authored-By: Claude <noreply@anthropic.com>
@usrflo
usrflo force-pushed the feature/repeated-sending-2 branch from 81e7fe2 to 8acdb11 Compare July 21, 2026 14:38
@usrflo

usrflo commented Jul 21, 2026

Copy link
Copy Markdown
Author

Last update: the max wait time limit - used as a window to cancel packages prepared to be resended - needs to be calculated according to the airtime / package size.

…tection to be replaced by _prefs.interference_threshold when the currentRSSI problem is solved, possibly via PR meshcore-dev#2933
@usrflo

usrflo commented Jul 22, 2026

Copy link
Copy Markdown
Author

Repeated-sending - current state and the safeguards that keep it stable

A resend is a redundant retransmission of a DIRECT (acknowledged-path) packet, queued by the originator and auto-cancelled when a downstream relay's forward — or the destination's ACK — is overheard. The logic only ever adds a retry that can be called off, and sheds/collisions are guarded as follows.

Safeguards

  • Scoped eligibility + attempt cap. Only direct-routed packets still carrying ≥1 relay hash are resendable (there must be a downstream forward that can cancel it). The final hop has no downstream forward, so it gets exactly one resend, cancelled by the returning ACK. Retries are capped at getMaxResendAttempts() (default 2).

  • Non-invasive resend LBT. Resends deliberately skip CAD so the radio stays in RX and can still overhear the cancelling forward. Instead isResendChannelActive() gates on isReceivingPacket() || (currentRSSI − noiseFloor ≥ RESEND_INTERFERENCE_MARGIN), margin fixed at 12 dB (as long as the prefs int.threshold discussion goes on). The RSSI-energy term detects an ongoing transmission at any point — preamble or payload — so a long packet is not overlaid by a retry. First sends keep normal CAD carrier-sense, but it's recommended to set cad off as it seems to increase deafness.

  • Cancel window sized to packet length / SF. The wait before a resend fires is C0 + K·airtime + margin + (attempt−1)·jitter, under an airtime-proportional ceiling (CAP_BASE + CAP_AIRTIME_X·airtime). The window therefore always outlasts the downstream forward for any length or spreading factor. The earlier flat 1500 ms cap under-covered SF10 long messages, so every long packet fired its resend before the forward could cancel it — a redundant, colliding resend on each one.

  • Per-hop busy-recheck bucket (resenders stay out of each other's way). When the channel is busy, deferred resenders retry at (pathHashCount % 3 + 1) × 120 ms instead of all firing on the same loop tick. The hash count drops by exactly 1 per forwarding hop, so any two mutually-in-range chain neighbours (≤2 hops apart) always land in different buckets and never collide on retry; same-bucket pairs are ≥3 hops apart and don't interfere. This is a re-check offset only — the cancel window is not inflated.

  • Pool-shedding (queue-overflow protection). A resend shares a packet-pool slot with RX, so it is only queued when the free pool is healthy (freeCount > 6). Under sustained load the redundant retry is dropped rather than exhausting the pool and deafening the node — the primary direct forward has already been transmitted.


If you like to test this PR it's recommended to use:

set cad off
set agc.reset.interval 0     # I only tested with 0, so better to do so
set max.resend 2

This PR ist part of the ufo firmware, latest build at time of writing: v0.6

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants