Limit Meshtext messages to 15 total attempts

Stop on a matching forwarded echo and preserve lower saved retry settings. Keep scheduled beacons on their separate single-send path.
This commit is contained in:
mikecarper committed 2026-10-23 10:33:21 -07:00
1 parent 51adb8fe1e
commit 99eb24736f
8 files changed
+80 -16

No files matched your search

+7 -4
View File
@@ -23,9 +23,10 @@ Chat remains plain text. A separate GPS-free NodeInfo is sent after the first
successful chat transmission per used channel/identity per boot; the explicit
command can also request one. Automatic metadata waits for a 250 ms gap after
the latest successful MT transmission; explicit requests bypass that gap.
MT heard-repeat retries use the Companion's
`flood.retry.count`, with the metadata cap and `flood.retry.advert` gate for
NodeInfo.
MT messages default to 15 total attempts (the first send plus up to 14 retries)
unless a matching repeat is heard. The Companion's `flood.retry.count` can lower
that budget; `0` or `mt.hops 0` sends once. NodeInfo keeps its metadata cap and
`flood.retry.advert` gate. Scheduled beacons remain fire and forget.
See [carrier-wave RF checks](carrier_wave.md) for `cw on [seconds]`,
`cw2 on [seconds]`, and stopping a carrier with `off`.
@@ -5156,7 +5157,8 @@ section apply to infrastructure roles. Companion does not expose repeater
bridge or prefix retry filters.
On builds with [Meshtext channel chat](radio_profiles.md#heard-repeat-retries),
`flood.retry.count` also limits retries of the node's own MT channel text.
`flood.retry.count` also limits retries of the node's own MT channel text,
with a cap of 15 total attempts (the original plus 14 retries).
Retries reuse the original encoded packet and stop after hearing a matching
forwarded copy with a lower remaining hop limit. This is not a recipient ACK
or a delivery guarantee. NodeInfo permits at most one additional transmission,
@@ -5166,6 +5168,7 @@ received MT traffic or apply MeshCore prefix/bridge rules to MT packets.
`set mt.hops 0` disables heard-repeat retries, without suppressing the original
chat or its first-use NodeInfo. `get mt.retry` reports retry/heard/exhausted
counters, pending work and the configured count without changing settings.
Scheduled beacons always send once per occurrence.
**Search terms:** flood tx retries, flood retry attempts, flood retransmissions, broadcast retries.
+7 -6
View File
@@ -813,9 +813,11 @@ networks or guaranteed message delivery.
#### Heard-repeat retries
MT channel chat uses the Companion's saved `flood.retry.count` budget: `0`
disables retries, and `1`–`15` allows that many extra transmissions after the
initial send. Locally originated MT traffic is hop zero for the
MT channel messages default to **15 total attempts** without a heard repeat:
the initial send and up to 14 retries. They use the Companion's saved
`flood.retry.count`, capped at 14 extra transmissions for MT text. Lower saved
counts retain their extra-retry meaning; `0` disables retries. MeshCore's retry
count meaning is unchanged. Locally originated MT traffic is hop zero for the
`flood.retry.path` gate, so a gate of `0` still permits it; the group-data gate
does not apply to channel text. `mt.hops 0` disables heard-repeat retries
because peers cannot forward a packet with no remaining hop budget; the
@@ -826,9 +828,8 @@ airtime-based flood spacing and the normal CAD and airtime limits rather than
transmitting continuously.
`get mt.retry` is read-only diagnostics: it reports retry transmissions,
matching heard repeats, exhausted retry sequences, pending work and the
configured retry count. Use `set flood.retry.count`, not this getter, to change
the budget.
matching heard repeats, exhausted retry sequences, pending work and the shared
configured retry `count`. Use `set flood.retry.count` to change the budget.
A matching over-air copy with a lower remaining hop limit cancels the retry
sequence. Merely receiving another message, an unchanged-hop copy or a packet
+2
View File
@@ -64,6 +64,8 @@ or Meshtext NodeInfo. Meshtext beacons request no ACK and have no forwarding
budget. MeshCore Public uses one locally transmitted flood; other MeshCore
nodes may relay that flood according to their own policies.
Beacons are **fire and forget**, independent of `flood.retry.count` and MT
message retries. They do not wait for an echo or resend when none is heard.
The transmitter briefly borrows the physical radio, sends once, and restores
its normal receive settings, power, and RX power saving. It does not occupy,
replace, or persist changes to `radio2`, including a temporary second profile.
@@ -460,7 +460,8 @@ uint8_t MyMesh::meshtextRetryLimit(const mesh::mt::PendingFrame& frame) const {
// exist when mt.hops is zero, so don't force all configured duplicates.
if (!(frame.bytes[12] & 7)) return 0;
const uint8_t count = _prefs.flood_retry_attempts > 15 ? 15 : _prefs.flood_retry_attempts;
return frame.nodeinfo ? (_prefs.flood_retry_advert_enabled && count ? 1 : 0) : count;
return frame.nodeinfo ? (_prefs.flood_retry_advert_enabled && count ? 1 : 0)
: mesh::mt::messageRetryLimit(count);
}
uint32_t MyMesh::meshtextRetryDelay(const mesh::mt::PendingFrame& frame) {
+7
View File
@@ -43,4 +43,11 @@ struct PendingFrame {
static constexpr unsigned DeliverySlots = 4;
static constexpr unsigned ChatDeliverySlots = DeliverySlots - 1;
// Text messages get at most 15 transmissions, including the original send.
// Lower saved flood retry counts still mean additional transmissions.
static constexpr uint8_t MaxMessageAttempts = 15;
constexpr uint8_t messageRetryLimit(uint8_t count) {
return count < MaxMessageAttempts ? count : MaxMessageAttempts - 1;
}
} } // namespace mesh::mt
+39 -4
View File
@@ -99,7 +99,7 @@ public:
mesh::RadioProfileCLI _radio_profiles;
struct {
float freq=909.5; char node_name[32]="Test";
uint8_t flood_retry_attempts=15,flood_retry_max_path=1;
uint8_t flood_retry_attempts=@DEFAULT_FLOOD_RETRIES@,flood_retry_max_path=1;
uint8_t flood_retry_group_max_path=0xff,flood_retry_advert_enabled=1;
} _prefs;
mesh::LocalIdentity self_id;
@@ -1016,6 +1016,40 @@ static void automaticNodeinfoQuiet() {
failure.completeForeign(); failure.advance(250); assert(failure.foreign.size()==3);
puts("PASS auto-nodeinfo-quiet");
}
static void defaultTries() {
for (bool echo_on_last : {false, true}) {
Preferences::reset(); MyMesh node; active(node); announce(node);
assert(node._prefs.flood_retry_attempts==15);
char reply[256];
assert(node.handleMeshtextCommand("get mt.retry",reply,sizeof(reply)));
assert(strstr(reply,"count=15"));
const auto before=node.foreign.size();
assert(node.sendMeshtextText(node.stored[0],"fifteen tries",13));
const auto original=node.foreign.back();
const auto next_id=node._mt_packet_id;
node.completeForeign();
for (unsigned attempt=1;attempt<15;++attempt) {
auto* frame=pendingChat(node); assert(frame);
advanceTo(node,frame->due);
assert(node.foreign_pending && node.foreign.back()==original);
assert(node._mt_packet_id==next_id);
node.completeForeign();
}
assert(node.foreign.size()==before+15 && node._mt_retried==14);
auto* frame=pendingChat(node); assert(frame); // Final echo window remains.
if (echo_on_last) {
const auto echo=repeated(original);
assert(receiveFrame(node,echo.data(),echo.size()));
assert(node._mt_heard==1 && node._mt_exhausted==0);
} else {
advanceTo(node,frame->due);
assert(node._mt_exhausted==1 && node._mt_heard==0);
}
node.advance(3600000);
assert(!pendingChat(node) && !node.foreign_pending && node.foreign.size()==before+15);
}
puts("PASS default-tries");
}
static void heardRetries() {
Preferences::reset(); MyMesh node; active(node); announce(node);
node._prefs.flood_retry_attempts=2;
@@ -1040,16 +1074,16 @@ static void heardRetries() {
capped._prefs.flood_retry_attempts=15;
assert(capped.sendMeshtextText(capped.stored[0],"bounded",7));
const auto cap_original=capped.foreign.back(); capped.completeForeign();
for(unsigned i=0;i<15;++i) {
for(unsigned i=0;i<14;++i) {
auto* current=pendingChat(capped); assert(current);
advanceTo(capped,current->due);
assert(capped.foreign_pending && capped.foreign.back()==cap_original);
capped.completeForeign();
}
assert(capped._mt_retried==15 && capped.foreign.size()==17);
assert(capped._mt_retried==14 && capped.foreign.size()==16);
auto* current=pendingChat(capped); assert(current); advanceTo(capped,current->due);
assert(capped._mt_exhausted==1 && !pendingChat(capped));
capped.advance(3600000); assert(capped.foreign.size()==17);
capped.advance(3600000); assert(capped.foreign.size()==16);
Preferences::reset(); MyMesh disabled; active(disabled); announce(disabled);
disabled._prefs.flood_retry_attempts=0;
@@ -1620,6 +1654,7 @@ int main(int argc,char** argv) {
else if(!strcmp(argv[1],"auto-nodeinfo"))automaticNodeinfo();
else if(!strcmp(argv[1],"auto-nodeinfo-quiet"))automaticNodeinfoQuiet();
else if(!strcmp(argv[1],"heard-retries"))heardRetries();
else if(!strcmp(argv[1],"default-tries"))defaultTries();
else if(!strcmp(argv[1],"strict-echo"))strictEcho();
else if(!strcmp(argv[1],"delivery-lifecycle"))deliveryLifecycle();
else if(!strcmp(argv[1],"delivery-contention"))deliveryContention();
+8 -1
View File
@@ -212,6 +212,13 @@ static void basic() {
assert(f.radio.p.generation[0] == saved.generation[0] && f.radio.p.generation[1] == saved.generation[1]);
assert(!f.d.getNumSentFlood() && !f.d.getNumSentDirect() && !f.d.mc_sent);
assert(f.d.getTotalAirTime() == 21);
// A silent channel never turns this beacon into a chat retry. Its raw
// owner is retired after TX, even while message retries are enabled.
for (unsigned minute = 0; minute < 15; ++minute) {
f.tick(60000);
assert(!f.d.hasOutbound() && !f.d.hasBeaconRadioFrame());
assert(f.radio.beacon_sends == 1 && f.radio.normal_sends == 0);
}
}
static void preparation() {
{ Fixture f; f.radio.prepare = RadioParamApplyResult::BUSY;
@@ -629,7 +636,7 @@ class BeaconDispatcherTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("PASS", result.stdout)
def test_one_tx_uses_private_tuple_preserves_radio2_and_accounts_airtime(self):
def test_one_tx_without_echo_preserves_radio2_and_accounts_airtime(self):
self.run_case("basic")
def test_prepare_busy_is_bounded_and_owned_failures_restore_exclusively(self):
+8
View File
@@ -82,6 +82,11 @@ class CompanionMeshtextTest(unittest.TestCase):
fixture = (ROOT / "test/fixtures/radio_profiles/cli_test.cpp").read_text(encoding="utf-8")
fixture = fixture[:fixture.index("static void permanentScheduleOrder()")]
text = (ROOT / "test/fixtures/companion_meshtext.cpp").read_text(encoding="utf-8")
prefs = (ROOT / "examples/companion_radio/NodePrefs.h").read_text(encoding="utf-8")
default_retries = re.search(r"uint8_t\s+flood_retry_attempts\s*=\s*(\d+)\s*;", prefs)
if default_retries is None:
raise AssertionError("Companion retry default missing from production preferences")
text = text.replace("@DEFAULT_FLOOD_RETRIES@", default_retries.group(1))
radio_source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text(encoding="utf-8")
airtime = body(radio_source, "uint32_t RadioLibWrapper::getProfileAirtime(")
airtime = "uint32_t actualProfileAirtime(uint8_t profile, int len, uint8_t cr=0) " + airtime[airtime.index("{"):]
@@ -250,6 +255,9 @@ class CompanionMeshtextTest(unittest.TestCase):
def test_heard_repeat_retries_identical_packets_bounded_by_existing_settings(self):
self.run_case("heard-retries")
def test_default_fifteen_total_attempts_and_last_attempt_echo(self):
self.run_case("default-tries")
def test_only_exact_forwarded_echo_on_the_current_profile_stops_retries(self):
self.run_case("strict-echo")