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

+44 -1
View File
@@ -3344,6 +3344,49 @@ The count, path, group-data path, and advert controls work on repeater, room-ser
firmware. Flood forwarding must also be enabled for retries to run. Prefix,
ignore, bridge, and bucket controls are repeater-only.
#### Hop-zero flood retry coding rate
Flood packets with no path hops recorded yet use the same CR escalation ladder
as direct retries. The starting point is the active radio CR, since a flood has
no single next-hop repeater whose SNR can select a starting CR. Each retry uses
its attempt number and the active radio setting; it does not restart the ladder
from the previous retry's override. Forwarded packets with one or more recorded
hops use the active radio CR on every retry, including bridge retries whose
effective path length discounts ignored hops.
The initial transmission uses the active radio CR. This table lists retries
after that initial transmission; any later retries remain at CR8:
| Starting CR | Retry 1 | Retry 2 | Retry 3 | Retry 4 | Retry 5+ |
| --- | --- | --- | --- | --- | --- |
| CR4 | CR4 | CR5 | CR7 | CR7 | CR8 |
| CR5 | CR5 | CR7 | CR7 | CR8 | CR8 |
| CR6 | CR6 | CR7 | CR7 | CR8 | CR8 |
| CR7 | CR7 | CR7 | CR8 | CR8 | CR8 |
| CR8 | CR8 | CR8 | CR8 | CR8 | CR8 |
The ladder is shared across presets. Presets change the retry budget, not the
CR steps. At CR5, the maximum hop-zero schedules are:
| Preset | Configured flood count | Hop-zero retries | CR schedule after the initial send |
| --- | ---: | ---: | --- |
| `infra` | 1 | 2 | CR5, CR7 |
| `rooftop` | 3 | 6 | CR5, CR7, CR7, CR8, CR8, CR8 |
| `mobile` | 15 | 15 | CR5, CR7, CR7, then CR8 for retries 4-15 |
These budgets apply to group text and the origin login/message family
(`RESPONSE`, `TXT_MSG`, `ANON_REQ`, `PATH`). Existing payload caps, echo
cancellation, and retry eligibility still apply: `REQ` and OTA do not use
generic flood retries; other payloads allow at most one retry. An eligible
advert at CR5 therefore gets only its first CR5 retry.
`set flood.retry.count 0` continues to disable all automatic flood retries,
including hop zero. It leaves the initial transmission and all direct-retry
settings and CR schedules unchanged. Direct retry's `direct.retry.cr` switch
and SNR thresholds continue to control direct retries only. Per-packet CR
overrides are restored after transmission and do not change the saved radio
setting or physical preamble.
#### View or change flood retry count
**Search terms:** flood tx retries, flood retry attempts, flood retransmissions, broadcast retries.
@@ -3690,7 +3733,7 @@ set direct.retry.margin 10
- Non-repeater retry packets start at the current radio CR and follow the same escalation pattern, clamped at CR8. With the normal CR5 radio setting this is CR5, CR7, CR7, then CR8.
- Room-server and sensor firmware accept `on` or `off`; numeric SNR thresholds
are repeater-only because those roles do not keep recent-repeater SNR data.
- `off` disables per-packet retry CR overrides and uses the current radio CR.
- `off` disables per-packet direct-retry CR overrides and uses the current radio CR.
- Retry packets may use a different coding rate, but they keep the radio's normal physical preamble.
- Unknown repeaters start at `+3.00 dB` for adaptive CR selection.
- A failed unknown repeater is seeded at `+2.75 dB`.
+10
View File
@@ -332,6 +332,16 @@ The shared retry preset sets these flood defaults:
| `rooftop` | `3` | `2` | `1` |
| `mobile` | `15` | `1` | `1` |
Hop-zero flood retries use the direct-retry CR ladder starting at the active
radio CR. With CR5, `infra` allows CR5, CR7; `rooftop` allows CR5, CR7, CR7,
CR8, CR8, CR8; `mobile` allows CR5, CR7, CR7, then CR8 through retry 15.
These lists exclude the initial send and remain subject to payload caps and
echo cancellation. Floods with recorded hops keep the active CR.
`flood.retry.count 0` still disables every automatic flood retry, including
hop zero, without changing direct-retry settings or their CR schedule.
See [hop-zero flood retry coding rate](cli_commands.md#hop-zero-flood-retry-coding-rate)
for every starting CR and the payload limits.
Example for path-gated retry:
```text
+15 -2
View File
@@ -145,6 +145,19 @@ void Mesh::configureDirectRetryPacket(Packet* retry, const Packet* original, uin
retry->tx_cr = getDirectRetryCodingRateForAttempt(default_cr, retry_attempt);
}
void Mesh::configureFloodRetryPacket(Packet* retry, const Packet* original, uint8_t retry_attempt) {
if (retry == NULL) return;
// Always start from the active radio setting, not the previous retry's
// override. Only the originating sender has no hops recorded yet; bridge
// path adjustments must not make a forwarded packet eligible for this.
const uint8_t default_cr = getDefaultTxCodingRate();
retry->tx_cr = original != NULL && original->isRouteFlood()
&& original->getPathHashCount() == 0
? getDirectRetryCodingRateForAttempt(default_cr, retry_attempt)
: default_cr;
}
static bool isPrimaryOtaTraffic(const uint8_t* msg, uint16_t len) {
return msg && len && ota::ota_is_transfer_message(msg[0]);
}
@@ -2367,7 +2380,7 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) {
}
*retry = *packet;
retry->tx_cr = getDefaultTxCodingRate();
configureFloodRetryPacket(retry, packet, _flood_retries[i].retry_attempts_sent + 1);
uint32_t retry_delay = getFloodRetryAttemptDelay(packet, _flood_retries[i].retry_attempts_sent);
if (queueOutboundPacket(retry, _flood_retries[i].priority, retry_delay)) {
_flood_retries[i].packet = retry;
@@ -2403,7 +2416,7 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) {
}
*retry = *packet;
retry->tx_cr = getDefaultTxCodingRate();
configureFloodRetryPacket(retry, packet, 1);
if (queueOutboundPacket(retry, _flood_retries[i].priority, _flood_retries[i].retry_delay)) {
unsigned long now = _ms->getMillis();
_flood_retries[i].packet = retry;
+6
View File
@@ -416,6 +416,12 @@ protected:
*/
static uint8_t getDirectRetryCodingRateForAttempt(uint8_t start_cr, uint8_t retry_attempt);
/**
* \brief Apply the direct-retry CR ladder to hop-zero flood retries, starting
* from the active radio CR. Forwarded floods keep the active CR.
*/
void configureFloodRetryPacket(Packet* retry, const Packet* original, uint8_t retry_attempt);
/**
* \brief Optional hook to set local-only transmit options on a retry packet before it is queued.
*/
+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;