feat(repeater): overhear suppression — cancel queued retransmit if a peer relays first

When enabled (Settings > Radio > Suppress dup), a received flood packet whose
hash matches one still waiting in the outbound queue cancels that queued
retransmit: another node already relayed it, so this node stays quiet. Cuts
redundant airtime in dense meshes; pairs with Yield (longer delay = wider window
to overhear a peer first).

- Dispatcher: wantsOverhearSuppress() hook (default off) + suppressQueuedDuplicate()
  scan over the outbound queue by packet hash; onRetransmitCancelled() hook.
- Mesh: overrides onRetransmitCancelled() to back out n_forwarded so the count
  reflects what actually goes on air. resetStats() already covered.
- Flood-only and hash-matched (payload hash ignores path for non-TRACE, so our
  copy and the peer's relayed copy hash equal). Own sends can't be overheard
  before their first TX, so suppression only ever cancels forwards.
- New repeat_suppress_dup pref, schema sentinel 0xC0DE000F, stray-byte clamp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-18 23:51:01 +02:00
parent e213ff6efb
commit d790f7e5cd
7 changed files with 59 additions and 2 deletions

View File

@@ -343,12 +343,14 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.repeat_max_hops, sizeof(_prefs.repeat_max_hops));
rd(&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost));
rd(&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr));
rd(&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup));
if (_prefs.repeat_skip_adverts > 1) _prefs.repeat_skip_adverts = 0;
if (_prefs.repeat_max_hops > 64) _prefs.repeat_max_hops = 0;
if (_prefs.repeat_delay_boost > 8) _prefs.repeat_delay_boost = 0;
if (_prefs.repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED &&
(_prefs.repeat_min_snr < -30 || _prefs.repeat_min_snr > 20))
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED;
if (_prefs.repeat_suppress_dup > 1) _prefs.repeat_suppress_dup = 0;
// → 0xC0DE000B: append bot_commands_enabled + quiet-hours. Older files leave
// stray bytes here; clamp so upgraders fall back to off / no quiet hours.
if (_prefs.bot_commands_enabled > 1) _prefs.bot_commands_enabled = 0;
@@ -510,6 +512,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.repeat_max_hops, sizeof(_prefs.repeat_max_hops));
file.write((uint8_t *)&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost));
file.write((uint8_t *)&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr));
file.write((uint8_t *)&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -153,6 +153,9 @@ protected:
bool filterRecvFloodPacket(mesh::Packet* packet) override;
bool allowPacketForward(const mesh::Packet* packet) override;
bool isRepeatLooped(const mesh::Packet* packet) const;
// Overhear suppression only makes sense while repeating; gated behind its own
// opt-in pref (Settings > Radio > Suppress dup).
bool wantsOverhearSuppress() const override { return _prefs.client_repeat && _prefs.repeat_suppress_dup; }
void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;

View File

@@ -164,17 +164,20 @@ struct NodePrefs { // persisted to file
// fixed repeaters. Effective delay = base * (1 + repeat_delay_boost). 0 = off.
// repeat_min_snr: drop a flood packet received below this SNR (dB), so marginal
// fringe traffic isn't re-flooded. REPEAT_SNR_DISABLED (-128) = off.
// repeat_suppress_dup: 1 = cancel a queued retransmit when the same flood is
// overheard from another node first (less redundant airtime in dense mesh).
uint8_t repeat_skip_adverts;
uint8_t repeat_max_hops;
uint8_t repeat_delay_boost;
int8_t repeat_min_snr;
static const int8_t REPEAT_SNR_DISABLED = -128;
uint8_t repeat_suppress_dup;
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept).
// High 24 bits identify the file format; low byte is the schema revision.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE000E;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE000F;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage

View File

@@ -63,6 +63,7 @@ class SettingsScreen : public UIScreen {
RPT_MAX_HOPS,
RPT_DELAY,
RPT_MIN_SNR,
RPT_SUPPRESS,
POWER_SAVE,
TX_APC,
// System section
@@ -290,7 +291,8 @@ class SettingsScreen : public UIScreen {
}
bool isRepeaterSubItem(int item) const {
return item == RPT_SKIP_ADV || item == RPT_MAX_HOPS || item == RPT_DELAY || item == RPT_MIN_SNR;
return item == RPT_SKIP_ADV || item == RPT_MAX_HOPS || item == RPT_DELAY
|| item == RPT_MIN_SNR || item == RPT_SUPPRESS;
}
bool isHomePage(int item) const {
@@ -648,6 +650,10 @@ class SettingsScreen : public UIScreen {
else strcpy(buf, "Off");
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == RPT_SUPPRESS) {
display.print(" Suppress dup");
display.setCursor(valCol(display), y);
display.print((p && p->repeat_suppress_dup) ? "ON" : "OFF");
} else if (item == POWER_SAVE) {
display.print("Pwr save");
display.setCursor(valCol(display), y);
@@ -1073,6 +1079,11 @@ public:
_dirty = true; return true;
}
}
if (_selected == RPT_SUPPRESS && p && (left || right || enter)) {
p->repeat_suppress_dup ^= 1;
_dirty = true;
return true;
}
if (_selected == POWER_SAVE && p && (left || right || enter)) {
p->rx_powersave ^= 1;
_task->applyPowerSave();