Escalate coding rate on hop-zero flood retries

This commit is contained in:
mikecarper committed 2026-09-10 11:17:58 -07:00
1 parent b8ecd7fcac
commit 499e5bc94a
6 files changed
+415 -3

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Execute preset application, role retry budgets, and the flood-count setter."""
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
class RetryCrPresetsTest(unittest.TestCase):
def test_presets_and_disabling_flood_leave_direct_settings_unchanged(self):
compiler = shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
header = (ROOT / "src/helpers/CommonCLI.h").read_text()
core = (ROOT / "src/Mesh.cpp").read_text()
repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
definitions = "\n".join(re.findall(
r"^#define (?:RETRY_PRESET_|DIRECT_RETRY_|FLOOD_RETRY_)[A-Z0-9_]+[^\n]*",
header, re.M))
definitions += "\n" + re.search(
r"#define FLOOD_RETRY_PATH_GATE_DISABLED[^\n]*",
(ROOT / "src/Mesh.h").read_text()).group()
generated = definitions + r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
#define constrain(x, low, high) ((x) < (low) ? (low) : (x) > (high) ? (high) : (x))
struct NodePrefs {
uint8_t retry_preset, direct_retry_attempts, direct_retry_enabled;
uint8_t direct_retry_cr_enabled, direct_retry_recent_enabled;
uint8_t direct_retry_prefs_magic[2], flood_retry_attempts;
uint8_t flood_retry_max_path, flood_retry_group_max_path, disable_fwd;
uint16_t direct_retry_base_ms, direct_retry_step_ms, direct_retry_snr_margin_x4;
int8_t direct_retry_cr4_snr_x4, direct_retry_cr5_snr_x4;
int8_t direct_retry_cr7_snr_x4, direct_retry_cr8_snr_x4;
};
namespace mesh {
struct Packet { uint8_t hops = 0; uint8_t getPathHashCount() const { return hops; } };
class Mesh { public:
static uint8_t getDirectRetryCodingRateForAttempt(uint8_t, uint8_t);
};
}
class MyMesh : public mesh::Mesh {
public:
NodePrefs _prefs{};
unsigned direct_cancellations = 0, flood_cancellations = 0;
uint8_t getFloodRetryMaxAttempts(const mesh::Packet*) const;
uint8_t roomFloodAttempts(const mesh::Packet*) const;
uint8_t sensorFloodAttempts(const mesh::Packet*) const;
uint8_t getDirectRetryCodingRateForSNR(int8_t) const;
void onRetryConfigChanged();
void cancelAllDirectRetries() { ++direct_cancellations; }
void cancelAllFloodRetries() { ++flood_cancellations; }
};
struct CLI {
NodePrefs* _prefs;
MyMesh* _callbacks;
unsigned saves = 0;
void savePrefs() { ++saves; }
int _atoi(const char* value) { return atoi(value); }
void set(const char* config, char* reply);
};
'''
for signature in ("static void markDirectRetryPrefsValid(",
"static void applyFloodRetryPreset(",
"static void applyDirectRetryPreset(",
"static void setDefaultDirectRetryPrefs(",
"static bool looksUnsignedInteger("):
generated += extract_braced(common, signature) + "\n"
generated += "namespace mesh {\n" + extract_braced(
core, "uint8_t Mesh::getDirectRetryCodingRateForAttempt(") + "\n}\n"
for signature in ("uint8_t MyMesh::getFloodRetryMaxAttempts(",
"uint8_t MyMesh::getDirectRetryCodingRateForSNR(",
"void MyMesh::onRetryConfigChanged("):
generated += extract_braced(repeater, signature) + "\n"
for role, owner, method in (("simple_room_server", "MyMesh", "roomFloodAttempts"),
("simple_sensor", "SensorMesh", "sensorFloodAttempts")):
source = (ROOT / f"examples/{role}/{owner}.cpp").read_text()
budget = extract_braced(source, f"uint8_t {owner}::getFloodRetryMaxAttempts(")
generated += budget.replace(f"{owner}::getFloodRetryMaxAttempts", f"MyMesh::{method}") + "\n"
setter = extract_braced(common, 'if (memcmp(config, "flood.retry.count ", 18) == 0)')
generated += "void CLI::set(const char* config, char* reply) { " + setter + " }\n"
generated += r'''
static std::vector<int> directSettings(const NodePrefs& p) {
return {p.direct_retry_attempts, p.direct_retry_enabled, p.direct_retry_cr_enabled,
p.direct_retry_recent_enabled, p.direct_retry_base_ms, p.direct_retry_step_ms,
p.direct_retry_snr_margin_x4, p.direct_retry_cr4_snr_x4, p.direct_retry_cr5_snr_x4,
p.direct_retry_cr7_snr_x4, p.direct_retry_cr8_snr_x4};
}
static std::vector<uint8_t> directSchedule(const MyMesh& node, int8_t snr) {
std::vector<uint8_t> rates;
for (uint8_t attempt = 1; attempt <= node._prefs.direct_retry_attempts; ++attempt) {
rates.push_back(node.getDirectRetryCodingRateForAttempt(
node.getDirectRetryCodingRateForSNR(snr), attempt));
}
return rates;
}
int main() {
const uint8_t presets[] = {RETRY_PRESET_INFRA, RETRY_PRESET_ROOFTOP, RETRY_PRESET_MOBILE};
const uint8_t direct_counts[] = {4, 15, 15};
const uint8_t origin_counts[] = {2, 6, 15};
const uint8_t expected_cr5[] = {5, 7, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8};
for (unsigned preset = 0; preset < 3; ++preset) {
for (int8_t snr : {-40, 10, 12, 30, 40}) {
MyMesh node;
setDefaultDirectRetryPrefs(&node._prefs);
applyDirectRetryPreset(&node._prefs, presets[preset]);
assert(node._prefs.direct_retry_attempts == direct_counts[preset]);
mesh::Packet packet;
assert(node.getFloodRetryMaxAttempts(&packet) == origin_counts[preset]);
assert(node.roomFloodAttempts(&packet) == origin_counts[preset]);
assert(node.sensorFloodAttempts(&packet) == origin_counts[preset]);
for (uint8_t i = 0; i < origin_counts[preset]; ++i) {
assert(node.getDirectRetryCodingRateForAttempt(5, i + 1) == expected_cr5[i]);
}
const auto settings = directSettings(node._prefs);
const auto schedule = directSchedule(node, snr);
CLI cli{&node._prefs, &node};
char reply[160] = {};
cli.set("flood.retry.count 0", reply);
assert(strcmp(reply, "OK") == 0 && cli.saves == 1);
assert(node._prefs.retry_preset == RETRY_PRESET_CUSTOM);
assert(node.getFloodRetryMaxAttempts(&packet) == 0);
assert(node.roomFloodAttempts(&packet) == 0);
assert(node.sensorFloodAttempts(&packet) == 0);
assert(node.flood_cancellations == 1 && node.direct_cancellations == 0);
assert(directSettings(node._prefs) == settings);
assert(directSchedule(node, snr) == schedule);
}
}
puts("15 preset/disabled-flood schedule checks passed");
}
'''
with tempfile.TemporaryDirectory(prefix=".tmp-retry-cr-", dir=ROOT) as directory:
work = Path(directory)
source = work / "retry-cr.cpp"
source.write_text(generated, encoding="ascii")
binary = work / "retry-cr"
result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra",
str(source), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("15 preset/disabled-flood schedule checks passed", checked.stdout)
if __name__ == "__main__":
unittest.main()
+181
View File
@@ -6,6 +6,7 @@
#include <helpers/FloodAdvertCLI.h>
#include <helpers/StaticPoolPacketManager.h>
#include <helpers/ota/OtaFormat.h>
#include <vector>
class TraceTestClock : public mesh::MillisecondClock {
public:
@@ -165,6 +166,186 @@ static mesh::Packet makeFloodPacket(uint8_t payload_type) {
return packet;
}
class RetryCodingRateRadio : public TraceTestRadio {
public:
uint8_t cr = 5;
std::vector<uint8_t> transmitted_crs;
bool setCodingRate(uint8_t value) override { cr = value; return true; }
bool startSendRaw(const uint8_t* bytes, int length) override {
transmitted_crs.push_back(cr);
return TraceTestRadio::startSendRaw(bytes, length);
}
};
class RetryCodingRateMesh : public TraceTestMesh {
public:
using TraceTestMesh::TraceTestMesh;
uint8_t base_cr = 5;
uint8_t flood_attempts = 15;
uint8_t direct_attempts = 15;
uint8_t getDefaultTxCodingRate() const override { return base_cr; }
uint8_t getFloodRetryMaxAttempts(const mesh::Packet*) const override { return flood_attempts; }
uint8_t getDirectRetryMaxAttempts(const mesh::Packet*) const override { return direct_attempts; }
bool allowDirectRetry(const mesh::Packet*, const uint8_t*, uint8_t) const override { return true; }
uint8_t floodCR(const mesh::Packet& packet, uint8_t attempt) {
mesh::Packet retry = packet;
configureFloodRetryPacket(&retry, &packet, attempt);
return retry.tx_cr;
}
uint8_t directCR(uint8_t attempt) {
mesh::Packet original, retry;
configureDirectRetryPacket(&retry, &original, attempt);
return retry.tx_cr;
}
void disableFloodRetries() {
flood_attempts = 0;
floodRetriesAllowed = false;
cancelAllFloodRetries();
}
};
class RetryCodingRateTest : public testing::Test {
protected:
TraceTestClock clock;
TraceTestRTC rtc;
TraceTestRNG rng;
RetryCodingRateRadio radio;
TraceTestTables tables;
StaticPoolPacketManager manager{12};
RetryCodingRateMesh node{radio, clock, rng, rtc, manager, tables};
void SetUp() override { node.begin(); }
void transmitNext() {
clock.now += 10000;
node.loop();
ASSERT_TRUE(radio.sending);
radio.complete = true;
++clock.now;
node.loop();
EXPECT_FALSE(radio.sending);
EXPECT_EQ(node.base_cr, radio.cr); // the next packet and RX use the normal CR
}
void queueFlood(bool scoped = false) {
auto* packet = manager.allocNew();
ASSERT_NE(nullptr, packet);
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
if (scoped) {
uint16_t codes[] = {0x1234, 0x5678};
ASSERT_TRUE(node.sendFlood(packet, codes, 0, 3));
} else {
ASSERT_TRUE(node.sendFlood(packet));
}
}
};
TEST_F(RetryCodingRateTest, HopZeroMatchesDirectLadderFromEveryRadioCR) {
for (uint8_t cr : {4, 5, 6, 7, 8}) {
node.base_cr = cr;
for (uint8_t route : {ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD}) {
auto packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
packet.header = route | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT);
packet.tx_cr = 8; // a previous retry must not advance the ladder twice
for (uint8_t hash_size : {1, 2, 3}) {
packet.setPathHashSizeAndCount(hash_size, 0);
for (uint8_t attempt = 1; attempt <= 15; ++attempt) {
SCOPED_TRACE(testing::Message() << "CR" << int(cr) << " retry " << int(attempt));
EXPECT_EQ(node.directCR(attempt), node.floodCR(packet, attempt));
}
}
}
}
}
TEST_F(RetryCodingRateTest, ForwardedFloodsKeepActiveCRAtEveryAttempt) {
auto packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
packet.tx_cr = 8;
for (uint8_t cr : {5, 6, 7, 8}) {
node.base_cr = cr;
for (uint8_t hash_size : {1, 2, 3}) {
for (uint8_t hops : {1, 2, 8}) {
packet.setPathHashSizeAndCount(hash_size, hops);
for (uint8_t attempt = 1; attempt <= 15; ++attempt) {
EXPECT_EQ(cr, node.floodCR(packet, attempt));
}
}
}
}
}
TEST_F(RetryCodingRateTest, HopZeroCR5ScheduleReachesTheRadioForEachPresetBudget) {
// infra, rooftop, mobile hop-zero budgets; the production preset/role
// calculations are exercised separately by test_retry_cr_presets.py.
for (uint8_t attempts : {2, 6, 15}) {
for (bool scoped : {false, true}) {
node.begin();
node.flood_attempts = attempts;
radio.transmitted_crs.clear();
queueFlood(scoped);
std::vector<uint8_t> expected{5}; // initial transmission
for (uint8_t i = 0; i <= attempts; ++i) {
if (i > 0) expected.push_back(i == 1 ? 5 : i <= 3 ? 7 : 8);
transmitNext();
}
EXPECT_EQ(expected, radio.transmitted_crs);
EXPECT_EQ(0, manager.getOutboundTotal());
}
}
}
TEST_F(RetryCodingRateTest, DisabledFloodRetrySendsOnlyTheInitialPacket) {
node.disableFloodRetries();
queueFlood();
transmitNext();
EXPECT_EQ((std::vector<uint8_t>{5}), radio.transmitted_crs);
EXPECT_EQ(0, manager.getOutboundTotal());
}
TEST_F(RetryCodingRateTest, DisablingFloodRetryMidSequenceCancelsEscalatedCopies) {
queueFlood();
transmitNext();
transmitNext();
transmitNext();
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(7, manager.getOutboundByIdx(0)->tx_cr);
node.disableFloodRetries();
clock.now += 10000;
node.loop();
EXPECT_FALSE(radio.sending);
EXPECT_EQ(0, manager.getOutboundTotal());
EXPECT_EQ((std::vector<uint8_t>{5, 5, 7}), radio.transmitted_crs);
}
TEST_F(RetryCodingRateTest, DirectTransmissionsKeepTheirScheduleWithFloodRetryOff) {
for (uint8_t attempts : {4, 15}) {
for (bool flood_enabled : {true, false}) {
node.begin();
node.direct_attempts = attempts;
node.floodRetriesAllowed = flood_enabled;
node.flood_attempts = flood_enabled ? 15 : 0;
radio.transmitted_crs.clear();
auto* packet = node.obtainNewPacket();
ASSERT_NE(nullptr, packet);
packet->header = PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT;
packet->payload_len = 4;
memset(packet->payload, 0x34, packet->payload_len);
const uint8_t path[] = {0x12, 0x34};
ASSERT_TRUE(node.sendDirect(packet, path, sizeof(path)));
std::vector<uint8_t> expected{5};
for (uint8_t i = 0; i <= attempts; ++i) {
if (i > 0) expected.push_back(i == 1 ? 5 : i <= 3 ? 7 : 8);
transmitNext();
}
EXPECT_EQ(expected, radio.transmitted_crs);
EXPECT_EQ(0, manager.getOutboundTotal());
}
}
}
static mesh::Packet makeOtaManifestFragment(uint8_t format_version) {
mesh::Packet packet = makeFloodPacket(PAYLOAD_TYPE_OTA);
packet.payload_len = 12;