diff --git a/src/mesh/FloodingRouter.cpp b/src/mesh/FloodingRouter.cpp index 13f98299f93..378d8401fbc 100644 --- a/src/mesh/FloodingRouter.cpp +++ b/src/mesh/FloodingRouter.cpp @@ -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" @@ -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); diff --git a/src/mesh/FloodingRouter.h b/src/mesh/FloodingRouter.h index e8a2e9685fa..16231781803 100644 --- a/src/mesh/FloodingRouter.h +++ b/src/mesh/FloodingRouter.h @@ -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 diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index f33de779cc0..61f605967af 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -8,6 +8,7 @@ #include "modules/TrafficManagementModule.h" #endif #include "NodeDB.h" +#include "modules/RepeatScalingModule.h" NextHopRouter::NextHopRouter() {} @@ -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; diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 5a9b292cda8..7efd912ce1e 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -11,6 +11,7 @@ #if !MESHTASTIC_EXCLUDE_BEACON #include "modules/MeshBeaconModule.h" #endif +#include "modules/RepeatScalingModule.h" #include #include @@ -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 diff --git a/src/modules/HopScalingModule.h b/src/modules/HopScalingModule.h index 70d87428a52..b16130f8656 100644 --- a/src/modules/HopScalingModule.h +++ b/src/modules/HopScalingModule.h @@ -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: diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 546651c5bc2..d7712d2bcb2 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -50,6 +50,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" @@ -143,6 +146,10 @@ void setupModules() hopScalingModule = new HopScalingModule(); #endif +#if !MESHTASTIC_EXCLUDE_REPEATSCALING + repeatScalingModule = new RepeatScalingModule(); +#endif + #if !MESHTASTIC_EXCLUDE_ADMIN adminModule = new AdminModule(); #endif diff --git a/src/modules/RepeatScalingModule.cpp b/src/modules/RepeatScalingModule.cpp new file mode 100644 index 00000000000..d7d7f7f8dc3 --- /dev/null +++ b/src/modules/RepeatScalingModule.cpp @@ -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; +} diff --git a/src/modules/RepeatScalingModule.h b/src/modules/RepeatScalingModule.h new file mode 100644 index 00000000000..4dc6bd5154c --- /dev/null +++ b/src/modules/RepeatScalingModule.h @@ -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; diff --git a/test/native-suite-count b/test/native-suite-count index 8f92bfdd497..a2720097dcc 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -35 +39 diff --git a/test/test_flooding_router/test_main.cpp b/test/test_flooding_router/test_main.cpp new file mode 100644 index 00000000000..ad866f7d01d --- /dev/null +++ b/test/test_flooding_router/test_main.cpp @@ -0,0 +1,146 @@ +// Unit tests for FloodingRouter::perhapsCancelDupe's role-gating and its delegation to +// RepeatScalingModule (the "how many duplicates should we tolerate before giving up" decision +// and its bookkeeping/logging now lives there - see test/test_repeat_scaling_module). +// +// perhapsCancelDupe() is safe to call directly here even with no RadioInterface: Router:: +// cancelSending() is iface-null-safe (just returns false), so the queue-cancellation side effect +// is a no-op - what we can and do observe is whether/how many times RepeatScalingModule was +// consulted at all. + +#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc. +#include "TestUtil.h" +#include + +#include "configuration.h" +#include "mesh/FloodingRouter.h" +#include "modules/RepeatScalingModule.h" + +static constexpr NodeNum kSender1 = 0x000005AB; +static constexpr PacketId kId1 = 0x1001; + +// --------------------------------------------------------------------------- +// Test shim - re-exposes the protected perhapsCancelDupe. Nulls cryptLock before it's rebuilt so +// the Router base can be (re)constructed (same pattern as test_nexthop_routing's +// NextHopRouterTestShim / test_mqtt's MockRouter). +// --------------------------------------------------------------------------- +class FloodingRouterTestShim : public FloodingRouter +{ + public: + FloodingRouterTestShim() : FloodingRouter() + { + delete cryptLock; + cryptLock = nullptr; + } + + using FloodingRouter::perhapsCancelDupe; + + protected: + // FloodingRouter::perhapsRebroadcast is pure virtual; not exercised by these tests. + bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override { return false; } +}; + +// Test double standing in for the real RepeatScalingModule: records how many times +// FloodingRouter consulted it, and returns a fixed, test-controlled decision. +class CountingRepeatScalingModule : public RepeatScalingModule +{ + public: + int callCount = 0; + bool decision = false; + bool shouldCancelDupe(const meshtastic_MeshPacket *p) override + { + callCount++; + return decision; + } +}; + +static FloodingRouterTestShim *shim = nullptr; +static CountingRepeatScalingModule *counting = nullptr; + +static meshtastic_MeshPacket makeDupePacket(NodeNum from, PacketId id) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.id = id; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + return p; +} + +void setUp(void) +{ + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + counting->callCount = 0; + counting->decision = false; + repeatScalingModule = counting; +} + +void tearDown(void) {} + +// =========================================================================== +// perhapsCancelDupe: role-gating and delegation to RepeatScalingModule +// =========================================================================== + +void test_perhapsCancelDupe_consults_repeatScalingModule_for_client_role(void) +{ + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + shim->perhapsCancelDupe(&p); + TEST_ASSERT_EQUAL_INT(1, counting->callCount); +} + +void test_perhapsCancelDupe_router_role_never_consults_repeatScalingModule(void) +{ + // ROUTER never cancels its own rebroadcast (roleAllowsCancelingDupe() gates it out entirely), + // so it should also never bother asking RepeatScalingModule about it. + config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + + shim->perhapsCancelDupe(&p); + shim->perhapsCancelDupe(&p); + + TEST_ASSERT_EQUAL_INT(0, counting->callCount); +} + +void test_perhapsCancelDupe_non_lora_transport_never_consults_repeatScalingModule(void) +{ + // Only LoRa-transport packets can trigger a cancel; other transports should never be routed + // through RepeatScalingModule. + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_INTERNAL; + + shim->perhapsCancelDupe(&p); + + TEST_ASSERT_EQUAL_INT(0, counting->callCount); +} + +void test_perhapsCancelDupe_does_not_crash_when_repeatScalingModule_says_cancel(void) +{ + // With no RadioInterface, Router::cancelSending() is a safe no-op - this just confirms the + // true-branch (attempting the cancel) doesn't crash and still only consults the module once. + counting->decision = true; + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + + shim->perhapsCancelDupe(&p); + + TEST_ASSERT_EQUAL_INT(1, counting->callCount); +} + +// =========================================================================== + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + shim = new FloodingRouterTestShim(); + counting = new CountingRepeatScalingModule(); + + printf("\n=== perhapsCancelDupe (role-gating + delegation) ===\n"); + RUN_TEST(test_perhapsCancelDupe_consults_repeatScalingModule_for_client_role); + RUN_TEST(test_perhapsCancelDupe_router_role_never_consults_repeatScalingModule); + RUN_TEST(test_perhapsCancelDupe_non_lora_transport_never_consults_repeatScalingModule); + RUN_TEST(test_perhapsCancelDupe_does_not_crash_when_repeatScalingModule_says_cancel); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_repeat_scaling_module/test_main.cpp b/test/test_repeat_scaling_module/test_main.cpp new file mode 100644 index 00000000000..c58d92072e6 --- /dev/null +++ b/test/test_repeat_scaling_module/test_main.cpp @@ -0,0 +1,596 @@ +// Unit tests for RepeatScalingModule: its ring-buffer dupe tracking, per-portnum threshold, +// busy-mesh gate, and the shouldCancelDupe integration of all three. + +#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc. +#include "TestUtil.h" +#include + +#include "airtime.h" +#include "configuration.h" +#include "modules/HopScalingModule.h" +#include "modules/RepeatScalingModule.h" + +static constexpr NodeNum kSender1 = 0x000005AB; +static constexpr NodeNum kSender2 = 0x000006CD; +static constexpr PacketId kId1 = 0x1001; +static constexpr PacketId kId2 = 0x2002; + +// Re-exposes the protected helpers and overrides getDupeCancelThreshold so threshold-gating can be +// tested without depending on a real portnum case. +class RepeatScalingModuleTestShim : public RepeatScalingModule +{ + public: + using RepeatScalingModule::clearDupeCount; + using RepeatScalingModule::registerDupeHeard; + + uint8_t testThreshold = 1; + uint8_t getDupeCancelThreshold(const meshtastic_MeshPacket *p) override { return testThreshold; } +}; + +// A second, minimal shim that does NOT override getDupeCancelThreshold, for testing the real +// compile-time default implementation directly. +class RepeatScalingModulePlainShim : public RepeatScalingModule +{ + public: + using RepeatScalingModule::clearDupeCount; + using RepeatScalingModule::getDupeCancelThreshold; + using RepeatScalingModule::registerDupeHeard; +}; + +static RepeatScalingModuleTestShim *shim = nullptr; +static RepeatScalingModulePlainShim *plainShim = nullptr; + +// Scoped helpers that install a global airTime/hopScalingModule reporting a busy condition for the +// enclosing scope, restoring the previous pointer on destruction (mirrors ScopedBusyAirTime). +class ScopedChannelUtil +{ + public: + explicit ScopedChannelUtil(float percent) : previous(airTime) + { + // channelUtilizationPercent() == sum(channelUtilization[]) / (PERIODS * 10 * 1000) * 100 + busy.channelUtilization[0] = static_cast(percent / 100.0f * CHANNEL_UTILIZATION_PERIODS * 10 * 1000); + airTime = &busy; + } + ~ScopedChannelUtil() { airTime = previous; } + + private: + AirTime busy; + AirTime *previous; +}; + +class ScopedAirUtilTx +{ + public: + explicit ScopedAirUtilTx(float percent) : previous(airTime) + { + // utilizationTXPercent() == sum(utilizationTX[]) / MS_IN_HOUR * 100 + busy.utilizationTX[0] = static_cast(percent / 100.0f * MS_IN_HOUR); + airTime = &busy; + } + ~ScopedAirUtilTx() { airTime = previous; } + + private: + AirTime busy; + AirTime *previous; +}; + +#if HAS_VARIABLE_HOPS +class ScopedDirectActiveNodes +{ + public: + explicit ScopedDirectActiveNodes(uint16_t count) : previous(hopScalingModule) + { + HopScalingModule::PerHopCounts counts; + counts.perHop[0] = count; + busy.setLastPerHopCountsForTest(counts); + hopScalingModule = &busy; + } + ~ScopedDirectActiveNodes() { hopScalingModule = previous; } + + private: + HopScalingModule busy; + HopScalingModule *previous; +}; +#endif + +static meshtastic_MeshPacket makeDupePacket(NodeNum from, PacketId id) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.id = id; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; // undecodable by default + return p; +} + +void setUp(void) +{ + shim->testThreshold = 1; + // Clear any tracking state a previous test may have left behind for our fixed test keys. + shim->clearDupeCount(kSender1, kId1); + shim->clearDupeCount(kSender2, kId2); + plainShim->clearDupeCount(kSender1, kId1); + plainShim->clearDupeCount(kSender2, kId2); +} + +void tearDown(void) {} + +// =========================================================================== +// Group 1 - registerDupeHeard / clearDupeCount (the ring buffer) +// =========================================================================== + +void test_registerDupeHeard_starts_at_one(void) +{ + TEST_ASSERT_EQUAL_UINT8(1, shim->registerDupeHeard(kSender1, kId1)); +} + +void test_registerDupeHeard_increments_on_repeat(void) +{ + TEST_ASSERT_EQUAL_UINT8(1, shim->registerDupeHeard(kSender1, kId1)); + TEST_ASSERT_EQUAL_UINT8(2, shim->registerDupeHeard(kSender1, kId1)); + TEST_ASSERT_EQUAL_UINT8(3, shim->registerDupeHeard(kSender1, kId1)); +} + +void test_registerDupeHeard_independent_per_packet(void) +{ + TEST_ASSERT_EQUAL_UINT8(1, shim->registerDupeHeard(kSender1, kId1)); + TEST_ASSERT_EQUAL_UINT8(2, shim->registerDupeHeard(kSender1, kId1)); + // A different (sender, id) pair tracks its own independent count. + TEST_ASSERT_EQUAL_UINT8(1, shim->registerDupeHeard(kSender2, kId2)); + TEST_ASSERT_EQUAL_UINT8(2, shim->registerDupeHeard(kSender2, kId2)); + // The first pair's count is untouched by the second's activity. + TEST_ASSERT_EQUAL_UINT8(3, shim->registerDupeHeard(kSender1, kId1)); +} + +void test_clearDupeCount_resets_tracking(void) +{ + shim->registerDupeHeard(kSender1, kId1); + shim->registerDupeHeard(kSender1, kId1); + shim->clearDupeCount(kSender1, kId1); + // After clearing, the next duplicate heard for this packet starts fresh at 1. + TEST_ASSERT_EQUAL_UINT8(1, shim->registerDupeHeard(kSender1, kId1)); +} + +void test_clearDupeCount_of_untracked_packet_is_noop(void) +{ + // Clearing a (sender, id) that was never registered must not crash or disturb other entries. + shim->registerDupeHeard(kSender1, kId1); + shim->clearDupeCount(0xFFFFFFFF, 0xFFFFFFFF); + TEST_ASSERT_EQUAL_UINT8(2, shim->registerDupeHeard(kSender1, kId1)); +} + +void test_registerDupeHeard_ring_eviction_does_not_crash(void) +{ + // DUPE_COUNT_TRACKER_SIZE is 8 (see RepeatScalingModule.h); track more distinct packets than + // that and confirm the ring buffer just evicts older entries rather than misbehaving. + for (uint32_t i = 0; i < 20; i++) { + uint8_t count = shim->registerDupeHeard(kSender1, 0x10000 + i); + TEST_ASSERT_EQUAL_UINT8(1, count); // each is a fresh (sender, id) pair + } +} + +// =========================================================================== +// Group 2 - getDupeCancelThreshold (compile-time per-portnum default) +// =========================================================================== + +void test_default_threshold_is_one_for_undecoded_directed_packet(void) +{ + // Portnum unknowable (encrypted, no noteScheduled cache hit) falls back to the next_hop gate + // (see Group 2b below) - a directed route (specific next_hop byte, not flooding) keeps the + // historical default of 1. + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.next_hop = 0x42; // a specific, directed relay byte - not NO_NEXT_HOP_PREFERENCE + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +void test_default_threshold_is_one_for_unlisted_portnum(void) +{ + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_POSITION_APP; // no case for this portnum + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +void test_text_message_threshold_is_two(void) +{ + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&p)); +} + +void test_text_message_compressed_threshold_is_two(void) +{ + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP; + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&p)); +} + +void test_text_message_threshold_applies_regardless_of_addressing(void) +{ + // getDupeCancelThreshold only inspects portnum, not p->to - so a DM (to a specific node) + // gets the same threshold as a broadcast for the same portnum. This is what lets + // NextHopRouter::shouldFilterReceived's call to the same inherited + // FloodingRouter::perhapsCancelDupe -> RepeatScalingModule::shouldCancelDupe path extend the + // tolerance to DM text messages too, with no separate addressing-based gate. + meshtastic_MeshPacket dm = makeDupePacket(kSender1, kId1); + dm.to = 0x12345678; // a specific node, not NODENUM_BROADCAST + dm.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + dm.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&dm)); +} + +// =========================================================================== +// Group 2b - next_hop gate for undecodable packets (e.g. a relayed PKI DM not addressed to us). +// next_hop is a plaintext header field, so it's visible even when portnum isn't. +// =========================================================================== + +// A route-less DM (specific node, NO_NEXT_HOP_PREFERENCE) - the shape of a relayed PKI DM we can't +// decrypt, carrying the same next_hop as any broadcast. +static meshtastic_MeshPacket makeUndecodableFloodedDm(NodeNum from, PacketId id) +{ + meshtastic_MeshPacket p = makeDupePacket(from, id); + p.to = 0x12345678; // a specific node, not NODENUM_BROADCAST + p.next_hop = NO_NEXT_HOP_PREFERENCE; + return p; +} + +void test_undecodable_flooded_packet_gets_text_message_tolerance(void) +{ + // NO_NEXT_HOP_PREFERENCE means this packet is being flood-relayed (every broadcast, or a DM + // with no directed route known yet) - treated the same as a decoded text message. + meshtastic_MeshPacket p = makeUndecodableFloodedDm(kSender1, kId1); + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&p)); +} + +void test_undecodable_directed_packet_keeps_default_threshold(void) +{ + // A specific next_hop byte means a directed route is already known - delivery is backed by + // the sender's end-to-end ACK/retry, so no extra tolerance. + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.next_hop = 0x42; + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +void test_next_hop_gate_is_overridden_by_a_cached_portnum(void) +{ + // A cache hit (see noteScheduled()) always takes priority over the next_hop gate, even for a + // flood-relayed packet whose real, known portnum doesn't warrant extra tolerance. + plainShim->noteScheduled(kSender1, kId1, meshtastic_PortNum_POSITION_APP); + meshtastic_MeshPacket p = makeUndecodableFloodedDm(kSender1, kId1); + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +void test_next_hop_gate_suppressed_by_busy_channel_util(void) +{ + // Same busy-mesh override that applies to the portnum table also applies to the next_hop gate. + ScopedChannelUtil busy(11.0f); // above the 10% BUSY_CHANNEL_UTIL_PERCENT gate + meshtastic_MeshPacket p = makeUndecodableFloodedDm(kSender1, kId1); + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +void test_next_hop_gate_tolerated_when_channel_util_low(void) +{ + ScopedChannelUtil notBusy(10.0f); // exactly at the threshold, not above it + meshtastic_MeshPacket p = makeUndecodableFloodedDm(kSender1, kId1); + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&p)); +} + +void test_next_hop_gate_suppressed_by_busy_air_util_tx(void) +{ + ScopedAirUtilTx busy(5.0f); // above the 4% BUSY_AIR_UTIL_TX_PERCENT gate + meshtastic_MeshPacket p = makeUndecodableFloodedDm(kSender1, kId1); + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +void test_next_hop_gate_tolerated_when_air_util_tx_low(void) +{ + ScopedAirUtilTx notBusy(4.0f); // exactly at the threshold, not above it + meshtastic_MeshPacket p = makeUndecodableFloodedDm(kSender1, kId1); + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&p)); +} + +#if HAS_VARIABLE_HOPS +void test_next_hop_gate_suppressed_by_busy_direct_node_count(void) +{ + ScopedDirectActiveNodes busy(11); // above the 10-node BUSY_DIRECT_ACTIVE_NODES gate + meshtastic_MeshPacket p = makeUndecodableFloodedDm(kSender1, kId1); + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +void test_next_hop_gate_tolerated_when_direct_node_count_low(void) +{ + ScopedDirectActiveNodes notBusy(10); // exactly at the threshold, not above it + meshtastic_MeshPacket p = makeUndecodableFloodedDm(kSender1, kId1); + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&p)); +} +#endif + +// =========================================================================== +// Group 3 - meshTooBusyForExtraRepeats gate (channel/air util, direct-neighbor density) +// =========================================================================== + +void test_busy_channel_util_suppresses_text_message_threshold(void) +{ + ScopedChannelUtil busy(11.0f); // above the 10% BUSY_CHANNEL_UTIL_PERCENT gate + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +void test_channel_util_at_gate_does_not_suppress(void) +{ + ScopedChannelUtil notBusy(10.0f); // exactly at the threshold, not above it + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&p)); +} + +void test_busy_air_util_tx_suppresses_text_message_threshold(void) +{ + ScopedAirUtilTx busy(5.0f); // above the 4% BUSY_AIR_UTIL_TX_PERCENT gate + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +void test_air_util_tx_at_gate_does_not_suppress(void) +{ + ScopedAirUtilTx notBusy(4.0f); // exactly at the threshold, not above it + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&p)); +} + +#if HAS_VARIABLE_HOPS +void test_busy_direct_node_count_suppresses_text_message_threshold(void) +{ + ScopedDirectActiveNodes busy(11); // above the 10-node BUSY_DIRECT_ACTIVE_NODES gate + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +void test_direct_node_count_at_gate_does_not_suppress(void) +{ + ScopedDirectActiveNodes notBusy(10); // exactly at the threshold, not above it + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&p)); +} +#endif + +void test_busy_mesh_does_not_affect_already_unextended_portnums(void) +{ + // A portnum whose threshold is already 1 (the default floor) has nothing for the busy-mesh + // gate to suppress - confirm it stays at 1, not some other value, while busy. + ScopedChannelUtil busy(50.0f); + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_POSITION_APP; + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&p)); +} + +// =========================================================================== +// Group 4 - shouldCancelDupe (integration: threshold gates the cancel + clears tracking) +// =========================================================================== + +void test_shouldCancelDupe_clears_tracking_once_threshold_reached(void) +{ + shim->testThreshold = 1; // historical behavior: give up (and clear) on the very first duplicate + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + + TEST_ASSERT_TRUE(shim->shouldCancelDupe(&p)); + + // Threshold (1) was reached on the first call, so tracking was cleared - the next duplicate + // heard for this packet starts a fresh count, rather than continuing to accumulate. + TEST_ASSERT_EQUAL_UINT8(1, shim->registerDupeHeard(kSender1, kId1)); +} + +void test_shouldCancelDupe_waits_for_configured_repeats(void) +{ + shim->testThreshold = 3; // this node should tolerate hearing 2 repeats before giving up + meshtastic_MeshPacket p = makeDupePacket(kSender1, kId1); + + TEST_ASSERT_FALSE(shim->shouldCancelDupe(&p)); // 1st duplicate heard -> internal count 1, below threshold + + // Directly registering one more duplicate both (a) advances the count to 2 for the next + // step, and (b) proves tracking was NOT cleared by the call above: a cleared entry would + // restart at 1, not continue to 2. + TEST_ASSERT_EQUAL_UINT8(2, shim->registerDupeHeard(kSender1, kId1)); + + TEST_ASSERT_TRUE(shim->shouldCancelDupe(&p)); // 3rd duplicate heard -> internal count 3, reaches threshold + // Threshold reached inside the call above, so tracking was cleared - starts fresh at 1. + TEST_ASSERT_EQUAL_UINT8(1, shim->registerDupeHeard(kSender1, kId1)); +} + +// =========================================================================== +// Group 5 - noteScheduled + encrypted duplicates (as heard for real over the air). +// Unlike Group 2 (which builds already-decoded packets), these start from an encrypted packet and +// rely solely on noteScheduled() for portnum visibility - the real production path. +// =========================================================================== + +void test_encrypted_dupe_uses_portnum_noted_when_we_scheduled_our_own_rebroadcast(void) +{ + // Simulates NextHopRouter::perhapsRebroadcast having decoded and scheduled our own copy. + plainShim->noteScheduled(kSender1, kId1, meshtastic_PortNum_TEXT_MESSAGE_APP); + + // The duplicate heard over the air is still encrypted - this is the packet shape + // shouldCancelDupe actually sees in production. Broadcast-addressed (to left at its + // zero-init default, distinct from a DM's specific-node "to" below). + meshtastic_MeshPacket dupe = makeDupePacket(kSender1, kId1); + TEST_ASSERT_TRUE(dupe.which_payload_variant == meshtastic_MeshPacket_encrypted_tag); + + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&dupe)); +} + +void test_encrypted_dm_dupe_uses_portnum_noted_when_we_scheduled_our_own_rebroadcast(void) +{ + // Same as the broadcast case above, but addressed as a DM (see the module-level comment in + // RepeatScalingModule.cpp: NextHopRouter::shouldFilterReceived's dupe path reuses the same + // inherited FloodingRouter::perhapsCancelDupe -> shouldCancelDupe call, so a DM's encrypted + // duplicate must get the same noteScheduled-cached tolerance a broadcast's does). + plainShim->noteScheduled(kSender1, kId1, meshtastic_PortNum_TEXT_MESSAGE_APP); + + meshtastic_MeshPacket dupe = makeDupePacket(kSender1, kId1); + dupe.to = 0x12345678; // a specific node, not NODENUM_BROADCAST + TEST_ASSERT_TRUE(dupe.which_payload_variant == meshtastic_MeshPacket_encrypted_tag); + + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&dupe)); +} + +void test_encrypted_dupe_without_noteScheduled_falls_back_to_next_hop_gate(void) +{ + // We never scheduled our own rebroadcast of this (sender, id) - e.g. we're not the assigned + // next hop, or we never decoded it - so there is no cached portnum at all. This is the + // pre-fix behavior for every encrypted duplicate; now it falls to the next_hop gate (Group 2b) + // instead of unconditionally defaulting to 1 - pin next_hop explicitly so this test isolates + // "no cache" from that gate's own behavior. + meshtastic_MeshPacket dupe = makeDupePacket(kSender2, kId2); + dupe.next_hop = 0x42; // a specific, directed relay byte - not NO_NEXT_HOP_PREFERENCE + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&dupe)); +} + +void test_noteScheduled_is_keyed_per_sender_and_id(void) +{ + plainShim->noteScheduled(kSender1, kId1, meshtastic_PortNum_TEXT_MESSAGE_APP); + + // A different (sender, id) pair must not pick up kSender1/kId1's noted portnum. Pin next_hop + // so the lack of a cache hit (not the next_hop gate) is what's under test here. + meshtastic_MeshPacket unrelated = makeDupePacket(kSender2, kId2); + unrelated.next_hop = 0x42; + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&unrelated)); + + meshtastic_MeshPacket dupe = makeDupePacket(kSender1, kId1); + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&dupe)); +} + +void test_noteScheduled_updates_existing_entry(void) +{ + // registerDupeHeard may have already claimed a ring slot for this (sender, id) - e.g. we + // heard a duplicate before deciding to (re)schedule our own rebroadcast of it (hop-limit + // upgrade). noteScheduled must update that slot's portnum in place, not just no-op. + plainShim->registerDupeHeard(kSender1, kId1); + plainShim->noteScheduled(kSender1, kId1, meshtastic_PortNum_TEXT_MESSAGE_APP); + + meshtastic_MeshPacket dupe = makeDupePacket(kSender1, kId1); + TEST_ASSERT_EQUAL_UINT8(2, plainShim->getDupeCancelThreshold(&dupe)); +} + +void test_clearDupeCount_also_forgets_noted_portnum(void) +{ + plainShim->noteScheduled(kSender1, kId1, meshtastic_PortNum_TEXT_MESSAGE_APP); + plainShim->clearDupeCount(kSender1, kId1); + + // Once cleared (e.g. shouldCancelDupe already gave up on this packet), a stale portnum must + // not leak into whatever unrelated packet reuses this ring slot next. Pin next_hop so the + // cleared cache (not the next_hop gate) is what's under test here. + meshtastic_MeshPacket dupe = makeDupePacket(kSender1, kId1); + dupe.next_hop = 0x42; + TEST_ASSERT_EQUAL_UINT8(1, plainShim->getDupeCancelThreshold(&dupe)); +} + +void test_shouldCancelDupe_end_to_end_with_encrypted_duplicate(void) +{ + // Full path through the real (non-overridden) threshold logic, starting from an encrypted + // duplicate: this is the regression test for the "portnum=-1" bug. Before the noteScheduled + // cache existed, this packet always fell back to threshold 1 and cancelled on the very first + // heard duplicate, regardless of portnum. + RepeatScalingModulePlainShim endToEnd; + endToEnd.noteScheduled(kSender1, kId1, meshtastic_PortNum_TEXT_MESSAGE_APP); + + meshtastic_MeshPacket dupe = makeDupePacket(kSender1, kId1); + TEST_ASSERT_FALSE(endToEnd.shouldCancelDupe(&dupe)); // 1st duplicate: tolerated, threshold is 2 + TEST_ASSERT_TRUE(endToEnd.shouldCancelDupe(&dupe)); // 2nd duplicate: threshold reached, cancel +} + +void test_shouldCancelDupe_end_to_end_with_encrypted_dm_duplicate(void) +{ + // Same end-to-end regression test as above, but for a DM (to a specific node rather than + // NODENUM_BROADCAST). Confirms the noteScheduled cache extends the same tolerance to a DM's + // encrypted duplicate that it does to a broadcast's - matching how NextHopRouter routes both + // through the same inherited shouldCancelDupe call with no separate addressing-based gate. + RepeatScalingModulePlainShim endToEnd; + endToEnd.noteScheduled(kSender1, kId1, meshtastic_PortNum_TEXT_MESSAGE_APP); + + meshtastic_MeshPacket dupe = makeDupePacket(kSender1, kId1); + dupe.to = 0x12345678; // a specific node, not NODENUM_BROADCAST + TEST_ASSERT_FALSE(endToEnd.shouldCancelDupe(&dupe)); // 1st duplicate: tolerated, threshold is 2 + TEST_ASSERT_TRUE(endToEnd.shouldCancelDupe(&dupe)); // 2nd duplicate: threshold reached, cancel +} + +// =========================================================================== + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + shim = new RepeatScalingModuleTestShim(); + plainShim = new RepeatScalingModulePlainShim(); + + printf("\n=== registerDupeHeard / clearDupeCount (ring buffer) ===\n"); + RUN_TEST(test_registerDupeHeard_starts_at_one); + RUN_TEST(test_registerDupeHeard_increments_on_repeat); + RUN_TEST(test_registerDupeHeard_independent_per_packet); + RUN_TEST(test_clearDupeCount_resets_tracking); + RUN_TEST(test_clearDupeCount_of_untracked_packet_is_noop); + RUN_TEST(test_registerDupeHeard_ring_eviction_does_not_crash); + + printf("\n=== getDupeCancelThreshold (compile-time default) ===\n"); + RUN_TEST(test_default_threshold_is_one_for_undecoded_directed_packet); + RUN_TEST(test_default_threshold_is_one_for_unlisted_portnum); + RUN_TEST(test_text_message_threshold_is_two); + RUN_TEST(test_text_message_compressed_threshold_is_two); + RUN_TEST(test_text_message_threshold_applies_regardless_of_addressing); + + printf("\n=== next_hop gate (undecodable packets, e.g. a relayed PKI DM) ===\n"); + RUN_TEST(test_undecodable_flooded_packet_gets_text_message_tolerance); + RUN_TEST(test_undecodable_directed_packet_keeps_default_threshold); + RUN_TEST(test_next_hop_gate_is_overridden_by_a_cached_portnum); + RUN_TEST(test_next_hop_gate_suppressed_by_busy_channel_util); + RUN_TEST(test_next_hop_gate_tolerated_when_channel_util_low); + RUN_TEST(test_next_hop_gate_suppressed_by_busy_air_util_tx); + RUN_TEST(test_next_hop_gate_tolerated_when_air_util_tx_low); +#if HAS_VARIABLE_HOPS + RUN_TEST(test_next_hop_gate_suppressed_by_busy_direct_node_count); + RUN_TEST(test_next_hop_gate_tolerated_when_direct_node_count_low); +#endif + + printf("\n=== meshTooBusyForExtraRepeats gate ===\n"); + RUN_TEST(test_busy_channel_util_suppresses_text_message_threshold); + RUN_TEST(test_channel_util_at_gate_does_not_suppress); + RUN_TEST(test_busy_air_util_tx_suppresses_text_message_threshold); + RUN_TEST(test_air_util_tx_at_gate_does_not_suppress); +#if HAS_VARIABLE_HOPS + RUN_TEST(test_busy_direct_node_count_suppresses_text_message_threshold); + RUN_TEST(test_direct_node_count_at_gate_does_not_suppress); +#endif + RUN_TEST(test_busy_mesh_does_not_affect_already_unextended_portnums); + + printf("\n=== shouldCancelDupe (threshold gating) ===\n"); + RUN_TEST(test_shouldCancelDupe_clears_tracking_once_threshold_reached); + RUN_TEST(test_shouldCancelDupe_waits_for_configured_repeats); + + printf("\n=== noteScheduled + encrypted duplicates (as heard for real over the air) ===\n"); + RUN_TEST(test_encrypted_dupe_uses_portnum_noted_when_we_scheduled_our_own_rebroadcast); + RUN_TEST(test_encrypted_dm_dupe_uses_portnum_noted_when_we_scheduled_our_own_rebroadcast); + RUN_TEST(test_encrypted_dupe_without_noteScheduled_falls_back_to_next_hop_gate); + RUN_TEST(test_noteScheduled_is_keyed_per_sender_and_id); + RUN_TEST(test_noteScheduled_updates_existing_entry); + RUN_TEST(test_clearDupeCount_also_forgets_noted_portnum); + RUN_TEST(test_shouldCancelDupe_end_to_end_with_encrypted_duplicate); + RUN_TEST(test_shouldCancelDupe_end_to_end_with_encrypted_dm_duplicate); + + exit(UNITY_END()); +} + +void loop() {}