diff --git a/docs/cli_commands.md b/docs/cli_commands.md index e70ba21725..c815606001 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, Limit Hit Count and Last Limit Hit Age (Repeater Only) +**Usage:** `stats-advert-ratelimit` + +**Serial Only:** No + +--- + ## Logging ### Begin capture of rx log to node storage @@ -440,6 +447,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..d278237f85 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(150, 3, 9) // 150s window, 3x burst, floor 9 #if defined(WITH_RS232_BRIDGE) , bridge(&_prefs, WITH_RS232_BRIDGE, _mgr, &rtc) #endif @@ -1163,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) { @@ -1251,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-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\":%u,\"last_limit_reached_ago\":%lu}", + (unsigned)stats.limit, (unsigned)stats.remaining, (unsigned)stats.denied, + (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/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..789cbfa05a 100644 --- a/examples/simple_repeater/RateLimiter.h +++ b/examples/simple_repeater/RateLimiter.h @@ -3,21 +3,134 @@ #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; } -}; \ No newline at end of file +}; + +struct AdaptiveRateLimiterStats { + uint8_t limit; + uint8_t remaining; + uint8_t denied; + uint8_t load_avg; + uint16_t limit_reached; + uint32_t last_limit_reached_ago; +}; + +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, + 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 _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; + uint16_t _limit_reached; + 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)_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)_load_avg * _burst_multiplier); + return clamped > _min_limit ? clamped : _min_limit; + } + + void advanceWindow(uint32_t now) { + if (now - _window_start < _window_secs) + return; + + uint32_t elapsed = (_window_secs == 0) ? 1 : (now - _window_start) / _window_secs; + + if (elapsed > EWMA_TOTAL_WEIGHT * 8) + elapsed = EWMA_TOTAL_WEIGHT * 8; + + _load_avg = nextEwma(); + _window_limit = computeLimit(); + + while (elapsed > 1 && _load_avg > 0) { + _window_count = 0; + _load_avg = nextEwma(); + _window_limit = computeLimit(); + + elapsed--; + } + + _window_start = now; + _window_count = 0; + _denied = 0; + } + +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), _limit_reached(0), _last_limit_reached_at(0) {} + + bool allow(uint32_t now) { + advanceWindow(now); + + if (_window_count >= _window_limit) { + if (_denied < 255) _denied++; + return false; + } + + _window_count++; + + 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; + 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/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/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 { 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..36d065cb1e --- /dev/null +++ b/test/test_simple_repeater/test_adaptive_rate_limiter.cpp @@ -0,0 +1,204 @@ +#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)); +} + +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); + EXPECT_EQ(0, stats.last_limit_reached_ago); +} + +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, StatsRecordsDeniedAndLastLimitReachedAge) { + 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); + EXPECT_EQ(2, stats.last_limit_reached_ago); +} + +TEST(AdaptiveRateLimiter, StatsDeniedResetsOnWindowRolloverAndKeepsLimitReachedAge) { + 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); + 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) { + 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); + EXPECT_EQ(1, beforeClear.last_limit_reached_ago); + + 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); + EXPECT_EQ(0, afterClear.last_limit_reached_ago); + EXPECT_FALSE(limiter.allow(2)); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}