Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/mesh/FloodingRouter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "configuration.h"
#include "mesh-pb-constants.h"
#include "meshUtils.h"
#include "modules/RepeatScalingModule.h"
#include "modules/TextMessageModule.h"
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
#include "modules/TraceRouteModule.h"
Expand Down Expand Up @@ -138,10 +139,16 @@ bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p)
void FloodingRouter::perhapsCancelDupe(const meshtastic_MeshPacket *p)
{
if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && roleAllowsCancelingDupe(p)) {
// cancel rebroadcast of this message *if* there was already one, unless we're a router!
// But only LoRa packets should be able to trigger this.
if (Router::cancelSending(p->from, p->id))
txRelayCanceled++;
// RepeatScalingModule owns the "how many duplicates should we tolerate before giving up"
// decision (and its bookkeeping/logging) - see RepeatScalingModule::shouldCancelDupe. When
// the module is compiled out (MESHTASTIC_EXCLUDE_REPEATSCALING), fall back to the historical
// behavior of cancelling on the first duplicate rather than never cancelling at all.
if (!repeatScalingModule || repeatScalingModule->shouldCancelDupe(p)) {
// cancel rebroadcast of this message *if* there was already one, unless we're a router!
// But only LoRa packets should be able to trigger this.
if (Router::cancelSending(p->from, p->id))
txRelayCanceled++;
}
}
if (config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER_LATE && iface) {
iface->clampToLateRebroadcastWindow(getFrom(p), p->id);
Expand Down
4 changes: 3 additions & 1 deletion src/mesh/FloodingRouter.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ class FloodingRouter : public Router
// the same packet
bool roleAllowsCancelingDupe(const meshtastic_MeshPacket *p);

/* Call when receiving a duplicate packet to check whether we should cancel a packet in the Tx queue */
/* Call when receiving a duplicate packet to check whether we should cancel a packet in the Tx queue.
* The "how many duplicates should we tolerate before giving up" decision (and its bookkeeping/
* logging) is delegated to RepeatScalingModule - see perhapsCancelDupe's implementation. */
void perhapsCancelDupe(const meshtastic_MeshPacket *p);

// Return true if we are a rebroadcaster
Expand Down
10 changes: 10 additions & 0 deletions src/mesh/NextHopRouter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "modules/TrafficManagementModule.h"
#endif
#include "NodeDB.h"
#include "modules/RepeatScalingModule.h"

NextHopRouter::NextHopRouter() {}

Expand Down Expand Up @@ -174,6 +175,15 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
return true;
LOG_INFO("Rebroadcast received message coming from %x", p->relay_node);

// Cache this packet's portnum against (p->from, p->id) so that when a duplicate of
// it arrives from the air later - still encrypted to us, since only this decoded
// copy ever gets decoded - RepeatScalingModule::getDupeCancelThreshold has a
// per-portnum signal to act on instead of always seeing an undecodable packet.
if (repeatScalingModule)
repeatScalingModule->noteScheduled(
p->from, p->id,
p->which_payload_variant == meshtastic_MeshPacket_decoded_tag ? p->decoded.portnum : -1);

// If exhausting hops, force hop_limit = 0 regardless of other logic
if (exhaustHops) {
tosend->hop_limit = 0;
Expand Down
7 changes: 7 additions & 0 deletions src/mesh/RadioLibInterface.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#if !MESHTASTIC_EXCLUDE_BEACON
#include "modules/MeshBeaconModule.h"
#endif
#include "modules/RepeatScalingModule.h"
#include <pb_decode.h>
#include <pb_encode.h>

Expand Down Expand Up @@ -789,6 +790,12 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp)
enableInterrupt(isrTxLevel0);
lastTxStart = millis();
printPacket("Started Tx", txp);
if (repeatScalingModule) {
uint8_t dupesTolerated = repeatScalingModule->getToleratedDupeCount(txp->from, txp->id);
if (dupesTolerated > 0)
LOG_DEBUG("[REPEATSCALE] Transmitting 0x%08x from=0x%08x after tolerating %u duplicate(s)", txp->id,
txp->from, dupesTolerated);
}
#ifdef LED_LORA
digitalWrite(LED_LORA, LED_STATE_ON);
#endif
Expand Down
4 changes: 4 additions & 0 deletions src/modules/HopScalingModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ class HopScalingModule : private concurrency::OSThread
uint16_t getHashSeed() const { return hashSeed; }
/// Expose hashNodeId for tests that need to compute which node IDs pass a given denominator.
uint16_t hashNodeIdPublic(uint32_t nodeId) const { return hashNodeId(nodeId); }
/// Directly inject the last-rollover per-hop counts, bypassing the histogram/rollHour()
/// machinery. For tests (e.g. FloodingRouter's dupe-repeat gate) that only need a specific
/// getLastPerHopCounts() result and don't care how it was derived.
void setLastPerHopCountsForTest(const PerHopCounts &counts) { lastPerHopCounts = counts; }
#endif

protected:
Expand Down
7 changes: 7 additions & 0 deletions src/modules/Modules.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
#if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h"
#endif
#if !MESHTASTIC_EXCLUDE_REPEATSCALING
#include "modules/RepeatScalingModule.h"
#endif
#include "modules/TextMessageModule.h"
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
#include "modules/TraceRouteModule.h"
Expand Down Expand Up @@ -140,6 +143,10 @@ void setupModules()
hopScalingModule = new HopScalingModule();
#endif

#if !MESHTASTIC_EXCLUDE_REPEATSCALING
repeatScalingModule = new RepeatScalingModule();
#endif

#if !MESHTASTIC_EXCLUDE_ADMIN
adminModule = new AdminModule();
#endif
Expand Down
185 changes: 185 additions & 0 deletions src/modules/RepeatScalingModule.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#include "RepeatScalingModule.h"
#include "DebugConfiguration.h"
#include "airtime.h"
#include "configuration.h"
#include "modules/HopScalingModule.h"

RepeatScalingModule *repeatScalingModule;

// Design notes for getDupeCancelThreshold()'s policy (kept here rather than inline):
//
// Historically we cancel our own queued rebroadcast the instant we hear one duplicate from another
// node. This module lets selected packet types instead tolerate a few heard duplicates first,
// trading a little extra airtime for better delivery odds. Thresholds live in a compile-time
// per-portnum switch (not runtime config) because the right values aren't yet settled.
//
// The switch applies uniformly to broadcasts and DMs - NextHopRouter routes duplicates through the
// same FloodingRouter::perhapsCancelDupe -> shouldCancelDupe path - so a DM of a listed portnum
// gets the same tolerance as a broadcast of it, and only ever for a *decodable* DM (a DM not
// addressed to us can't be decoded by us).
//
// When the portnum can't be determined (packet still encrypted, no noteScheduled() cache hit), we
// fall back to a next_hop gate: NO_NEXT_HOP_PREFERENCE (flood-relayed) gets text-message tolerance
// since it has the same uncertain single-path shape; a specific next_hop does not, as delivery
// there is already backed by the sender's end-to-end ACK/retry.
//
// Either way, meshTooBusyForExtraRepeats() forces the threshold back to 1 on a busy/dense mesh.

namespace
{
// Thresholds above which the mesh is busy/dense enough that extra repeats aren't worth the airtime.
constexpr float BUSY_CHANNEL_UTIL_PERCENT = 10.0f;
constexpr float BUSY_AIR_UTIL_TX_PERCENT = 4.0f;
constexpr uint16_t BUSY_DIRECT_ACTIVE_NODES = 10;

// True if channel/air utilization or direct-neighbor density says the mesh is too busy for extra
// repeats. Logs which condition tripped.
bool meshTooBusyForExtraRepeats()
{
if (airTime && airTime->channelUtilizationPercent() > BUSY_CHANNEL_UTIL_PERCENT) {
LOG_DEBUG("[REPEATSCALE] Mesh busy: chUtil=%.1f%% > %.1f%%", airTime->channelUtilizationPercent(),
BUSY_CHANNEL_UTIL_PERCENT);
return true;
}
if (airTime && airTime->utilizationTXPercent() > BUSY_AIR_UTIL_TX_PERCENT) {
LOG_DEBUG("[REPEATSCALE] Mesh busy: airUtilTX=%.1f%% > %.1f%%", airTime->utilizationTXPercent(),
BUSY_AIR_UTIL_TX_PERCENT);
return true;
}
#if HAS_VARIABLE_HOPS
// perHop[0] is HopScalingModule's estimate of active direct (hop_away == 0) neighbors.
if (hopScalingModule && hopScalingModule->getLastPerHopCounts().perHop[0] > BUSY_DIRECT_ACTIVE_NODES) {
LOG_DEBUG("[REPEATSCALE] Mesh busy: directActiveNodes=%u > %u", hopScalingModule->getLastPerHopCounts().perHop[0],
BUSY_DIRECT_ACTIVE_NODES);
return true;
}
#endif
return false;
}
} // namespace

// Per-portnum duplicate-tolerance threshold; see the design notes above for the full rationale.
uint8_t RepeatScalingModule::getDupeCancelThreshold(const meshtastic_MeshPacket *p)
{
const int32_t portnum = resolvePortnum(p);

uint8_t threshold;
if (portnum >= 0) {
switch (portnum) {
case meshtastic_PortNum_TEXT_MESSAGE_APP:
case meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP:
// User-visible chat: no broadcast ACK/retry safety net, so tolerate one heard repeat.
threshold = 2;
break;
default:
threshold = 1;
break;
}
} else {
// Portnum unknowable (undecodable packet): fall back to the plaintext next_hop header.
threshold = (p->next_hop == NO_NEXT_HOP_PREFERENCE) ? 2 : 1;
LOG_DEBUG("[REPEATSCALE] portnum unknown for 0x%08x from=0x%08x; next_hop=0x%x -> threshold=%u", p->id, p->from,
p->next_hop, threshold);
}

// A busy/dense mesh overrides any extra tolerance decided above.
if (threshold > 1 && meshTooBusyForExtraRepeats()) {
LOG_DEBUG("[REPEATSCALE] portnum=%d wanted threshold=%u but mesh is busy; falling back to 1", portnum, threshold);
return 1;
}

return threshold;
}

uint8_t RepeatScalingModule::registerDupeHeard(NodeNum sender, PacketId id)
{
for (auto &entry : dupeCounts) {
if (entry.id == id && entry.sender == sender) {
if (entry.count < UINT8_MAX)
entry.count++;
return entry.count;
}
}
// Not tracked yet: claim the next ring slot, evicting whatever was there.
DupeCountEntry &slot = dupeCounts[dupeCountsNextSlot];
dupeCountsNextSlot = (dupeCountsNextSlot + 1) % DUPE_COUNT_TRACKER_SIZE;
slot.sender = sender;
slot.id = id;
slot.count = 1;
slot.portnum = -1; // no noteScheduled() preceded this
return slot.count;
}

void RepeatScalingModule::clearDupeCount(NodeNum sender, PacketId id)
{
for (auto &entry : dupeCounts) {
if (entry.id == id && entry.sender == sender) {
entry.sender = 0;
entry.id = 0;
entry.count = 0;
entry.portnum = -1;
return;
}
}
}

void RepeatScalingModule::noteScheduled(NodeNum sender, PacketId id, int32_t portnum)
{
for (auto &entry : dupeCounts) {
if (entry.id == id && entry.sender == sender) {
entry.portnum = portnum;
return;
}
}
// Not tracked yet: claim the next ring slot. count starts at 0 (not 1) since scheduling our
// own rebroadcast is not itself a heard duplicate.
DupeCountEntry &slot = dupeCounts[dupeCountsNextSlot];
dupeCountsNextSlot = (dupeCountsNextSlot + 1) % DUPE_COUNT_TRACKER_SIZE;
slot.sender = sender;
slot.id = id;
slot.count = 0;
slot.portnum = portnum;
}

int32_t RepeatScalingModule::lookupNotedPortnum(NodeNum sender, PacketId id) const
{
for (const auto &entry : dupeCounts) {
if (entry.id == id && entry.sender == sender)
return entry.portnum;
}
return -1;
}

int32_t RepeatScalingModule::resolvePortnum(const meshtastic_MeshPacket *p) const
{
return (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) ? p->decoded.portnum
: lookupNotedPortnum(p->from, p->id);
}

uint8_t RepeatScalingModule::getToleratedDupeCount(NodeNum sender, PacketId id) const
{
for (const auto &entry : dupeCounts) {
if (entry.id == id && entry.sender == sender)
return entry.count;
}
return 0;
}

bool RepeatScalingModule::shouldCancelDupe(const meshtastic_MeshPacket *p)
{
const uint8_t threshold = getDupeCancelThreshold(p);
const uint8_t dupesHeard = registerDupeHeard(p->from, p->id);
const int32_t portnum = resolvePortnum(p); // for logging only

if (dupesHeard >= threshold) {
LOG_INFO("[REPEATSCALE] Giving up own rebroadcast of 0x%08x from=0x%08x portnum=%d: heard %u/%u duplicate(s)", p->id,
p->from, portnum, dupesHeard, threshold);
clearDupeCount(p->from, p->id);
return true;
}

LOG_DEBUG("[REPEATSCALE] Tolerated duplicate %u/%u of 0x%08x from=0x%08x portnum=%d: will still transmit our own "
"rebroadcast",
dupesHeard, threshold, p->id, p->from, portnum);
return false;
}
60 changes: 60 additions & 0 deletions src/modules/RepeatScalingModule.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#pragma once

#include "MeshTypes.h"
#include "mesh/mesh-pb-constants.h"

/**
* RepeatScalingModule owns the "how many duplicate rebroadcasts of a packet we ourselves have
* queued to rebroadcast should we tolerate before giving up" decision (see
* FloodingRouter::perhapsCancelDupe, its sole caller).
*
* The historical behavior is to cancel our own queued rebroadcast as soon as we hear the very
* first duplicate from another node. This module allows some packet types (see
* getDupeCancelThreshold in RepeatScalingModule.cpp) to instead tolerate a configurable number of
* heard duplicates first, trading a little extra airtime for better delivery odds - unless the
* mesh is already busy/dense, in which case it always falls back to the historical behavior.
*/
class RepeatScalingModule
{
public:
RepeatScalingModule() = default;
virtual ~RepeatScalingModule() = default;

// Note a heard duplicate for (p->from, p->id); returns true (and clears tracking) once the
// per-portnum threshold is reached, meaning the caller should cancel its own rebroadcast.
// Virtual so FloodingRouter role-gating tests can substitute a double.
virtual bool shouldCancelDupe(const meshtastic_MeshPacket *p);

// Cache the portnum of a rebroadcast we've scheduled, so later encrypted duplicates of it can
// still be classified by getDupeCancelThreshold(). Pass -1 if it couldn't be decoded.
void noteScheduled(NodeNum sender, PacketId id, int32_t portnum);

// Duplicates heard (and tolerated) so far for (sender, id), or 0. For logging at TX time.
uint8_t getToleratedDupeCount(NodeNum sender, PacketId id) const;

protected:
// Duplicates to tolerate before cancelling our own rebroadcast. Virtual so tests can inject a
// threshold without relying on a real portnum case.
virtual uint8_t getDupeCancelThreshold(const meshtastic_MeshPacket *p);

// Ephemeral ring buffer of per-(sender, id) heard-duplicate counts (not persistent state).
uint8_t registerDupeHeard(NodeNum sender, PacketId id);
void clearDupeCount(NodeNum sender, PacketId id);
int32_t lookupNotedPortnum(NodeNum sender, PacketId id) const;

private:
// Decoded portnum if available, else the one cached by noteScheduled() (or -1).
int32_t resolvePortnum(const meshtastic_MeshPacket *p) const;

static constexpr uint8_t DUPE_COUNT_TRACKER_SIZE = 8;
struct DupeCountEntry {
NodeNum sender = 0;
PacketId id = 0;
uint8_t count = 0;
int32_t portnum = -1;
};
DupeCountEntry dupeCounts[DUPE_COUNT_TRACKER_SIZE];
uint8_t dupeCountsNextSlot = 0;
};

extern RepeatScalingModule *repeatScalingModule;
2 changes: 1 addition & 1 deletion test/native-suite-count
Original file line number Diff line number Diff line change
@@ -1 +1 @@
30
32
Loading
Loading