From dbc4dda0ca67a84f2bfccbd950e0608527f8e0ab Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 23 Apr 2026 16:25:51 -0700 Subject: [PATCH] Retry 3 times with a 200ms,300ms,400ms backoff. --- docs/cli_commands.md | 13 +++++ examples/simple_repeater/MyMesh.cpp | 85 ++++++++++++++++++++++++++++ examples/simple_repeater/MyMesh.h | 2 + src/Dispatcher.h | 2 + src/Mesh.cpp | 55 +++++++++++++++++- src/Mesh.h | 6 ++ src/helpers/SimpleMeshTables.h | 86 +++++++++++++++++++++++++---- 7 files changed, 236 insertions(+), 13 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index c6a7fac0..9fa175a2 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -98,6 +98,19 @@ --- +### Get or set recent repeater fallback prefix/SNR +**Usage:** +- `recent.repeater` +- `recent.repeater ` + +**Parameters:** +- `prefix_hex`: 1-3 bytes of next-hop prefix (hex) +- `snr_db`: SNR in dB (supports decimals; stored at x4 precision) + +**Note:** `set` is rejected when the prefix already exists in neighbors. + +--- + ## Statistics ### Clear Stats diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index f268679c..9d9e7d03 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -77,6 +77,15 @@ const NeighbourInfo* MyMesh::findNeighbourByHash(const uint8_t* hash, uint8_t ha return NULL; } +bool MyMesh::allowRecentRepeaterPrefixStore(const uint8_t* prefix, uint8_t prefix_len, void* ctx) { + if (ctx == NULL || prefix == NULL || prefix_len == 0) { + return true; + } + + const MyMesh* self = (const MyMesh*) ctx; + return self->findNeighbourByHash(prefix, prefix_len) == NULL; +} + void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { #if MAX_NEIGHBOURS // check if neighbours enabled // find existing neighbour, else use least recently updated @@ -510,6 +519,34 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { } } +void MyMesh::onDirectRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis) { + if (packet == NULL) { + return; + } + + MESH_DEBUG_PRINTLN("%s direct retry %s (type=%d, route=%s, payload_len=%d, delay=%lu)", + getLogDateTime(), + event, + (uint32_t)packet->getPayloadType(), + packet->isRouteDirect() ? "D" : "F", + (uint32_t)packet->payload_len, + (unsigned long)delay_millis); + + if (_logging) { + File f = openAppend(PACKET_LOG_FILE); + if (f) { + f.print(getLogDateTime()); + f.printf(": DIRECT RETRY %s (type=%d, route=%s, payload_len=%d, delay=%lu)\n", + event, + (uint32_t)packet->getPayloadType(), + packet->isRouteDirect() ? "D" : "F", + (uint32_t)packet->payload_len, + (unsigned long)delay_millis); + f.close(); + } + } +} + int MyMesh::calcRxDelay(float score, uint32_t air_time) const { if (_prefs.rx_delay_base <= 0.0f) return 0; return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); @@ -880,6 +917,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc active_bw = _prefs.bw; active_sf = _prefs.sf; active_cr = _prefs.cr; + + ((SimpleMeshTables *)getTables())->setRecentRepeaterAllowFilter(&MyMesh::allowRecentRepeaterPrefixStore, this); } void MyMesh::begin(FILESYSTEM *fs) { @@ -901,6 +940,7 @@ void MyMesh::begin(FILESYSTEM *fs) { active_bw = _prefs.bw; active_sf = _prefs.sf; active_cr = _prefs.cr; + ((SimpleMeshTables *)getTables())->setRecentRepeaterMinSNRX4(getDirectRetryMinSNRX4()); radio_set_tx_power(_prefs.tx_power_dbm); updateAdvertTimer(); @@ -1250,6 +1290,48 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } else { strcpy(reply, "Err - ??"); } + } else if (memcmp(command, "recent.repeater", 15) == 0) { + const char* sub = command + 15; + while (*sub == ' ') sub++; + auto* tables = (SimpleMeshTables*)getTables(); + if (*sub == 0) { + const auto* info = tables->getLatestRecentRepeater(); + if (info == NULL) { + strcpy(reply, "> none"); + } else { + char hex[(MAX_ROUTE_HASH_BYTES * 2) + 1]; + mesh::Utils::toHex(hex, info->prefix, info->prefix_len); + sprintf(reply, "> %s,%s", hex, StrHelper::ftoa(((float)info->snr_x4) / 4.0f)); + } + } else { + char* params = (char*) sub; + char* arg_snr = strchr(params, ' '); + if (arg_snr == NULL) { + strcpy(reply, "Err - usage: recent.repeater "); + } else { + *arg_snr++ = 0; + while (*arg_snr == ' ') arg_snr++; + if (*arg_snr == 0) { + strcpy(reply, "Err - usage: recent.repeater "); + } else { + int hex_len = strlen(params); + int prefix_len = hex_len / 2; + uint8_t prefix[MAX_ROUTE_HASH_BYTES] = {0}; + if ((hex_len % 2) != 0 || prefix_len <= 0 || prefix_len > MAX_ROUTE_HASH_BYTES || !mesh::Utils::fromHex(prefix, prefix_len, params)) { + strcpy(reply, "Err - prefix must be 1-3 bytes hex"); + } else { + float snr_db = strtof(arg_snr, nullptr); + int snr_x4 = (int)(snr_db * 4.0f + (snr_db >= 0.0f ? 0.5f : -0.5f)); + snr_x4 = constrain(snr_x4, -128, 127); + if (tables->setRecentRepeater(prefix, (uint8_t)prefix_len, (int8_t)snr_x4)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - prefix is already in neighbors"); + } + } + } + } + } } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } @@ -1293,6 +1375,9 @@ void MyMesh::loop() { MESH_DEBUG_PRINTLN("Radio params restored"); } + // Keep recent-prefix learning aligned with the live retry SNR gate. + ((SimpleMeshTables *)getTables())->setRecentRepeaterMinSNRX4(getDirectRetryMinSNRX4()); + // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { acl.save(_fs); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 31528829..4b5d7b1f 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -119,6 +119,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #endif const NeighbourInfo* findNeighbourByHash(const uint8_t* hash, uint8_t hash_len) const; + static bool allowRecentRepeaterPrefixStore(const uint8_t* prefix, uint8_t prefix_len, void* ctx); int8_t getDirectRetryMinSNRX4() const; void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); @@ -148,6 +149,7 @@ protected: uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; bool allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const override; uint32_t getDirectRetryEchoDelay(const mesh::Packet* packet) const override; + void onDirectRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis) override; int getInterferenceThreshold() const override { return _prefs.interference_threshold; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 29460061..970e1975 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -161,6 +161,8 @@ protected: virtual uint32_t getCADFailMaxDuration() const; virtual int getInterferenceThreshold() const { return 0; } // disabled by default virtual int getAGCResetInterval() const { return 0; } // disabled by default + virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } + const Packet* getOutboundInFlight() const { return outbound; } public: void begin(); diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 082b6bb4..b4710b8c 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -3,12 +3,16 @@ namespace mesh { +static const uint8_t DIRECT_RETRY_MAX_ATTEMPTS = 3; +static const uint32_t DIRECT_RETRY_BACKOFF_MS[DIRECT_RETRY_MAX_ATTEMPTS] = { 200, 300, 400 }; + void Mesh::begin() { for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { _direct_retries[i].packet = NULL; _direct_retries[i].trigger_packet = NULL; _direct_retries[i].retry_at = 0; _direct_retries[i].retry_delay = 0; + _direct_retries[i].retry_attempts_sent = 0; _direct_retries[i].priority = 0; _direct_retries[i].progress_marker = 0; _direct_retries[i].expect_path_growth = false; @@ -27,6 +31,9 @@ void Mesh::loop() { } if (!isDirectRetryQueued(_direct_retries[i].packet)) { + if (_direct_retries[i].packet == getOutboundInFlight()) { + continue; // currently transmitting; keep slot until onSendComplete/onSendFail emits event + } clearDirectRetrySlot(i); } } @@ -443,6 +450,7 @@ void Mesh::clearDirectRetrySlot(int idx) { _direct_retries[idx].trigger_packet = NULL; _direct_retries[idx].retry_at = 0; _direct_retries[idx].retry_delay = 0; + _direct_retries[idx].retry_attempts_sent = 0; _direct_retries[idx].priority = 0; _direct_retries[idx].progress_marker = 0; _direct_retries[idx].expect_path_growth = false; @@ -491,8 +499,12 @@ bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { break; } } + onDirectRetryEvent("canceled_echo", _direct_retries[i].packet, 0); + onDirectRetryEvent("good", _direct_retries[i].packet, 0); clearDirectRetrySlot(i); } else { + onDirectRetryEvent("canceled_echo", _direct_retries[i].trigger_packet, 0); + onDirectRetryEvent("good", _direct_retries[i].trigger_packet, 0); clearDirectRetrySlot(i); } cleared = true; @@ -510,7 +522,35 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { if (_direct_retries[i].queued) { if (_direct_retries[i].packet == packet) { // The retry packet itself just finished transmitting; Dispatcher will release it after this hook. - clearDirectRetrySlot(i); + onDirectRetryEvent("resent", packet, 0); + _direct_retries[i].retry_attempts_sent++; + if (_direct_retries[i].retry_attempts_sent >= DIRECT_RETRY_MAX_ATTEMPTS) { + onDirectRetryEvent("failure", packet, 0); + clearDirectRetrySlot(i); + continue; + } + + Packet* retry = obtainNewPacket(); + if (retry == NULL) { + onDirectRetryEvent("dropped_no_packet", packet, 0); + onDirectRetryEvent("failure", packet, 0); + clearDirectRetrySlot(i); + continue; + } + + *retry = *packet; + uint32_t retry_delay = DIRECT_RETRY_BACKOFF_MS[_direct_retries[i].retry_attempts_sent]; + sendPacket(retry, _direct_retries[i].priority, retry_delay); + if (isDirectRetryQueued(retry)) { + _direct_retries[i].packet = retry; + _direct_retries[i].retry_delay = retry_delay; + _direct_retries[i].retry_at = futureMillis(retry_delay); + onDirectRetryEvent("queued", retry, retry_delay); + } else { + onDirectRetryEvent("dropped_queue_full", retry, retry_delay); + onDirectRetryEvent("failure", retry, 0); + clearDirectRetrySlot(i); + } } continue; } @@ -522,6 +562,8 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { // Allocate the retry packet only after TX-complete so busy repeaters do not reserve pool slots early. Packet* retry = obtainNewPacket(); if (retry == NULL) { + onDirectRetryEvent("dropped_no_packet", packet, _direct_retries[i].retry_delay); + onDirectRetryEvent("failure", packet, 0); clearDirectRetrySlot(i); continue; } @@ -535,7 +577,10 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { _direct_retries[i].trigger_packet = NULL; _direct_retries[i].queued = true; _direct_retries[i].retry_at = futureMillis(_direct_retries[i].retry_delay); + onDirectRetryEvent("queued", retry, _direct_retries[i].retry_delay); } else { + onDirectRetryEvent("dropped_queue_full", retry, _direct_retries[i].retry_delay); + onDirectRetryEvent("failure", retry, 0); clearDirectRetrySlot(i); } } @@ -550,12 +595,16 @@ void Mesh::clearPendingDirectRetryOnSendFail(const Packet* packet) { if (_direct_retries[i].queued) { if (_direct_retries[i].packet == packet) { // The queued retry itself failed; Dispatcher will release it after this hook. + onDirectRetryEvent("dropped_send_fail", packet, 0); + onDirectRetryEvent("failure", packet, 0); clearDirectRetrySlot(i); } continue; } if (_direct_retries[i].trigger_packet == packet) { + onDirectRetryEvent("dropped_send_fail", packet, 0); + onDirectRetryEvent("failure", packet, 0); clearDirectRetrySlot(i); } } @@ -638,17 +687,19 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority) { } // Only store retry metadata here; allocate the retry packet after the initial TX really completes. - uint32_t retry_delay = getDirectRetryEchoDelay(packet); + uint32_t retry_delay = DIRECT_RETRY_BACKOFF_MS[0]; calculateDirectRetryKey(packet, _direct_retries[slot_idx].retry_key); _direct_retries[slot_idx].packet = NULL; _direct_retries[slot_idx].trigger_packet = const_cast(packet); _direct_retries[slot_idx].retry_at = 0; _direct_retries[slot_idx].retry_delay = retry_delay; + _direct_retries[slot_idx].retry_attempts_sent = 0; _direct_retries[slot_idx].priority = priority; _direct_retries[slot_idx].progress_marker = progress_marker; _direct_retries[slot_idx].expect_path_growth = expect_path_growth; _direct_retries[slot_idx].queued = false; _direct_retries[slot_idx].active = true; + onDirectRetryEvent("armed", packet, retry_delay); } Packet* Mesh::createAdvert(const LocalIdentity& id, const uint8_t* app_data, size_t app_data_len) { diff --git a/src/Mesh.h b/src/Mesh.h index 874336b0..89b6bed4 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -34,6 +34,7 @@ class Mesh : public Dispatcher { Packet* trigger_packet; unsigned long retry_at; uint32_t retry_delay; + uint8_t retry_attempts_sent; uint8_t retry_key[MAX_HASH_SIZE]; uint8_t priority; uint8_t progress_marker; @@ -111,6 +112,11 @@ protected: */ virtual uint8_t getExtraAckTransmitCount() const; + /** + * \brief Optional hook for logging direct-retry lifecycle events. + */ + virtual void onDirectRetryEvent(const char* event, const Packet* packet, uint32_t delay_millis) { } + /** * \brief Perform search of local DB of peers/contacts. * \returns Number of peers with matching hash diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 217fd5a0..f5da272b 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -13,6 +13,8 @@ class SimpleMeshTables : public mesh::MeshTables { public: + typedef bool (*RecentRepeaterAllowFn)(const uint8_t* prefix, uint8_t prefix_len, void* ctx); + struct RecentRepeaterInfo { // Just enough identity to match a next-hop path prefix plus the SNR that heard it. uint8_t prefix[MAX_ROUTE_HASH_BYTES]; @@ -28,6 +30,9 @@ private: uint32_t _direct_dups, _flood_dups; RecentRepeaterInfo _recent_repeaters[MAX_RECENT_REPEATERS]; int _next_recent_repeater_idx; + int8_t _recent_repeater_min_snr_x4; + RecentRepeaterAllowFn _recent_repeater_allow_fn; + void* _recent_repeater_allow_ctx; bool hasSeenAck(uint32_t ack) const { for (int i = 0; i < MAX_PACKET_ACKS; i++) { @@ -58,6 +63,11 @@ private: _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; } + bool prefixesOverlap(const uint8_t* a, uint8_t a_len, const uint8_t* b, uint8_t b_len) const { + uint8_t n = a_len < b_len ? a_len : b_len; + return n > 0 && memcmp(a, b, n) == 0; + } + bool extractRecentRepeater(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const { // Learn repeater prefixes only from packet shapes that expose a trustworthy repeater ID. if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->payload_len >= PUB_KEY_SIZE) { @@ -96,14 +106,10 @@ private: if (!extractRecentRepeater(packet, prefix, prefix_len) || prefix_len == 0) { return; } - - // Ring buffer is enough here; retry fallback only needs a recent prefix->SNR observation. - RecentRepeaterInfo& slot = _recent_repeaters[_next_recent_repeater_idx]; - memset(slot.prefix, 0, sizeof(slot.prefix)); - memcpy(slot.prefix, prefix, prefix_len); - slot.prefix_len = prefix_len; - slot.snr_x4 = packet->_snr; - _next_recent_repeater_idx = (_next_recent_repeater_idx + 1) % MAX_RECENT_REPEATERS; + if (packet->_snr < _recent_repeater_min_snr_x4) { + return; + } + setRecentRepeater(prefix, prefix_len, packet->_snr); } public: @@ -115,6 +121,9 @@ public: _direct_dups = _flood_dups = 0; memset(_recent_repeaters, 0, sizeof(_recent_repeaters)); _next_recent_repeater_idx = 0; + _recent_repeater_min_snr_x4 = -128; + _recent_repeater_allow_fn = NULL; + _recent_repeater_allow_ctx = NULL; } #ifdef ESP32 @@ -216,19 +225,74 @@ public: uint32_t getNumDirectDups() const { return _direct_dups; } uint32_t getNumFloodDups() const { return _flood_dups; } + void setRecentRepeaterMinSNRX4(int8_t min_snr_x4) { + _recent_repeater_min_snr_x4 = min_snr_x4; + } + void setRecentRepeaterAllowFilter(RecentRepeaterAllowFn fn, void* ctx) { + _recent_repeater_allow_fn = fn; + _recent_repeater_allow_ctx = ctx; + } + bool setRecentRepeater(const uint8_t* prefix, uint8_t prefix_len, int8_t snr_x4) { + if (prefix == NULL || prefix_len == 0) { + return false; + } + + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + + if (_recent_repeater_allow_fn != NULL && !_recent_repeater_allow_fn(prefix, prefix_len, _recent_repeater_allow_ctx)) { + return false; + } + + // Keep one slot for overlapping prefixes so 1/2/3-byte paths share the same entry. + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + RecentRepeaterInfo& existing = _recent_repeaters[i]; + if (existing.prefix_len == 0 || !prefixesOverlap(existing.prefix, existing.prefix_len, prefix, prefix_len)) { + continue; + } + if (prefix_len > existing.prefix_len) { + memset(existing.prefix, 0, sizeof(existing.prefix)); + memcpy(existing.prefix, prefix, prefix_len); + existing.prefix_len = prefix_len; + } + existing.snr_x4 = snr_x4; + return true; + } + + // Ring buffer is enough here; retry fallback only needs a recent prefix->SNR observation. + RecentRepeaterInfo& slot = _recent_repeaters[_next_recent_repeater_idx]; + memset(slot.prefix, 0, sizeof(slot.prefix)); + memcpy(slot.prefix, prefix, prefix_len); + slot.prefix_len = prefix_len; + slot.snr_x4 = snr_x4; + _next_recent_repeater_idx = (_next_recent_repeater_idx + 1) % MAX_RECENT_REPEATERS; + return true; + } + const RecentRepeaterInfo* getLatestRecentRepeater() const { + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + int idx = (_next_recent_repeater_idx - 1 - i + MAX_RECENT_REPEATERS) % MAX_RECENT_REPEATERS; + const RecentRepeaterInfo* info = &_recent_repeaters[idx]; + if (info->prefix_len > 0) { + return info; + } + } + return NULL; + } + const RecentRepeaterInfo* findRecentRepeaterByHash(const uint8_t* hash, uint8_t hash_len) const { if (hash == NULL || hash_len == 0) { return NULL; } - // Search newest-to-oldest so the retry gate prefers the freshest SNR sample for a prefix. + // Search newest-to-oldest and allow 1/2/3-byte prefixes to overlap-match. for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { int idx = (_next_recent_repeater_idx - 1 - i + MAX_RECENT_REPEATERS) % MAX_RECENT_REPEATERS; const RecentRepeaterInfo* info = &_recent_repeaters[idx]; - if (info->prefix_len < hash_len || info->prefix_len == 0) { + if (info->prefix_len == 0) { continue; } - if (memcmp(info->prefix, hash, hash_len) == 0) { + if (prefixesOverlap(info->prefix, info->prefix_len, hash, hash_len)) { return info; } }