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 |
|||
|
|||
// Mock Stream class for native testing
|
|||
// Provides minimal interface needed by Utils.h
|
|||
// Mock Stream class for native testing.
|
|||
// 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 { |
|||
public: |
|||
virtual void print(char c) {} |
|||
virtual void print(const char* str) {} |
|||
virtual ~Stream() {} |
|||
|
|||
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