Skip to content

Commit 75e5b11

Browse files
committed
Add configurable loop detection system (MeshCore 1.14 compatible)
Implements four loop detection modes for flood routing: - off: No loop detection (allow unlimited repeats) - minimal: Allow up to 4 occurrences in path - moderate: Allow up to 2 occurrences in path - strict: Allow only 1 occurrence (default, backward compatible) Features: - New CLI commands: get/set loop.detect - EEPROM persistence across reboots - Default mode 'strict' maintains original behavior - Configurable per-node via serial or remote CLI Test script included: tools/test_loop_detect.py Version bumped to 0.6.0
1 parent d30bd80 commit 75e5b11

7 files changed

Lines changed: 240 additions & 8 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@ MIT License - See LICENSE file for details.
195195

196196
## Changelog
197197

198+
### v0.6.0 (2026-03-06)
199+
- **Loop Detection System** - MeshCore 1.14 compatible configurable loop detection
200+
- Four modes: `off`, `minimal` (4+ occurrences), `moderate` (2+), `strict` (1, default)
201+
- New commands: `get loop.detect`, `set loop.detect {off|minimal|moderate|strict}`
202+
- Backward compatible: default mode `strict` maintains original behavior
203+
- Stored in EEPROM, survives reboots
204+
- Test script: `tools/test_loop_detect.py`
205+
198206
### v0.5.2 (2026-02-15)
199207
- **Extended MeshCore CLI** - 23 new commands for full MeshCore standard compatibility
200208
- `get` aliases: `get name`, `get lat`, `get lon`, `get tx`, `get radio`, `get freq`, `get repeat`, `get flood.max`, `get advert.interval`, `get guest.password`, `get public.key`

src/core/Config.cpp

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const NodeConfig defaultConfig = {
3030
{0}, // reportDestPubKey (empty)
3131
false, // alertEnabled
3232
{0}, // alertDestPubKey (empty)
33+
LOOP_DETECT_STRICT, // loopDetectMode (default: strict for backward compat)
3334
{0} // reserved
3435
};
3536

@@ -65,9 +66,14 @@ void loadConfig() {
6566
alertEnabled = config.alertEnabled;
6667
memcpy(alertDestPubKey, config.alertDestPubKey, REPORT_PUBKEY_SIZE);
6768

68-
CONFIG_LOG("[C] Loaded (report=%s, alert=%s)\n\r",
69+
// Load loop detection mode
70+
loopDetectMode = config.loopDetectMode;
71+
if (loopDetectMode > LOOP_DETECT_STRICT) loopDetectMode = LOOP_DETECT_STRICT;
72+
73+
CONFIG_LOG("[C] Loaded (report=%s, alert=%s, loopDetect=%d)\n\r",
6974
reportEnabled ? "on" : "off",
70-
alertEnabled ? "on" : "off");
75+
alertEnabled ? "on" : "off",
76+
loopDetectMode);
7177
} else {
7278
// First boot or version mismatch - use defaults
7379
powerSaveMode = defaultConfig.powerSaveMode;
@@ -88,6 +94,9 @@ void loadConfig() {
8894
alertEnabled = defaultConfig.alertEnabled;
8995
memset(alertDestPubKey, 0, REPORT_PUBKEY_SIZE);
9096

97+
// Set default loop detection
98+
loopDetectMode = defaultConfig.loopDetectMode;
99+
91100
CONFIG_LOG("[C] First boot, using defaults\n\r");
92101
saveConfig(); // Save defaults
93102
}
@@ -117,6 +126,9 @@ void saveConfig() {
117126
config.alertEnabled = alertEnabled;
118127
memcpy(config.alertDestPubKey, alertDestPubKey, REPORT_PUBKEY_SIZE);
119128

129+
// Save loop detection mode
130+
config.loopDetectMode = loopDetectMode;
131+
120132
memset(config.reserved, 0, sizeof(config.reserved));
121133

122134
EEPROM.put(0, config);
@@ -146,6 +158,9 @@ void resetConfig() {
146158
alertEnabled = defaultConfig.alertEnabled;
147159
memset(alertDestPubKey, 0, REPORT_PUBKEY_SIZE);
148160

161+
// Reset loop detection mode
162+
loopDetectMode = defaultConfig.loopDetectMode;
163+
149164
saveConfig();
150165
CONFIG_LOG("[C] Reset to factory defaults\n\r");
151166
}

src/core/Config.h

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@
2626
#define CONFIG_PASSWORD_LEN 16
2727
#endif
2828

29+
//=============================================================================
30+
// Loop Detection Modes
31+
//=============================================================================
32+
#define LOOP_DETECT_OFF 0 // No loop detection (allow unlimited)
33+
#define LOOP_DETECT_MINIMAL 1 // Minimal: allow 4+ repeats in path
34+
#define LOOP_DETECT_MODERATE 2 // Moderate: allow 2+ repeats
35+
#define LOOP_DETECT_STRICT 3 // Strict: allow only 1 occurrence (original behavior)
36+
2937
//=============================================================================
3038
// NodeConfig structure
3139
//=============================================================================
@@ -45,7 +53,8 @@ struct NodeConfig {
4553
uint8_t reportDestPubKey[REPORT_PUBKEY_SIZE];
4654
bool alertEnabled;
4755
uint8_t alertDestPubKey[REPORT_PUBKEY_SIZE];
48-
uint8_t reserved[4];
56+
uint8_t loopDetectMode; // 0=off, 1=minimal, 2=moderate, 3=strict
57+
uint8_t reserved[3];
4958
};
5059
#endif
5160

src/core/globals.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ bool deepSleepEnabled = MC_DEEP_SLEEP_ENABLED;
7272
bool rxBoostEnabled = MC_RX_BOOST_ENABLED;
7373
uint8_t powerSaveMode = 1;
7474

75+
//=============================================================================
76+
// Loop Detection
77+
//=============================================================================
78+
uint8_t loopDetectMode = LOOP_DETECT_STRICT; // Default: strict (backward compat)
79+
7580
//=============================================================================
7681
// Timing
7782
//=============================================================================

src/core/globals.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ extern bool deepSleepEnabled;
9393
extern bool rxBoostEnabled;
9494
extern uint8_t powerSaveMode;
9595

96+
//=============================================================================
97+
// Loop Detection
98+
//=============================================================================
99+
extern uint8_t loopDetectMode; // 0=off, 1=minimal, 2=moderate, 3=strict
100+
96101
//=============================================================================
97102
// Timing
98103
//=============================================================================

src/main.cpp

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,31 @@ static bool dispatchSharedCommand(const char* cmd, CmdCtx& ctx, bool isAdmin) {
445445
uint8_t hops = atoi(cmd + 14);
446446
if (hops >= 1 && hops <= 15) { repeaterHelper.setMaxFloodHops(hops); CP("hops:%d\n", hops); }
447447
}
448+
else if (strcmp(cmd, "get loop.detect") == 0) {
449+
const char* mode = (loopDetectMode == LOOP_DETECT_OFF) ? "off" :
450+
(loopDetectMode == LOOP_DETECT_MINIMAL) ? "minimal" :
451+
(loopDetectMode == LOOP_DETECT_MODERATE) ? "moderate" : "strict";
452+
CP("> %s\n", mode);
453+
}
454+
else if (strncmp(cmd, "set loop.detect ", 16) == 0) {
455+
const char* arg = cmd + 16;
456+
uint8_t newMode = 0xFF;
457+
if (strcmp(arg, "off") == 0) newMode = LOOP_DETECT_OFF;
458+
else if (strcmp(arg, "minimal") == 0) newMode = LOOP_DETECT_MINIMAL;
459+
else if (strcmp(arg, "moderate") == 0) newMode = LOOP_DETECT_MODERATE;
460+
else if (strcmp(arg, "strict") == 0) newMode = LOOP_DETECT_STRICT;
461+
462+
if (newMode != 0xFF) {
463+
loopDetectMode = newMode;
464+
saveConfig();
465+
const char* mode = (loopDetectMode == LOOP_DETECT_OFF) ? "off" :
466+
(loopDetectMode == LOOP_DETECT_MINIMAL) ? "minimal" :
467+
(loopDetectMode == LOOP_DETECT_MODERATE) ? "moderate" : "strict";
468+
CP("loop.detect=%s\n", mode);
469+
} else {
470+
CP("E:off|minimal|moderate|strict\n");
471+
}
472+
}
448473
else if (strncmp(cmd, "set name ", 9) == 0) {
449474
const char* n = cmd + 9;
450475
if (strlen(n) > 0 && strlen(n) < 16) {
@@ -2148,15 +2173,42 @@ bool shouldForward(MCPacket* pkt) {
21482173
return false;
21492174
}
21502175

2151-
// FLOOD: check path length and loop prevention
2176+
// FLOOD: check path length and loop detection
21522177
if (isFlood) {
21532178
if (pkt->pathLen >= MC_MAX_PATH_SIZE - 1) {
21542179
return false;
21552180
}
2156-
// Loop prevention: don't forward if we're already in the path
2157-
uint8_t myHash = nodeIdentity.getNodeHash();
2158-
for (uint8_t i = 0; i < pkt->pathLen; i++) {
2159-
if (pkt->path[i] == myHash) return false;
2181+
2182+
// Loop detection with configurable strictness
2183+
if (loopDetectMode != LOOP_DETECT_OFF) {
2184+
uint8_t myHash = nodeIdentity.getNodeHash();
2185+
uint8_t occurrences = 0;
2186+
2187+
// Count how many times we appear in the path
2188+
for (uint8_t i = 0; i < pkt->pathLen; i++) {
2189+
if (pkt->path[i] == myHash) {
2190+
occurrences++;
2191+
}
2192+
}
2193+
2194+
// Check against threshold based on mode
2195+
uint8_t maxOccurrences;
2196+
switch (loopDetectMode) {
2197+
case LOOP_DETECT_MINIMAL:
2198+
maxOccurrences = 4; // Allow up to 4 occurrences
2199+
break;
2200+
case LOOP_DETECT_MODERATE:
2201+
maxOccurrences = 2; // Allow up to 2 occurrences
2202+
break;
2203+
case LOOP_DETECT_STRICT:
2204+
default:
2205+
maxOccurrences = 1; // Allow only 1 occurrence (original behavior)
2206+
break;
2207+
}
2208+
2209+
if (occurrences >= maxOccurrences) {
2210+
return false; // Loop detected
2211+
}
21602212
}
21612213
}
21622214

tools/test_loop_detect.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Test script for Loop Detection feature
4+
Tests get/set loop.detect commands via serial interface
5+
"""
6+
7+
import serial
8+
import time
9+
import sys
10+
import argparse
11+
12+
def send_command(ser, cmd, timeout=2.0):
13+
"""Send command and wait for response"""
14+
ser.write((cmd + '\r\n').encode())
15+
ser.flush()
16+
time.sleep(0.1)
17+
18+
start = time.time()
19+
response = ""
20+
while (time.time() - start) < timeout:
21+
if ser.in_waiting:
22+
chunk = ser.read(ser.in_waiting).decode('utf-8', errors='ignore')
23+
response += chunk
24+
if '\n' in chunk:
25+
break
26+
time.sleep(0.01)
27+
28+
return response.strip()
29+
30+
def test_loop_detect(port, baudrate=115200):
31+
"""Test loop detection commands"""
32+
print(f"Testing Loop Detection on {port} @ {baudrate}")
33+
print("=" * 60)
34+
35+
try:
36+
ser = serial.Serial(port, baudrate, timeout=1)
37+
time.sleep(0.5) # Wait for connection
38+
39+
# Clear any pending data
40+
ser.reset_input_buffer()
41+
42+
# Test 1: Get current mode
43+
print("\n1. Getting current loop.detect mode:")
44+
response = send_command(ser, "get loop.detect")
45+
print(f" Response: {response}")
46+
assert "> " in response, "get loop.detect failed"
47+
48+
# Test 2: Set to OFF
49+
print("\n2. Setting loop.detect to OFF:")
50+
response = send_command(ser, "set loop.detect off")
51+
print(f" Response: {response}")
52+
assert "loop.detect=off" in response, "set loop.detect off failed"
53+
54+
# Verify
55+
response = send_command(ser, "get loop.detect")
56+
print(f" Verify: {response}")
57+
assert "> off" in response, "verify off failed"
58+
59+
# Test 3: Set to MINIMAL
60+
print("\n3. Setting loop.detect to MINIMAL:")
61+
response = send_command(ser, "set loop.detect minimal")
62+
print(f" Response: {response}")
63+
assert "loop.detect=minimal" in response, "set loop.detect minimal failed"
64+
65+
# Verify
66+
response = send_command(ser, "get loop.detect")
67+
print(f" Verify: {response}")
68+
assert "> minimal" in response, "verify minimal failed"
69+
70+
# Test 4: Set to MODERATE
71+
print("\n4. Setting loop.detect to MODERATE:")
72+
response = send_command(ser, "set loop.detect moderate")
73+
print(f" Response: {response}")
74+
assert "loop.detect=moderate" in response, "set loop.detect moderate failed"
75+
76+
# Verify
77+
response = send_command(ser, "get loop.detect")
78+
print(f" Verify: {response}")
79+
assert "> moderate" in response, "verify moderate failed"
80+
81+
# Test 5: Set to STRICT (default)
82+
print("\n5. Setting loop.detect to STRICT:")
83+
response = send_command(ser, "set loop.detect strict")
84+
print(f" Response: {response}")
85+
assert "loop.detect=strict" in response, "set loop.detect strict failed"
86+
87+
# Verify
88+
response = send_command(ser, "get loop.detect")
89+
print(f" Verify: {response}")
90+
assert "> strict" in response, "verify strict failed"
91+
92+
# Test 6: Invalid mode
93+
print("\n6. Testing invalid mode:")
94+
response = send_command(ser, "set loop.detect invalid")
95+
print(f" Response: {response}")
96+
assert "E:" in response, "invalid mode should return error"
97+
98+
print("\n" + "=" * 60)
99+
print("✓ All Loop Detection tests PASSED!")
100+
print("=" * 60)
101+
102+
ser.close()
103+
return True
104+
105+
except serial.SerialException as e:
106+
print(f"\n✗ Serial error: {e}")
107+
return False
108+
except AssertionError as e:
109+
print(f"\n✗ Test failed: {e}")
110+
return False
111+
except Exception as e:
112+
print(f"\n✗ Unexpected error: {e}")
113+
return False
114+
115+
def main():
116+
parser = argparse.ArgumentParser(description='Test Loop Detection feature')
117+
parser.add_argument('port', nargs='?', default=None, help='Serial port (e.g., /dev/ttyUSB0)')
118+
parser.add_argument('-b', '--baudrate', type=int, default=115200, help='Baud rate (default: 115200)')
119+
args = parser.parse_args()
120+
121+
port = args.port
122+
if not port:
123+
# Try to auto-detect
124+
import glob
125+
ports = glob.glob('/dev/ttyUSB*') + glob.glob('/dev/ttyACM*') + glob.glob('COM*')
126+
if ports:
127+
port = ports[0]
128+
print(f"Auto-detected port: {port}")
129+
else:
130+
print("Error: No serial port specified and none auto-detected")
131+
print("Usage: python test_loop_detect.py <port>")
132+
sys.exit(1)
133+
134+
success = test_loop_detect(port, args.baudrate)
135+
sys.exit(0 if success else 1)
136+
137+
if __name__ == '__main__':
138+
main()

0 commit comments

Comments
 (0)