From 3c4b55f6e49dd003f283a35f9a849d93670aae5e Mon Sep 17 00:00:00 2001 From: ViezeVingertjes Date: Tue, 12 May 2026 23:58:53 +0200 Subject: [PATCH 1/7] Add adaptive advert flood rate limiter to repeater --- docs/cli_commands.md | 12 +++ examples/simple_repeater/MyMesh.cpp | 6 +- examples/simple_repeater/MyMesh.h | 1 + examples/simple_repeater/RateLimiter.h | 101 ++++++++++++++++++++++--- src/helpers/CommonCLI.cpp | 12 ++- src/helpers/CommonCLI.h | 1 + 6 files changed, 119 insertions(+), 14 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index e70ba21725..3ab9153dc1 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -440,6 +440,18 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change the advert rate limiter (Repeater Only) +**Usage:** +- `get advert.ratelimit` +- `set advert.ratelimit ` + +**Parameters:** +- `state`: `on`|`off` + +**Default:** `on` + +--- + #### View or change this node's advert path hash size **Usage:** - `get path.hash.mode` diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 53f642fdf6..256475ef63 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -428,6 +428,9 @@ void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, ui bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; + if (!_prefs.disable_advert_rate_limiter + && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT + && !advert_limiter.allow(rtc_clock.getCurrentTime())) return false; if (packet->isRouteFlood() && packet->getPathHashCount() >= _prefs.flood_max) return false; if (packet->isRouteFlood() && recv_pkt_region == NULL) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); @@ -848,7 +851,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4), discover_limiter(4, 120), // max 4 every 2 minutes - anon_limiter(4, 180) // max 4 every 3 minutes + anon_limiter(4, 180), // max 4 every 3 minutes + advert_limiter(300, 3, 5) // 5-min window, 3x burst, floor 5 #if defined(WITH_RS232_BRIDGE) , bridge(&_prefs, WITH_RS232_BRIDGE, _mgr, &rtc) #endif diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8ed0317e69..14479424aa 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -99,6 +99,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { RegionEntry* recv_pkt_region; TransportKey default_scope; RateLimiter discover_limiter, anon_limiter; + AdaptiveRateLimiter advert_limiter; uint32_t pending_discover_tag; unsigned long pending_discover_until; bool region_load_active; diff --git a/examples/simple_repeater/RateLimiter.h b/examples/simple_repeater/RateLimiter.h index a6633c0a26..6304c4131e 100644 --- a/examples/simple_repeater/RateLimiter.h +++ b/examples/simple_repeater/RateLimiter.h @@ -3,21 +3,100 @@ #include class RateLimiter { - uint32_t _start_timestamp; - uint32_t _secs; - uint16_t _maximum, _count; + uint32_t _start; + uint16_t _secs; + uint16_t _maximum; + uint16_t _count; public: - RateLimiter(uint16_t maximum, uint32_t secs): _maximum(maximum), _secs(secs), _start_timestamp(0), _count(0) { } + RateLimiter(uint16_t maximum, uint16_t secs) + : _start(0), _secs(secs), _maximum(maximum), _count(0) {} bool allow(uint32_t now) { - if (now < _start_timestamp + _secs) { - _count++; - if (_count > _maximum) return false; // deny - } else { // time window now expired - _start_timestamp = now; - _count = 1; + if (now - _start >= _secs) { + _start = now; + _count = 0; } + + if (_count >= _maximum) + return false; + + _count++; + + return true; + } +}; + +class AdaptiveRateLimiter { + enum { + EWMA_SMOOTHING = 3, + EWMA_TOTAL_WEIGHT = EWMA_SMOOTHING + 1, + EWMA_GROWTH_CAP = 4 + }; + + static_assert(EWMA_SMOOTHING >= 1 && EWMA_SMOOTHING <= 256, "EWMA_SMOOTHING must be 1-256"); + static_assert(EWMA_GROWTH_CAP >= 1, "EWMA_GROWTH_CAP must be at least 1"); + + uint32_t _start; + uint16_t _secs; + uint8_t _count; + uint8_t _limit; + uint8_t _ewma; + uint8_t _burst; + uint8_t _floor; + + static uint8_t clampU8(uint16_t v) { return v > 255 ? 255 : (uint8_t)v; } + + uint8_t nextEwma() const { + uint8_t cap = clampU8((uint16_t)_ewma + EWMA_GROWTH_CAP); + uint8_t effective = (_count > _ewma) ? cap : _count; + uint16_t next = (uint16_t)_ewma * EWMA_SMOOTHING + effective; + + return (uint8_t)(next / EWMA_TOTAL_WEIGHT); + } + + uint8_t computeLimit() const { + uint8_t clamped = clampU8((uint16_t)_ewma * _burst); + return clamped > _floor ? clamped : _floor; + } + + void advanceWindow(uint32_t now) { + if (now - _start < _secs) + return; + + uint32_t elapsed = (_secs == 0) ? 1 : (now - _start) / _secs; + + if (elapsed > EWMA_TOTAL_WEIGHT * 8) + elapsed = EWMA_TOTAL_WEIGHT * 8; + + _ewma = nextEwma(); + _limit = computeLimit(); + + while (elapsed > 1 && _ewma > 0) { + _count = 0; + _ewma = nextEwma(); + _limit = computeLimit(); + + elapsed--; + } + + _start = now; + _count = 0; + } + +public: + AdaptiveRateLimiter(uint16_t secs, uint8_t burst, uint8_t floor) + : _start(0), _secs(secs), _count(0), _limit(floor), _ewma(floor), + _burst(burst), _floor(floor) {} + + bool allow(uint32_t now) { + advanceWindow(now); + + if (_count >= _limit) + return false; + + _count++; + return true; } -}; \ No newline at end of file +}; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b71afc72e2..24cc7373d0 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -89,7 +89,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - // next: 291 + file.read((uint8_t *)&_prefs->disable_advert_rate_limiter, sizeof(_prefs->disable_advert_rate_limiter)); // 291 + // next: 292 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -180,7 +181,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - // next: 291 + file.write((uint8_t *)&_prefs->disable_advert_rate_limiter, sizeof(_prefs->disable_advert_rate_limiter)); // 291 + // next: 292 file.close(); } @@ -547,6 +549,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->disable_fwd = memcmp(&config[7], "off", 3) == 0; savePrefs(); strcpy(reply, _prefs->disable_fwd ? "OK - repeat is now OFF" : "OK - repeat is now ON"); + } else if (memcmp(config, "advert.ratelimit ", 17) == 0) { + _prefs->disable_advert_rate_limiter = memcmp(&config[17], "off", 3) == 0; + savePrefs(); + strcpy(reply, _prefs->disable_advert_rate_limiter ? "OK - advert rate limiter OFF" : "OK - advert rate limiter ON"); #if defined(USE_SX1262) || defined(USE_SX1268) } else if (memcmp(config, "radio.rxgain ", 13) == 0) { _prefs->rx_boosted_gain = memcmp(&config[13], "on", 2) == 0; @@ -765,6 +771,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", _prefs->node_name); } else if (memcmp(config, "repeat", 6) == 0) { sprintf(reply, "> %s", _prefs->disable_fwd ? "off" : "on"); + } else if (memcmp(config, "advert.ratelimit", 16) == 0) { + sprintf(reply, "> %s", _prefs->disable_advert_rate_limiter ? "off" : "on"); } else if (memcmp(config, "lat", 3) == 0) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lat)); } else if (memcmp(config, "lon", 3) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index ffdc7c6536..429cc06c9f 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -61,6 +61,7 @@ struct NodePrefs { // persisted to file uint8_t rx_boosted_gain; // power settings uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; + uint8_t disable_advert_rate_limiter; }; class CommonCLICallbacks { From 708f3d9ead1eda871d3461f7bf92385062b37ae6 Mon Sep 17 00:00:00 2001 From: ViezeVingertjes Date: Wed, 13 May 2026 11:19:39 +0200 Subject: [PATCH 2/7] Add EWMA notes for adaptive advert flood rate limiter --- examples/simple_repeater/RateLimiter.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/simple_repeater/RateLimiter.h b/examples/simple_repeater/RateLimiter.h index 6304c4131e..fab4991735 100644 --- a/examples/simple_repeater/RateLimiter.h +++ b/examples/simple_repeater/RateLimiter.h @@ -28,6 +28,7 @@ class RateLimiter { }; class AdaptiveRateLimiter { + // EWMA of recent per-window advert counts; cap each window at max(floor, ewma * burst). enum { EWMA_SMOOTHING = 3, EWMA_TOTAL_WEIGHT = EWMA_SMOOTHING + 1, @@ -85,6 +86,9 @@ class AdaptiveRateLimiter { } public: + // secs: window length (seconds) for count reset and EWMA steps + // burst: multiplier on EWMA to set max adverts per window + // floor: minimum max adverts per window AdaptiveRateLimiter(uint16_t secs, uint8_t burst, uint8_t floor) : _start(0), _secs(secs), _count(0), _limit(floor), _ewma(floor), _burst(burst), _floor(floor) {} From 470e9e3ea21e736a88f391f6c05fbed3d05e10b3 Mon Sep 17 00:00:00 2001 From: ViezeVingertjes Date: Wed, 13 May 2026 11:19:39 +0200 Subject: [PATCH 3/7] Add native unit tests for adaptive advert flood rate limiter --- platformio.ini | 1 + .../test_adaptive_rate_limiter.cpp | 98 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 test/test_simple_repeater/test_adaptive_rate_limiter.cpp diff --git a/platformio.ini b/platformio.ini index b079cec939..608d53bfca 100644 --- a/platformio.ini +++ b/platformio.ini @@ -160,6 +160,7 @@ platform = native build_flags = -std=c++17 -I src -I test/mocks + -I examples/simple_repeater test_build_src = yes build_src_filter = -<*> diff --git a/test/test_simple_repeater/test_adaptive_rate_limiter.cpp b/test/test_simple_repeater/test_adaptive_rate_limiter.cpp new file mode 100644 index 0000000000..06be696ae3 --- /dev/null +++ b/test/test_simple_repeater/test_adaptive_rate_limiter.cpp @@ -0,0 +1,98 @@ +#include + +#include + +#include "RateLimiter.h" + +static int drainWindow(AdaptiveRateLimiter& limiter, uint32_t now) { + int allowed = 0; + while (limiter.allow(now) && allowed < 255) { + ++allowed; + } + return allowed; +} + +TEST(AdaptiveRateLimiter, UsesFloorInInitialWindow) { + AdaptiveRateLimiter limiter(300, 3, 5); + EXPECT_EQ(5, drainWindow(limiter, 1)); +} + +TEST(AdaptiveRateLimiter, RollsOverAtExactWindowBoundary) { + AdaptiveRateLimiter limiter(10, 1, 2); + + EXPECT_EQ(2, drainWindow(limiter, 1)); + EXPECT_EQ(0, drainWindow(limiter, 9)); + EXPECT_EQ(2, drainWindow(limiter, 10)); +} + +TEST(AdaptiveRateLimiter, CountsTrafficSpreadAcrossSameWindow) { + AdaptiveRateLimiter limiter(10, 3, 2); + + EXPECT_TRUE(limiter.allow(1)); + EXPECT_TRUE(limiter.allow(5)); + EXPECT_FALSE(limiter.allow(9)); + EXPECT_EQ(6, drainWindow(limiter, 10)); +} + +TEST(AdaptiveRateLimiter, GrowsLimitWithSustainedLoad) { + AdaptiveRateLimiter limiter(10, 3, 2); + + const int initialWindow = drainWindow(limiter, 1); + const int secondWindow = drainWindow(limiter, 10); + const int thirdWindow = drainWindow(limiter, 20); + + EXPECT_EQ(2, initialWindow); + EXPECT_EQ(6, secondWindow); + EXPECT_EQ(9, thirdWindow); + EXPECT_GT(secondWindow, initialWindow); + EXPECT_GT(thirdWindow, secondWindow); +} + +TEST(AdaptiveRateLimiter, DecaysBackToFloorAfterLongIdle) { + AdaptiveRateLimiter limiter(10, 3, 2); + + EXPECT_EQ(2, drainWindow(limiter, 1)); + EXPECT_EQ(6, drainWindow(limiter, 10)); + EXPECT_EQ(9, drainWindow(limiter, 20)); + EXPECT_EQ(2, drainWindow(limiter, 3000)); +} + +TEST(AdaptiveRateLimiter, LargeTimeJumpAgesAcrossMultipleWindows) { + AdaptiveRateLimiter shortJump(10, 3, 2); + AdaptiveRateLimiter longJump(10, 3, 2); + + EXPECT_EQ(2, drainWindow(shortJump, 1)); + EXPECT_EQ(6, drainWindow(shortJump, 10)); + EXPECT_EQ(9, drainWindow(shortJump, 20)); + + EXPECT_EQ(2, drainWindow(longJump, 1)); + EXPECT_EQ(6, drainWindow(longJump, 10)); + EXPECT_EQ(9, drainWindow(longJump, 20)); + + const int nextWindowShort = drainWindow(shortJump, 30); + const int afterLongIdle = drainWindow(longJump, 3000); + + EXPECT_GT(nextWindowShort, afterLongIdle); + EXPECT_EQ(2, afterLongIdle); +} + +TEST(AdaptiveRateLimiter, HandlesUint32TimestampWraparound) { + AdaptiveRateLimiter limiter(10, 3, 2); + const uint32_t beforeWrap = UINT32_MAX - 4; + + EXPECT_EQ(2, drainWindow(limiter, beforeWrap)); + EXPECT_EQ(0, drainWindow(limiter, 4)); + EXPECT_EQ(3, drainWindow(limiter, 5)); +} + +TEST(AdaptiveRateLimiter, ClampsLimitToUint8Maximum) { + AdaptiveRateLimiter limiter(10, 255, 255); + + EXPECT_EQ(255, drainWindow(limiter, 1)); + EXPECT_EQ(255, drainWindow(limiter, 10)); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 31fe95c0402314afecc3af415d280e1443043e21 Mon Sep 17 00:00:00 2001 From: ViezeVingertjes Date: Wed, 13 May 2026 14:56:14 +0200 Subject: [PATCH 4/7] Add adaptive advert limiter stats --- docs/cli_commands.md | 9 +- examples/simple_repeater/MyMesh.cpp | 6 ++ examples/simple_repeater/RateLimiter.h | 33 ++++++- .../test_adaptive_rate_limiter.cpp | 87 +++++++++++++++++++ 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 3ab9153dc1..9cfcb7a8ec 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -137,13 +137,20 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -### Packet stats - Packet counters: Received, Sent +### Packet Stats - Packet counters: Received, Sent **Usage:** `stats-packets` **Serial Only:** Yes --- +### Advert Limiter Stats - Limit, Remaining, Denied, Load Average and Last Limit Time (Repeater Only) +**Usage:** `stats-advert-limiter` + +**Serial Only:** No + +--- + ## Logging ### Begin capture of rx log to node storage diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 256475ef63..ce43f36a9f 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1167,6 +1167,7 @@ void MyMesh::clearStats() { radio_driver.resetStats(); resetStats(); ((SimpleMeshTables *)getTables())->resetStats(); + advert_limiter.clearStats(); } void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { @@ -1255,6 +1256,11 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sendNodeDiscoverReq(); strcpy(reply, "OK - Discover sent"); } + } else if (memcmp(command, "stats-advert-limiter", 20) == 0 && (command[20] == 0 || command[20] == ' ')) { + AdaptiveRateLimiterStats stats = advert_limiter.stats(rtc_clock.getCurrentTime()); + sprintf(reply, "{\"limit\":%u,\"remaining\":%u,\"denied\":%u,\"load_avg\":%u,\"limit_reached_at\":%lu}", + (unsigned)stats.limit, (unsigned)stats.remaining, (unsigned)stats.denied, + (unsigned)stats.load_avg, (unsigned long)stats.limit_reached_at); } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } diff --git a/examples/simple_repeater/RateLimiter.h b/examples/simple_repeater/RateLimiter.h index fab4991735..106a936b8c 100644 --- a/examples/simple_repeater/RateLimiter.h +++ b/examples/simple_repeater/RateLimiter.h @@ -27,6 +27,14 @@ class RateLimiter { } }; +struct AdaptiveRateLimiterStats { + uint8_t limit; + uint8_t remaining; + uint8_t denied; + uint8_t load_avg; + uint32_t limit_reached_at; +}; + class AdaptiveRateLimiter { // EWMA of recent per-window advert counts; cap each window at max(floor, ewma * burst). enum { @@ -45,6 +53,8 @@ class AdaptiveRateLimiter { uint8_t _ewma; uint8_t _burst; uint8_t _floor; + uint8_t _denied; + uint32_t _limit_reached_at; static uint8_t clampU8(uint16_t v) { return v > 255 ? 255 : (uint8_t)v; } @@ -83,6 +93,7 @@ class AdaptiveRateLimiter { _start = now; _count = 0; + _denied = 0; } public: @@ -91,16 +102,32 @@ class AdaptiveRateLimiter { // floor: minimum max adverts per window AdaptiveRateLimiter(uint16_t secs, uint8_t burst, uint8_t floor) : _start(0), _secs(secs), _count(0), _limit(floor), _ewma(floor), - _burst(burst), _floor(floor) {} + _burst(burst), _floor(floor), _denied(0), _limit_reached_at(0) {} bool allow(uint32_t now) { advanceWindow(now); - if (_count >= _limit) + if (_count >= _limit) { + if (_denied < 255) _denied++; return false; + } _count++; - + + if (_count >= _limit) + _limit_reached_at = now; + return true; } + + void clearStats() { + _denied = 0; + _limit_reached_at = 0; + } + + AdaptiveRateLimiterStats stats(uint32_t now) { + advanceWindow(now); + uint8_t remaining = (_count < _limit) ? (_limit - _count) : 0; + return { _limit, remaining, _denied, _ewma, _limit_reached_at }; + } }; diff --git a/test/test_simple_repeater/test_adaptive_rate_limiter.cpp b/test/test_simple_repeater/test_adaptive_rate_limiter.cpp index 06be696ae3..ad223bcc96 100644 --- a/test/test_simple_repeater/test_adaptive_rate_limiter.cpp +++ b/test/test_simple_repeater/test_adaptive_rate_limiter.cpp @@ -92,6 +92,93 @@ TEST(AdaptiveRateLimiter, ClampsLimitToUint8Maximum) { EXPECT_EQ(255, drainWindow(limiter, 10)); } +TEST(AdaptiveRateLimiter, StatsInitialState) { + AdaptiveRateLimiter limiter(10, 3, 5); + const AdaptiveRateLimiterStats stats = limiter.stats(1); + + EXPECT_EQ(5, stats.limit); + EXPECT_EQ(5, stats.remaining); + EXPECT_EQ(0, stats.denied); + EXPECT_EQ(5, stats.load_avg); + EXPECT_EQ(0, stats.limit_reached_at); +} + +TEST(AdaptiveRateLimiter, StatsRemainingDecreasesOnAllow) { + AdaptiveRateLimiter limiter(10, 3, 5); + + EXPECT_TRUE(limiter.allow(1)); + EXPECT_TRUE(limiter.allow(1)); + const AdaptiveRateLimiterStats stats = limiter.stats(1); + + EXPECT_EQ(3, stats.remaining); + EXPECT_EQ(0, stats.denied); +} + +TEST(AdaptiveRateLimiter, StatsRecordsDeniedAndLimitReachedAt) { + AdaptiveRateLimiter limiter(10, 3, 2); + + EXPECT_TRUE(limiter.allow(1)); + EXPECT_TRUE(limiter.allow(1)); + EXPECT_FALSE(limiter.allow(2)); + EXPECT_FALSE(limiter.allow(3)); + + const AdaptiveRateLimiterStats stats = limiter.stats(3); + + EXPECT_EQ(0, stats.remaining); + EXPECT_EQ(2, stats.denied); + EXPECT_EQ(1, stats.limit_reached_at); +} + +TEST(AdaptiveRateLimiter, StatsDeniedResetsOnWindowRollover) { + AdaptiveRateLimiter limiter(10, 3, 2); + + EXPECT_TRUE(limiter.allow(1)); + EXPECT_TRUE(limiter.allow(1)); + EXPECT_FALSE(limiter.allow(2)); + + const AdaptiveRateLimiterStats stats = limiter.stats(10); + + EXPECT_EQ(0, stats.denied); + EXPECT_EQ(1, stats.limit_reached_at); +} + +TEST(AdaptiveRateLimiter, StatsDeniedSaturatesAt255) { + AdaptiveRateLimiter limiter(10, 3, 1); + + EXPECT_TRUE(limiter.allow(1)); + + for (int i = 0; i < 300; ++i) + EXPECT_FALSE(limiter.allow(1)); + + const AdaptiveRateLimiterStats stats = limiter.stats(1); + + EXPECT_EQ(255, stats.denied); +} + +TEST(AdaptiveRateLimiter, ClearStatsOnlyClearsReportingCounters) { + AdaptiveRateLimiter limiter(10, 3, 2); + + EXPECT_TRUE(limiter.allow(1)); + EXPECT_TRUE(limiter.allow(1)); + EXPECT_FALSE(limiter.allow(2)); + + const AdaptiveRateLimiterStats beforeClear = limiter.stats(2); + + EXPECT_EQ(0, beforeClear.remaining); + EXPECT_EQ(1, beforeClear.denied); + EXPECT_EQ(1, beforeClear.limit_reached_at); + + limiter.clearStats(); + const AdaptiveRateLimiterStats afterClear = limiter.stats(2); + + EXPECT_EQ(2, afterClear.limit); + EXPECT_EQ(0, afterClear.remaining); + EXPECT_EQ(0, afterClear.denied); + EXPECT_EQ(2, afterClear.load_avg); + EXPECT_EQ(0, afterClear.limit_reached_at); + EXPECT_FALSE(limiter.allow(2)); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From 55ccab0b6042e97498a36179ee4592d1bf1f8c84 Mon Sep 17 00:00:00 2001 From: ViezeVingertjes Date: Wed, 13 May 2026 15:33:16 +0200 Subject: [PATCH 5/7] Add adaptive advert limiter naming cleanup --- docs/cli_commands.md | 4 +- examples/simple_repeater/MyMesh.cpp | 6 +- examples/simple_repeater/RateLimiter.h | 71 +++++++++---------- .../test_adaptive_rate_limiter.cpp | 12 ++-- 4 files changed, 45 insertions(+), 48 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 9cfcb7a8ec..6b5e6a9bba 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -144,8 +144,8 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -### Advert Limiter Stats - Limit, Remaining, Denied, Load Average and Last Limit Time (Repeater Only) -**Usage:** `stats-advert-limiter` +### Advert Limiter Stats - Limit, Remaining, Denied, Load Average and Last Limit Reached Time (Repeater Only) +**Usage:** `stats-advert-ratelimit` **Serial Only:** No diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index ce43f36a9f..0f8ce175d6 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1256,11 +1256,11 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sendNodeDiscoverReq(); strcpy(reply, "OK - Discover sent"); } - } else if (memcmp(command, "stats-advert-limiter", 20) == 0 && (command[20] == 0 || command[20] == ' ')) { + } else if (memcmp(command, "stats-advert-ratelimit", 22) == 0 && (command[22] == 0 || command[22] == ' ')) { AdaptiveRateLimiterStats stats = advert_limiter.stats(rtc_clock.getCurrentTime()); - sprintf(reply, "{\"limit\":%u,\"remaining\":%u,\"denied\":%u,\"load_avg\":%u,\"limit_reached_at\":%lu}", + sprintf(reply, "{\"limit\":%u,\"remaining\":%u,\"denied\":%u,\"load_avg\":%u,\"last_limit_reached_at\":%lu}", (unsigned)stats.limit, (unsigned)stats.remaining, (unsigned)stats.denied, - (unsigned)stats.load_avg, (unsigned long)stats.limit_reached_at); + (unsigned)stats.load_avg, (unsigned long)stats.last_limit_reached_at); } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } diff --git a/examples/simple_repeater/RateLimiter.h b/examples/simple_repeater/RateLimiter.h index 106a936b8c..e6774b3707 100644 --- a/examples/simple_repeater/RateLimiter.h +++ b/examples/simple_repeater/RateLimiter.h @@ -32,7 +32,7 @@ struct AdaptiveRateLimiterStats { uint8_t remaining; uint8_t denied; uint8_t load_avg; - uint32_t limit_reached_at; + uint32_t last_limit_reached_at; }; class AdaptiveRateLimiter { @@ -46,88 +46,85 @@ class AdaptiveRateLimiter { static_assert(EWMA_SMOOTHING >= 1 && EWMA_SMOOTHING <= 256, "EWMA_SMOOTHING must be 1-256"); static_assert(EWMA_GROWTH_CAP >= 1, "EWMA_GROWTH_CAP must be at least 1"); - uint32_t _start; - uint16_t _secs; - uint8_t _count; - uint8_t _limit; - uint8_t _ewma; - uint8_t _burst; - uint8_t _floor; + uint32_t _window_start; + uint16_t _window_secs; + uint8_t _window_count; + uint8_t _window_limit; + uint8_t _load_avg; + uint8_t _burst_multiplier; + uint8_t _min_limit; uint8_t _denied; - uint32_t _limit_reached_at; + uint32_t _last_limit_reached_at; static uint8_t clampU8(uint16_t v) { return v > 255 ? 255 : (uint8_t)v; } uint8_t nextEwma() const { - uint8_t cap = clampU8((uint16_t)_ewma + EWMA_GROWTH_CAP); - uint8_t effective = (_count > _ewma) ? cap : _count; - uint16_t next = (uint16_t)_ewma * EWMA_SMOOTHING + effective; + uint8_t cap = clampU8((uint16_t)_load_avg + EWMA_GROWTH_CAP); + uint8_t effective = (_window_count > _load_avg) ? cap : _window_count; + uint16_t next = (uint16_t)_load_avg * EWMA_SMOOTHING + effective; return (uint8_t)(next / EWMA_TOTAL_WEIGHT); } uint8_t computeLimit() const { - uint8_t clamped = clampU8((uint16_t)_ewma * _burst); - return clamped > _floor ? clamped : _floor; + uint8_t clamped = clampU8((uint16_t)_load_avg * _burst_multiplier); + return clamped > _min_limit ? clamped : _min_limit; } void advanceWindow(uint32_t now) { - if (now - _start < _secs) + if (now - _window_start < _window_secs) return; - uint32_t elapsed = (_secs == 0) ? 1 : (now - _start) / _secs; + uint32_t elapsed = (_window_secs == 0) ? 1 : (now - _window_start) / _window_secs; if (elapsed > EWMA_TOTAL_WEIGHT * 8) elapsed = EWMA_TOTAL_WEIGHT * 8; - _ewma = nextEwma(); - _limit = computeLimit(); + _load_avg = nextEwma(); + _window_limit = computeLimit(); - while (elapsed > 1 && _ewma > 0) { - _count = 0; - _ewma = nextEwma(); - _limit = computeLimit(); + while (elapsed > 1 && _load_avg > 0) { + _window_count = 0; + _load_avg = nextEwma(); + _window_limit = computeLimit(); elapsed--; } - _start = now; - _count = 0; + _window_start = now; + _window_count = 0; _denied = 0; } public: - // secs: window length (seconds) for count reset and EWMA steps - // burst: multiplier on EWMA to set max adverts per window - // floor: minimum max adverts per window - AdaptiveRateLimiter(uint16_t secs, uint8_t burst, uint8_t floor) - : _start(0), _secs(secs), _count(0), _limit(floor), _ewma(floor), - _burst(burst), _floor(floor), _denied(0), _limit_reached_at(0) {} + AdaptiveRateLimiter(uint16_t window_secs, uint8_t burst_multiplier, uint8_t min_limit) + : _window_start(0), _window_secs(window_secs), _window_count(0), _window_limit(min_limit), _load_avg(min_limit), + _burst_multiplier(burst_multiplier), _min_limit(min_limit), _denied(0), _last_limit_reached_at(0) {} bool allow(uint32_t now) { advanceWindow(now); - if (_count >= _limit) { + if (_window_count >= _window_limit) { if (_denied < 255) _denied++; return false; } - _count++; + _window_count++; - if (_count >= _limit) - _limit_reached_at = now; + if (_window_count >= _window_limit) + _last_limit_reached_at = now; return true; } void clearStats() { _denied = 0; - _limit_reached_at = 0; + _last_limit_reached_at = 0; } AdaptiveRateLimiterStats stats(uint32_t now) { advanceWindow(now); - uint8_t remaining = (_count < _limit) ? (_limit - _count) : 0; - return { _limit, remaining, _denied, _ewma, _limit_reached_at }; + uint8_t remaining = (_window_count < _window_limit) ? (_window_limit - _window_count) : 0; + return { _window_limit, remaining, _denied, _load_avg, _last_limit_reached_at }; } }; diff --git a/test/test_simple_repeater/test_adaptive_rate_limiter.cpp b/test/test_simple_repeater/test_adaptive_rate_limiter.cpp index ad223bcc96..0d017baa91 100644 --- a/test/test_simple_repeater/test_adaptive_rate_limiter.cpp +++ b/test/test_simple_repeater/test_adaptive_rate_limiter.cpp @@ -100,7 +100,7 @@ TEST(AdaptiveRateLimiter, StatsInitialState) { EXPECT_EQ(5, stats.remaining); EXPECT_EQ(0, stats.denied); EXPECT_EQ(5, stats.load_avg); - EXPECT_EQ(0, stats.limit_reached_at); + EXPECT_EQ(0, stats.last_limit_reached_at); } TEST(AdaptiveRateLimiter, StatsRemainingDecreasesOnAllow) { @@ -114,7 +114,7 @@ TEST(AdaptiveRateLimiter, StatsRemainingDecreasesOnAllow) { EXPECT_EQ(0, stats.denied); } -TEST(AdaptiveRateLimiter, StatsRecordsDeniedAndLimitReachedAt) { +TEST(AdaptiveRateLimiter, StatsRecordsDeniedAndLastLimitReachedAt) { AdaptiveRateLimiter limiter(10, 3, 2); EXPECT_TRUE(limiter.allow(1)); @@ -126,7 +126,7 @@ TEST(AdaptiveRateLimiter, StatsRecordsDeniedAndLimitReachedAt) { EXPECT_EQ(0, stats.remaining); EXPECT_EQ(2, stats.denied); - EXPECT_EQ(1, stats.limit_reached_at); + EXPECT_EQ(1, stats.last_limit_reached_at); } TEST(AdaptiveRateLimiter, StatsDeniedResetsOnWindowRollover) { @@ -139,7 +139,7 @@ TEST(AdaptiveRateLimiter, StatsDeniedResetsOnWindowRollover) { const AdaptiveRateLimiterStats stats = limiter.stats(10); EXPECT_EQ(0, stats.denied); - EXPECT_EQ(1, stats.limit_reached_at); + EXPECT_EQ(1, stats.last_limit_reached_at); } TEST(AdaptiveRateLimiter, StatsDeniedSaturatesAt255) { @@ -166,7 +166,7 @@ TEST(AdaptiveRateLimiter, ClearStatsOnlyClearsReportingCounters) { EXPECT_EQ(0, beforeClear.remaining); EXPECT_EQ(1, beforeClear.denied); - EXPECT_EQ(1, beforeClear.limit_reached_at); + EXPECT_EQ(1, beforeClear.last_limit_reached_at); limiter.clearStats(); const AdaptiveRateLimiterStats afterClear = limiter.stats(2); @@ -175,7 +175,7 @@ TEST(AdaptiveRateLimiter, ClearStatsOnlyClearsReportingCounters) { EXPECT_EQ(0, afterClear.remaining); EXPECT_EQ(0, afterClear.denied); EXPECT_EQ(2, afterClear.load_avg); - EXPECT_EQ(0, afterClear.limit_reached_at); + EXPECT_EQ(0, afterClear.last_limit_reached_at); EXPECT_FALSE(limiter.allow(2)); } From 87bd6fba64ddac665ad71ca5850b6608146b3a24 Mon Sep 17 00:00:00 2001 From: ViezeVingertjes Date: Wed, 13 May 2026 22:57:49 +0200 Subject: [PATCH 6/7] Update adaptive advert limiter stats to include limit hit count and last limit hit age --- docs/cli_commands.md | 2 +- examples/simple_repeater/MyMesh.cpp | 4 +-- examples/simple_repeater/RateLimiter.h | 14 +++++--- .../test_adaptive_rate_limiter.cpp | 33 +++++++++++++++---- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 6b5e6a9bba..c815606001 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -144,7 +144,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -### Advert Limiter Stats - Limit, Remaining, Denied, Load Average and Last Limit Reached Time (Repeater Only) +### Advert Limiter Stats - Limit, Remaining, Denied, Load Average, Limit Hit Count and Last Limit Hit Age (Repeater Only) **Usage:** `stats-advert-ratelimit` **Serial Only:** No diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 0f8ce175d6..1377600ec0 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1258,9 +1258,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } } else if (memcmp(command, "stats-advert-ratelimit", 22) == 0 && (command[22] == 0 || command[22] == ' ')) { AdaptiveRateLimiterStats stats = advert_limiter.stats(rtc_clock.getCurrentTime()); - sprintf(reply, "{\"limit\":%u,\"remaining\":%u,\"denied\":%u,\"load_avg\":%u,\"last_limit_reached_at\":%lu}", + sprintf(reply, "{\"limit\":%u,\"remaining\":%u,\"denied\":%u,\"load_avg\":%u,\"limit_reached\":%u,\"last_limit_reached_ago\":%lu}", (unsigned)stats.limit, (unsigned)stats.remaining, (unsigned)stats.denied, - (unsigned)stats.load_avg, (unsigned long)stats.last_limit_reached_at); + (unsigned)stats.load_avg, (unsigned)stats.limit_reached, (unsigned long)stats.last_limit_reached_ago); } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } diff --git a/examples/simple_repeater/RateLimiter.h b/examples/simple_repeater/RateLimiter.h index e6774b3707..789cbfa05a 100644 --- a/examples/simple_repeater/RateLimiter.h +++ b/examples/simple_repeater/RateLimiter.h @@ -32,7 +32,8 @@ struct AdaptiveRateLimiterStats { uint8_t remaining; uint8_t denied; uint8_t load_avg; - uint32_t last_limit_reached_at; + uint16_t limit_reached; + uint32_t last_limit_reached_ago; }; class AdaptiveRateLimiter { @@ -54,6 +55,7 @@ class AdaptiveRateLimiter { uint8_t _burst_multiplier; uint8_t _min_limit; uint8_t _denied; + uint16_t _limit_reached; uint32_t _last_limit_reached_at; static uint8_t clampU8(uint16_t v) { return v > 255 ? 255 : (uint8_t)v; } @@ -99,7 +101,7 @@ class AdaptiveRateLimiter { public: AdaptiveRateLimiter(uint16_t window_secs, uint8_t burst_multiplier, uint8_t min_limit) : _window_start(0), _window_secs(window_secs), _window_count(0), _window_limit(min_limit), _load_avg(min_limit), - _burst_multiplier(burst_multiplier), _min_limit(min_limit), _denied(0), _last_limit_reached_at(0) {} + _burst_multiplier(burst_multiplier), _min_limit(min_limit), _denied(0), _limit_reached(0), _last_limit_reached_at(0) {} bool allow(uint32_t now) { advanceWindow(now); @@ -111,20 +113,24 @@ class AdaptiveRateLimiter { _window_count++; - if (_window_count >= _window_limit) + if (_window_count >= _window_limit) { + if (_limit_reached < 65535) _limit_reached++; _last_limit_reached_at = now; + } return true; } void clearStats() { _denied = 0; + _limit_reached = 0; _last_limit_reached_at = 0; } AdaptiveRateLimiterStats stats(uint32_t now) { advanceWindow(now); uint8_t remaining = (_window_count < _window_limit) ? (_window_limit - _window_count) : 0; - return { _window_limit, remaining, _denied, _load_avg, _last_limit_reached_at }; + uint32_t last_limit_reached_ago = (_last_limit_reached_at == 0) ? 0 : (now - _last_limit_reached_at); + return { _window_limit, remaining, _denied, _load_avg, _limit_reached, last_limit_reached_ago }; } }; diff --git a/test/test_simple_repeater/test_adaptive_rate_limiter.cpp b/test/test_simple_repeater/test_adaptive_rate_limiter.cpp index 0d017baa91..36d065cb1e 100644 --- a/test/test_simple_repeater/test_adaptive_rate_limiter.cpp +++ b/test/test_simple_repeater/test_adaptive_rate_limiter.cpp @@ -100,7 +100,8 @@ TEST(AdaptiveRateLimiter, StatsInitialState) { EXPECT_EQ(5, stats.remaining); EXPECT_EQ(0, stats.denied); EXPECT_EQ(5, stats.load_avg); - EXPECT_EQ(0, stats.last_limit_reached_at); + EXPECT_EQ(0, stats.limit_reached); + EXPECT_EQ(0, stats.last_limit_reached_ago); } TEST(AdaptiveRateLimiter, StatsRemainingDecreasesOnAllow) { @@ -114,7 +115,7 @@ TEST(AdaptiveRateLimiter, StatsRemainingDecreasesOnAllow) { EXPECT_EQ(0, stats.denied); } -TEST(AdaptiveRateLimiter, StatsRecordsDeniedAndLastLimitReachedAt) { +TEST(AdaptiveRateLimiter, StatsRecordsDeniedAndLastLimitReachedAge) { AdaptiveRateLimiter limiter(10, 3, 2); EXPECT_TRUE(limiter.allow(1)); @@ -126,10 +127,11 @@ TEST(AdaptiveRateLimiter, StatsRecordsDeniedAndLastLimitReachedAt) { EXPECT_EQ(0, stats.remaining); EXPECT_EQ(2, stats.denied); - EXPECT_EQ(1, stats.last_limit_reached_at); + EXPECT_EQ(1, stats.limit_reached); + EXPECT_EQ(2, stats.last_limit_reached_ago); } -TEST(AdaptiveRateLimiter, StatsDeniedResetsOnWindowRollover) { +TEST(AdaptiveRateLimiter, StatsDeniedResetsOnWindowRolloverAndKeepsLimitReachedAge) { AdaptiveRateLimiter limiter(10, 3, 2); EXPECT_TRUE(limiter.allow(1)); @@ -139,7 +141,22 @@ TEST(AdaptiveRateLimiter, StatsDeniedResetsOnWindowRollover) { const AdaptiveRateLimiterStats stats = limiter.stats(10); EXPECT_EQ(0, stats.denied); - EXPECT_EQ(1, stats.last_limit_reached_at); + EXPECT_EQ(1, stats.limit_reached); + EXPECT_EQ(9, stats.last_limit_reached_ago); +} + +TEST(AdaptiveRateLimiter, StatsLimitReachedCountsEachWindowHit) { + AdaptiveRateLimiter limiter(10, 1, 1); + + EXPECT_TRUE(limiter.allow(1)); + EXPECT_FALSE(limiter.allow(1)); + + EXPECT_TRUE(limiter.allow(10)); + EXPECT_FALSE(limiter.allow(10)); + + const AdaptiveRateLimiterStats stats = limiter.stats(10); + + EXPECT_EQ(2, stats.limit_reached); } TEST(AdaptiveRateLimiter, StatsDeniedSaturatesAt255) { @@ -166,7 +183,8 @@ TEST(AdaptiveRateLimiter, ClearStatsOnlyClearsReportingCounters) { EXPECT_EQ(0, beforeClear.remaining); EXPECT_EQ(1, beforeClear.denied); - EXPECT_EQ(1, beforeClear.last_limit_reached_at); + EXPECT_EQ(1, beforeClear.limit_reached); + EXPECT_EQ(1, beforeClear.last_limit_reached_ago); limiter.clearStats(); const AdaptiveRateLimiterStats afterClear = limiter.stats(2); @@ -175,7 +193,8 @@ TEST(AdaptiveRateLimiter, ClearStatsOnlyClearsReportingCounters) { EXPECT_EQ(0, afterClear.remaining); EXPECT_EQ(0, afterClear.denied); EXPECT_EQ(2, afterClear.load_avg); - EXPECT_EQ(0, afterClear.last_limit_reached_at); + EXPECT_EQ(0, afterClear.limit_reached); + EXPECT_EQ(0, afterClear.last_limit_reached_ago); EXPECT_FALSE(limiter.allow(2)); } From d0f3545a2499edaa1cb0c3b0ce4371a6beb7fa38 Mon Sep 17 00:00:00 2001 From: ViezeVingertjes Date: Thu, 14 May 2026 11:38:53 +0200 Subject: [PATCH 7/7] Update advert limiter configuration to adjust the time window and burst floor values --- examples/simple_repeater/MyMesh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1377600ec0..d278237f85 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -852,7 +852,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc telemetry(MAX_PACKET_PAYLOAD - 4), discover_limiter(4, 120), // max 4 every 2 minutes anon_limiter(4, 180), // max 4 every 3 minutes - advert_limiter(300, 3, 5) // 5-min window, 3x burst, floor 5 + advert_limiter(150, 3, 9) // 150s window, 3x burst, floor 9 #if defined(WITH_RS232_BRIDGE) , bridge(&_prefs, WITH_RS232_BRIDGE, _mgr, &rtc) #endif