mirror of https://github.com/meshcore-dev/MeshCore
Browse Source
Autonomously shed inter-region traffic when a repeater's own TX duty cycle is high, protecting the local cluster during high-traffic events (issue #2747). Opt-in, off by default. When TX duty cycle exceeds a configurable threshold, regions are gated from the outermost layer inward: wildcard '*' first, then the broadest named regions, always keeping the innermost cluster. Recovery re-enables regions inside-out once duty cycle drops below (threshold - hysteresis), with per-step random jitter to desync recovery across repeaters. Design notes: - Gating is transient: a new non-persisted rt_flags overlay on RegionEntry is OR'd with config flags in the forward path, so a 'region save' or reboot mid-event can never make a deny permanent. - The gate level is re-asserted every check interval, so region edits or a bulk 'region load' commit (which rebuild entries with cleared rt_flags) cannot desync the gate state; disabling the feature while gated lifts the gate immediately. - Dispatcher::getTxDutyCyclePercent() refreshes the airtime budget before reading, so recovery still fires when traffic (and TX) stops. Config (persisted, appended for upgrade compat): set dc.gate <0|1>, set dc.gate.thresh <1-100>, set dc.gate.hyst <0-50> get dc.gate[.thresh|.hyst], get dc.gate.status (live duty% + level) Adds a native gtest suite for the gating logic (test/test_region_gating) and extends the native test mocks (Arduino/Stream/SHA256) so RegionMap builds off-device. Co-Authored-By: Claude Opus 4.8 <[email protected]>pull/2960/head
13 changed files with 427 additions and 14 deletions
@ -0,0 +1,58 @@ |
|||||
|
#pragma once |
||||
|
|
||||
|
// Minimal Arduino.h shim for native (host) unit tests.
|
||||
|
// Only provides what the headers pulled in by RegionMap / TransportKeyStore /
|
||||
|
// IdentityStore need to compile off-device. File / FILESYSTEM are inert stubs:
|
||||
|
// the region-gating tests exercise in-memory logic and never touch the filesystem.
|
||||
|
|
||||
|
#include <cstdint> |
||||
|
#include <cstddef> |
||||
|
#include <cstring> |
||||
|
#include <cstdio> |
||||
|
#include <cstdlib> |
||||
|
#include <cmath> |
||||
|
#include <string> |
||||
|
|
||||
|
#include "Stream.h" |
||||
|
|
||||
|
// ---- Arduino string helpers not present in the host libc --------------------
|
||||
|
static inline char* ltoa(long value, char* result, int base) { |
||||
|
if (base < 2 || base > 36) { *result = '\0'; return result; } |
||||
|
char* ptr = result; |
||||
|
char* low = result; |
||||
|
if (value < 0 && base == 10) { *ptr++ = '-'; low = ptr; } |
||||
|
unsigned long uval = (value < 0 && base == 10) ? (unsigned long)(-value) : (unsigned long)value; |
||||
|
do { |
||||
|
int digit = (int)(uval % (unsigned long)base); |
||||
|
*ptr++ = (char)(digit < 10 ? '0' + digit : 'a' + digit - 10); |
||||
|
uval /= (unsigned long)base; |
||||
|
} while (uval); |
||||
|
*ptr-- = '\0'; |
||||
|
while (low < ptr) { char t = *low; *low++ = *ptr; *ptr-- = t; } |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
// ---- filesystem stubs -------------------------------------------------------
|
||||
|
class File { |
||||
|
public: |
||||
|
operator bool() const { return false; } |
||||
|
int read(uint8_t* buf, size_t len) { (void)buf; (void)len; return 0; } |
||||
|
size_t write(const uint8_t* buf, size_t len) { (void)buf; (void)len; return 0; } |
||||
|
void close() {} |
||||
|
}; |
||||
|
|
||||
|
class FsMock { |
||||
|
public: |
||||
|
bool exists(const char* path) { (void)path; return false; } |
||||
|
bool remove(const char* path) { (void)path; return false; } |
||||
|
bool mkdir(const char* path) { (void)path; return false; } |
||||
|
File open(const char* path) { (void)path; return File(); } |
||||
|
File open(const char* path, const char* mode) { (void)path; (void)mode; return File(); } |
||||
|
File open(const char* path, const char* mode, bool create) { |
||||
|
(void)path; (void)mode; (void)create; return File(); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
#ifndef FILESYSTEM |
||||
|
#define FILESYSTEM FsMock |
||||
|
#endif |
||||
@ -1,10 +1,43 @@ |
|||||
#pragma once |
#pragma once |
||||
|
|
||||
// Mock Stream class for native testing
|
// Mock Stream class for native testing.
|
||||
// Provides minimal interface needed by Utils.h
|
// Provides the Print/Stream surface used by Utils.cpp and RegionMap.cpp (BufStream).
|
||||
|
|
||||
|
#include <cstdint> |
||||
|
#include <cstddef> |
||||
|
#include <cstdio> |
||||
|
#include <cstdarg> |
||||
|
#include <cstring> |
||||
|
|
||||
class Stream { |
class Stream { |
||||
public: |
public: |
||||
virtual void print(char c) {} |
virtual ~Stream() {} |
||||
virtual void print(const char* str) {} |
|
||||
|
virtual size_t write(uint8_t c) { (void)c; return 1; } |
||||
|
virtual size_t write(const uint8_t* buffer, size_t size) { |
||||
|
size_t written = 0; |
||||
|
while (written < size) { |
||||
|
if (!write(buffer[written])) break; |
||||
|
written++; |
||||
|
} |
||||
|
return written; |
||||
|
} |
||||
|
|
||||
|
virtual int available() { return 0; } |
||||
|
virtual int read() { return -1; } |
||||
|
virtual int peek() { return -1; } |
||||
|
virtual void flush() {} |
||||
|
|
||||
|
size_t print(char c) { return write((uint8_t)c); } |
||||
|
size_t print(const char* str) { return write((const uint8_t*)str, strlen(str)); } |
||||
|
size_t printf(const char* fmt, ...) { |
||||
|
char buf[256]; |
||||
|
va_list ap; |
||||
|
va_start(ap, fmt); |
||||
|
int n = vsnprintf(buf, sizeof(buf), fmt, ap); |
||||
|
va_end(ap); |
||||
|
if (n < 0) return 0; |
||||
|
if (n > (int)sizeof(buf) - 1) n = sizeof(buf) - 1; |
||||
|
return write((const uint8_t*)buf, (size_t)n); |
||||
|
} |
||||
}; |
}; |
||||
|
|||||
@ -0,0 +1,150 @@ |
|||||
|
#include <gtest/gtest.h> |
||||
|
#include "helpers/RegionMap.h" |
||||
|
|
||||
|
// Unit tests for duty-cycle region gating (issue #2747).
|
||||
|
// These exercise the pure hierarchy/level logic; no crypto or filesystem is touched.
|
||||
|
|
||||
|
static bool gated(RegionMap& rm, const char* name) { |
||||
|
RegionEntry* r = rm.findByName(name); // findByName("*") returns the wildcard
|
||||
|
return r && (r->rt_flags & REGION_DENY_FLOOD); |
||||
|
} |
||||
|
|
||||
|
// Build: * > eu > nl > nl-ge > nl-ge-nij (a single deep chain)
|
||||
|
static void buildNlChain(RegionMap& rm) { |
||||
|
RegionEntry* eu = rm.putRegion("eu", 0); |
||||
|
RegionEntry* nl = rm.putRegion("nl", eu->id); |
||||
|
RegionEntry* nlge = rm.putRegion("nl-ge", nl->id); |
||||
|
rm.putRegion("nl-ge-nij", nlge->id); |
||||
|
} |
||||
|
|
||||
|
// Build: * > cz > { cz-ulk, cz-stc, cz-lbk } (three regions on the same level)
|
||||
|
static void buildCzFlat(RegionMap& rm) { |
||||
|
RegionEntry* cz = rm.putRegion("cz", 0); |
||||
|
rm.putRegion("cz-ulk", cz->id); |
||||
|
rm.putRegion("cz-stc", cz->id); |
||||
|
rm.putRegion("cz-lbk", cz->id); |
||||
|
} |
||||
|
|
||||
|
TEST(RegionGating, DepthAndMaxGateLevel) { |
||||
|
TransportKeyStore ks; |
||||
|
RegionMap rm(ks); |
||||
|
buildNlChain(rm); |
||||
|
EXPECT_EQ(rm.getMaxDepth(), 4); |
||||
|
EXPECT_EQ(rm.getMaxGateLevel(), 4); |
||||
|
} |
||||
|
|
||||
|
TEST(RegionGating, GatesWildcardFirstThenOutermostInward) { |
||||
|
TransportKeyStore ks; |
||||
|
RegionMap rm(ks); |
||||
|
buildNlChain(rm); |
||||
|
|
||||
|
rm.applyDutyGate(0); |
||||
|
EXPECT_FALSE(gated(rm, "*")); |
||||
|
EXPECT_FALSE(gated(rm, "eu")); |
||||
|
|
||||
|
rm.applyDutyGate(1); // wildcard first
|
||||
|
EXPECT_TRUE(gated(rm, "*")); |
||||
|
EXPECT_FALSE(gated(rm, "eu")); |
||||
|
|
||||
|
rm.applyDutyGate(2); // + broadest named region
|
||||
|
EXPECT_TRUE(gated(rm, "*")); |
||||
|
EXPECT_TRUE(gated(rm, "eu")); |
||||
|
EXPECT_FALSE(gated(rm, "nl")); |
||||
|
|
||||
|
rm.applyDutyGate(3); |
||||
|
EXPECT_TRUE(gated(rm, "nl")); |
||||
|
EXPECT_FALSE(gated(rm, "nl-ge")); |
||||
|
|
||||
|
rm.applyDutyGate(4); // max: everything except the innermost cluster
|
||||
|
EXPECT_TRUE(gated(rm, "nl-ge")); |
||||
|
EXPECT_FALSE(gated(rm, "nl-ge-nij")); // innermost local cluster is always protected
|
||||
|
} |
||||
|
|
||||
|
TEST(RegionGating, InnermostClusterNeverGatedEvenAboveMax) { |
||||
|
TransportKeyStore ks; |
||||
|
RegionMap rm(ks); |
||||
|
buildNlChain(rm); |
||||
|
rm.applyDutyGate(99); // clamped behaviour
|
||||
|
EXPECT_TRUE(gated(rm, "nl-ge")); |
||||
|
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
||||
|
} |
||||
|
|
||||
|
TEST(RegionGating, RecoveryReenablesInsideOut) { |
||||
|
TransportKeyStore ks; |
||||
|
RegionMap rm(ks); |
||||
|
buildNlChain(rm); |
||||
|
|
||||
|
rm.applyDutyGate(4); |
||||
|
EXPECT_TRUE(gated(rm, "eu")); |
||||
|
EXPECT_TRUE(gated(rm, "nl-ge")); |
||||
|
|
||||
|
rm.applyDutyGate(2); // step back down: nl and nl-ge come back first
|
||||
|
EXPECT_TRUE(gated(rm, "*")); |
||||
|
EXPECT_TRUE(gated(rm, "eu")); |
||||
|
EXPECT_FALSE(gated(rm, "nl")); |
||||
|
EXPECT_FALSE(gated(rm, "nl-ge")); |
||||
|
|
||||
|
rm.applyDutyGate(0); // fully recovered
|
||||
|
EXPECT_FALSE(gated(rm, "*")); |
||||
|
EXPECT_FALSE(gated(rm, "eu")); |
||||
|
} |
||||
|
|
||||
|
TEST(RegionGating, MultipleRegionsOnSameLevelGateTogetherAndLeavesProtected) { |
||||
|
TransportKeyStore ks; |
||||
|
RegionMap rm(ks); |
||||
|
buildCzFlat(rm); |
||||
|
EXPECT_EQ(rm.getMaxDepth(), 2); |
||||
|
EXPECT_EQ(rm.getMaxGateLevel(), 2); |
||||
|
|
||||
|
rm.applyDutyGate(1); // wildcard only
|
||||
|
EXPECT_TRUE(gated(rm, "*")); |
||||
|
EXPECT_FALSE(gated(rm, "cz")); |
||||
|
|
||||
|
rm.applyDutyGate(2); // wildcard + cz; the three leaf regions stay up
|
||||
|
EXPECT_TRUE(gated(rm, "cz")); |
||||
|
EXPECT_FALSE(gated(rm, "cz-ulk")); |
||||
|
EXPECT_FALSE(gated(rm, "cz-stc")); |
||||
|
EXPECT_FALSE(gated(rm, "cz-lbk")); |
||||
|
} |
||||
|
|
||||
|
TEST(RegionGating, LoneWildcardIsNeverGated) { |
||||
|
TransportKeyStore ks; |
||||
|
RegionMap rm(ks); // no named regions
|
||||
|
EXPECT_EQ(rm.getMaxDepth(), 0); |
||||
|
EXPECT_EQ(rm.getMaxGateLevel(), 0); |
||||
|
rm.applyDutyGate(1); |
||||
|
EXPECT_FALSE(gated(rm, "*")); // wildcard is the local cluster here, keep serving
|
||||
|
} |
||||
|
|
||||
|
TEST(RegionGating, SingleNamedRegionGatesWildcardButKeepsRegion) { |
||||
|
TransportKeyStore ks; |
||||
|
RegionMap rm(ks); |
||||
|
rm.putRegion("cz", 0); |
||||
|
EXPECT_EQ(rm.getMaxGateLevel(), 1); |
||||
|
rm.applyDutyGate(1); |
||||
|
EXPECT_TRUE(gated(rm, "*")); |
||||
|
EXPECT_FALSE(gated(rm, "cz")); |
||||
|
} |
||||
|
|
||||
|
TEST(RegionGating, RuntimeGateIsSeparateFromConfigFlags) { |
||||
|
TransportKeyStore ks; |
||||
|
RegionMap rm(ks); |
||||
|
buildNlChain(rm); |
||||
|
|
||||
|
RegionEntry* eu = rm.findByName("eu"); |
||||
|
eu->flags &= (uint8_t)~REGION_DENY_FLOOD; // admin: this region allows flood
|
||||
|
|
||||
|
rm.applyDutyGate(2); // transiently gate 'eu'
|
||||
|
EXPECT_TRUE(eu->rt_flags & REGION_DENY_FLOOD); |
||||
|
EXPECT_FALSE(eu->flags & REGION_DENY_FLOOD); // config untouched
|
||||
|
EXPECT_TRUE(eu->effectiveFlags() & REGION_DENY_FLOOD); |
||||
|
|
||||
|
rm.applyDutyGate(0); // recover
|
||||
|
EXPECT_FALSE(eu->rt_flags & REGION_DENY_FLOOD); |
||||
|
EXPECT_FALSE(eu->effectiveFlags() & REGION_DENY_FLOOD); // flood allowed again
|
||||
|
} |
||||
|
|
||||
|
int main(int argc, char** argv) { |
||||
|
::testing::InitGoogleTest(&argc, argv); |
||||
|
return RUN_ALL_TESTS(); |
||||
|
} |
||||
Loading…
Reference in new issue