From 28a24002b7694ab6e72decaaee5c08f1e7860777 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sat, 31 Jan 2026 15:01:40 +0100 Subject: [PATCH 01/11] MESHCORE_SIMULATOR patch --- src/Dispatcher.cpp | 16 +++++++++++++--- src/helpers/BaseChatMesh.cpp | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 9d7a11131d..46e5c16752 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -4,9 +4,13 @@ #include #endif +#ifdef MESHCORE_SIMULATOR + #include "sim_context.h" +#endif + #include -namespace mesh { + namespace mesh { #define MAX_RX_DELAY_MILLIS 32000 // 32 seconds #define MIN_TX_BUDGET_RESERVE_MS 100 // min budget (ms) required before allowing next TX @@ -383,7 +387,13 @@ bool Dispatcher::millisHasNowPassed(unsigned long timestamp) const { } unsigned long Dispatcher::futureMillis(int millis_from_now) const { - return _ms->getMillis() + millis_from_now; + unsigned long wake_time = _ms->getMillis() + millis_from_now; + #ifdef MESHCORE_SIMULATOR // Register wake time with simulator for accurate scheduling + if (auto *ctx = SIM_CTX()) { + ctx->wake_registry.registerWakeTime(wake_time); + } + #endif + + return wake_time; } - } \ No newline at end of file diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index e2b116b456..57780eb0cc 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -784,7 +784,7 @@ void BaseChatMesh::resetPathTo(ContactInfo& recipient) { recipient.out_path_len = OUT_PATH_UNKNOWN; } -static ContactInfo* table; // pass via global :-( +static thread_local ContactInfo *table; // pass via global (thread_local for simulation) static int cmp_adv_timestamp(const void *a, const void *b) { int a_idx = *((int *)a); From 5a040a739e9042b6ffa36456c61ac518e8c21f28 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Mon, 1 Jun 2026 23:28:57 +0200 Subject: [PATCH 02/11] Implemented repeated sending for error correction --- docs/cli_commands.md | 12 ++ examples/companion_radio/DataStore.cpp | 2 + examples/companion_radio/MyMesh.cpp | 6 + examples/companion_radio/MyMesh.h | 1 + examples/companion_radio/NodePrefs.h | 1 + examples/simple_repeater/MyMesh.cpp | 1 + examples/simple_repeater/MyMesh.h | 3 + examples/simple_room_server/MyMesh.cpp | 1 + examples/simple_room_server/MyMesh.h | 3 + examples/simple_sensor/SensorMesh.cpp | 1 + examples/simple_sensor/SensorMesh.h | 1 + src/Dispatcher.cpp | 173 +++++++++++++++--------- src/Dispatcher.h | 23 ++++ src/Mesh.cpp | 56 +++++++- src/Mesh.h | 4 +- src/MeshCore.h | 2 +- src/Packet.cpp | 31 +++-- src/Packet.h | 9 +- src/helpers/CommonCLI.cpp | 18 ++- src/helpers/CommonCLI.h | 1 + src/helpers/SimpleMeshTables.h | 56 ++++++-- src/helpers/StaticPoolPacketManager.cpp | 2 + 22 files changed, 311 insertions(+), 96 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index f63e6879e1..15bb832906 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -466,6 +466,18 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change the maximum direct-route resend attempts +**Usage:** +- `get max.resend` +- `set max.resend ` + +**Parameters:** +- `value`: Maximum number of resend attempts for direct-routed packets (0–5). `0` disables resending entirely. + +**Default:** `2` + +--- + #### View or change the retransmit delay factor for flood traffic **Usage:** - `get txdelay` diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c7988bb344..ef837b9eaa 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -233,6 +233,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 + file.read((uint8_t *)&_prefs.max_resend_attempts, sizeof(_prefs.max_resend_attempts)); // 137 file.close(); } @@ -273,6 +274,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 + file.write((uint8_t *)&_prefs.max_resend_attempts, sizeof(_prefs.max_resend_attempts)); // 137 file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f180421912..15c5898038 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -271,6 +271,7 @@ uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.5f); return getRNG()->nextInt(0, 5*t + 1); } + uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.2f); return getRNG()->nextInt(0, 5*t + 1); @@ -878,6 +879,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.tx_power_dbm = LORA_TX_POWER; _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default + _prefs.max_resend_attempts = 2; //_prefs.rx_delay_base = 10.0f; enable once new algo fixed #if defined(USE_SX1262) || defined(USE_SX1268) #ifdef SX126X_RX_BOOSTED_GAIN @@ -935,6 +937,7 @@ void MyMesh::begin(bool has_display) { _prefs.tx_power_dbm = constrain(_prefs.tx_power_dbm, -9, MAX_LORA_TX_POWER); _prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1 _prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours + _prefs.max_resend_attempts = constrain(_prefs.max_resend_attempts, 0, 5); #ifdef BLE_PIN_CODE // 123456 by default if (_prefs.ble_pin == 0) { @@ -1437,6 +1440,9 @@ void MyMesh::handleCmdFrame(size_t len) { _prefs.advert_loc_policy = cmd_frame[3]; if (len >= 5) { _prefs.multi_acks = cmd_frame[4]; + if (len >= 6) { + _prefs.max_resend_attempts = constrain(cmd_frame[5], 0, 5); + } } } } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index f189a2c5e5..d7c727253b 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -109,6 +109,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint32_t getRetransmitDelay(const mesh::Packet *packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override; uint8_t getExtraAckTransmitCount() const override; + uint8_t getMaxResendAttempts() const override { return _prefs.max_resend_attempts; } bool filterRecvFloodPacket(mesh::Packet* packet) override; bool allowPacketForward(const mesh::Packet* packet) override; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 48c381ceaf..6acf5554e1 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -34,4 +34,5 @@ struct NodePrefs { // persisted to file uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) char default_scope_name[31]; uint8_t default_scope_key[16]; + uint8_t max_resend_attempts; // 0 = disabled, 1-5, default 2 }; \ No newline at end of file diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1f68c6f2a0..25f20b23bf 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -874,6 +874,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; _prefs.tx_delay_factor = 0.5f; // was 0.25f _prefs.direct_tx_delay_factor = 0.3f; // was 0.2 + _prefs.max_resend_attempts = 2; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; _prefs.node_lon = ADVERT_LON; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8ed0317e69..6d210e5d22 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -156,6 +156,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getExtraAckTransmitCount() const override { return _prefs.multi_acks; } + uint8_t getMaxResendAttempts() const override { + return _prefs.max_resend_attempts; + } #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 2fb80be24c..00230065b4 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -630,6 +630,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_delay_base = 0.0f; // off by default, was 10.0 _prefs.tx_delay_factor = 0.5f; // was 0.25f; _prefs.direct_tx_delay_factor = 0.2f; // was zero + _prefs.max_resend_attempts = 2; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; _prefs.node_lon = ADVERT_LON; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 1b35ae95a1..8cd8246c21 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -150,6 +150,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t getExtraAckTransmitCount() const override { return _prefs.multi_acks; } + uint8_t getMaxResendAttempts() const override { + return _prefs.max_resend_attempts; + } bool filterRecvFloodPacket(mesh::Packet* pkt) override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 879fcbf026..64e1c72b07 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -712,6 +712,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; _prefs.tx_delay_factor = 0.5f; // was 0.25f _prefs.direct_tx_delay_factor = 0.2f; // was zero + _prefs.max_resend_attempts = 2; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; _prefs.node_lon = ADVERT_LON; diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 424b16c175..0ce151b549 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -121,6 +121,7 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; int getInterferenceThreshold() const override; int getAGCResetInterval() const override; + uint8_t getMaxResendAttempts() const override { return _prefs.max_resend_attempts; } void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 46e5c16752..9195fc12d6 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -10,7 +10,7 @@ #include - namespace mesh { +namespace mesh { #define MAX_RX_DELAY_MILLIS 32000 // 32 seconds #define MIN_TX_BUDGET_RESERVE_MS 100 // min budget (ms) required before allowing next TX @@ -68,10 +68,6 @@ uint32_t Dispatcher::getCADFailMaxDuration() const { } void Dispatcher::loop() { - if (millisHasNowPassed(next_floor_calib_time)) { - _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); - next_floor_calib_time = futureMillis(NOISE_FLOOR_CALIB_INTERVAL); - } _radio->loop(); // check for radio 'stuck' in mode other than Rx @@ -115,10 +111,13 @@ void Dispatcher::loop() { } else { n_sent_direct++; } - releasePacket(outbound); // return to pool + // allow for possible retransmission for reliability + if (!resendPacket(outbound)) { + releasePacket(outbound); // return to pool + } outbound = NULL; } else if (millisHasNowPassed(outbound_expiry)) { - MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): WARNING: outbound packed send timed out!", getLogDateTime()); + MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): WARNING: outbound packet %s send timed out!", getLogDateTime(), outbound->getHashHex()); _radio->onSendFinished(); logTxFail(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); @@ -147,6 +146,14 @@ void Dispatcher::loop() { } checkRecv(); checkSend(); + + // Do noise floor calibration LAST, when no critical operations are pending + if (millisHasNowPassed(next_floor_calib_time)) { + if (!_radio->isReceiving() && _mgr->getOutboundCount(_ms->getMillis()) == 0) { + _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); + next_floor_calib_time = futureMillis(NOISE_FLOOR_CALIB_INTERVAL); + } + } } bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) { @@ -192,76 +199,82 @@ bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) { } void Dispatcher::checkRecv() { - Packet* pkt; - float score; - uint32_t air_time; - { - uint8_t raw[MAX_TRANS_UNIT+1]; + while (true) { + + Packet *pkt = nullptr; + float score = 0.0f; + uint32_t air_time = 0; + + uint8_t raw[MAX_TRANS_UNIT + 1]; int len = _radio->recvRaw(raw, MAX_TRANS_UNIT); - if (len > 0) { - logRxRaw(_radio->getLastSNR(), _radio->getLastRSSI(), raw, len); + if (len <= 0) { + break; + } - pkt = _mgr->allocNew(); - if (pkt == NULL) { - MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(): WARNING: received data, no unused packets available!", getLogDateTime()); - } else { - if (tryParsePacket(pkt, raw, len)) { - pkt->_snr = _radio->getLastSNR() * 4.0f; - score = _radio->packetScore(_radio->getLastSNR(), len); - air_time = _radio->getEstAirtimeFor(len); - rx_air_time += air_time; - } else { - _mgr->free(pkt); // put back into pool - pkt = NULL; - } - } - } else { - pkt = NULL; + logRxRaw(_radio->getLastSNR(), _radio->getLastRSSI(), raw, len); + + pkt = _mgr->allocNew(); + if (pkt == NULL) { + MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(): WARNING: received data, no unused packets available!", getLogDateTime()); + continue; } - } - if (pkt) { - #if MESH_PACKET_LOGGING - Serial.print(getLogDateTime()); - Serial.printf(": RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d time=%d", - pkt->getRawLength(), pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, - (int)pkt->getSNR(), (int)_radio->getLastRSSI(), (int)(score*1000), air_time); - - static uint8_t packet_hash[MAX_HASH_SIZE]; - pkt->calculatePacketHash(packet_hash); - Serial.print(" hash="); - mesh::Utils::printHex(Serial, packet_hash, MAX_HASH_SIZE); - - if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ - || pkt->getPayloadType() == PAYLOAD_TYPE_RESPONSE || pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { - Serial.printf(" [%02X -> %02X]\n", (uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]); + + if (tryParsePacket(pkt, raw, len)) { + pkt->_snr = _radio->getLastSNR() * 4.0f; + score = _radio->packetScore(_radio->getLastSNR(), len); + air_time = _radio->getEstAirtimeFor(len); + rx_air_time += air_time; } else { - Serial.printf("\n"); + _mgr->free(pkt); // put back into pool + pkt = NULL; } - #endif - logRx(pkt, pkt->getRawLength(), score); // hook for custom logging - if (pkt->isRouteFlood()) { - n_recv_flood++; + if (pkt) { +#if MESH_PACKET_LOGGING + Serial.print(getLogDateTime()); + Serial.printf(": RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d time=%d", + pkt->getRawLength(), pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, + (int)pkt->getSNR(), (int)_radio->getLastRSSI(), (int)(score*1000), air_time); + + pkt->calculatePacketHash(); + Serial.print(" hash="); + mesh::Utils::printHex(Serial, pkt->hash, MAX_HASH_SIZE); - int _delay = calcRxDelay(score, air_time); - if (_delay < 50) { - MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(), score delay below threshold (%d)", getLogDateTime(), _delay); - processRecvPacket(pkt); // is below the score delay threshold, so process immediately + if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ + || pkt->getPayloadType() == PAYLOAD_TYPE_RESPONSE || pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { + Serial.printf(" [%02X -> %02X]\n", (uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]); } else { - MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(), score delay is: %d millis", getLogDateTime(), _delay); - if (_delay > MAX_RX_DELAY_MILLIS) { - _delay = MAX_RX_DELAY_MILLIS; + Serial.printf("\n"); + } +#endif + logRx(pkt, pkt->getRawLength(), score); // hook for custom logging + + if (pkt->isRouteFlood()) { + n_recv_flood++; + + int _delay = calcRxDelay(score, air_time); + if (_delay < 50) { + MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(), score delay below threshold (%d)", getLogDateTime(), _delay); + processRecvPacket(pkt); // is below the score delay threshold, so process immediately + } else { + MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(), score delay is: %d millis", getLogDateTime(), _delay); + if (_delay > MAX_RX_DELAY_MILLIS) { + _delay = MAX_RX_DELAY_MILLIS; + } + _mgr->queueInbound(pkt, futureMillis(_delay)); // add to delayed inbound queue } - _mgr->queueInbound(pkt, futureMillis(_delay)); // add to delayed inbound queue + } else { + n_recv_direct++; + processRecvPacket(pkt); } - } else { - n_recv_direct++; - processRecvPacket(pkt); } } } void Dispatcher::processRecvPacket(Packet* pkt) { + + MESH_DEBUG_PRINTLN("Dispatcher::processRecvPacket %s", pkt->getHashHex()); + DispatcherAction action = onRecvPacket(pkt); if (action == ACTION_RELEASE) { _mgr->free(pkt); @@ -343,8 +356,8 @@ void Dispatcher::checkSend() { #if MESH_PACKET_LOGGING Serial.print(getLogDateTime()); - Serial.printf(": TX, len=%d (type=%d, route=%s, payload_len=%d)", - len, outbound->getPayloadType(), outbound->isRouteDirect() ? "D" : "F", outbound->payload_len); + Serial.printf(": TX, len=%d (type=%d, route=%s, payload_len=%d, attempt=%d)", len, + outbound->getPayloadType(), outbound->isRouteDirect() ? "D" : "F", outbound->payload_len, outbound->sending_attempts); if (outbound->getPayloadType() == PAYLOAD_TYPE_PATH || outbound->getPayloadType() == PAYLOAD_TYPE_REQ || outbound->getPayloadType() == PAYLOAD_TYPE_RESPONSE || outbound->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { Serial.printf(" [%02X -> %02X]\n", (uint32_t)outbound->payload[1], (uint32_t)outbound->payload[0]); @@ -388,12 +401,38 @@ bool Dispatcher::millisHasNowPassed(unsigned long timestamp) const { unsigned long Dispatcher::futureMillis(int millis_from_now) const { unsigned long wake_time = _ms->getMillis() + millis_from_now; - #ifdef MESHCORE_SIMULATOR // Register wake time with simulator for accurate scheduling +#ifdef MESHCORE_SIMULATOR // Register wake time with simulator for accurate scheduling if (auto *ctx = SIM_CTX()) { ctx->wake_registry.registerWakeTime(wake_time); } - #endif - +#endif + return wake_time; } + +bool Dispatcher::resendPacket(mesh::Packet *packet) { + + // prepare error correction via potential retransmit: + // re-send only direct routed packets, with remaining path hops whose retransmits can be recognized; + // the final hop will ACK separately, so out-of-scope here + if (packet->isRouteDirect() && packet->path_len > 0 && packet->sending_attempts < getMaxResendAttempts()) { + packet->sending_attempts++; + + MESH_DEBUG_PRINTLN("Dispatcher::resendPacket %s attempt=%d", packet->getHashHex(), + packet->sending_attempts); + + // Schedule re-send after the equivalent post-TX airtime silence has elapsed. + // We compute the silence directly from the packet's estimated airtime × budget factor. + // Adding 100ms jitter on top gives the downstream repeater enough time to forward the + // packet, and for us to hear that forwarding (and cancel this re-send) before we + // actually transmit. This avoids unnecessary retransmissions and collisions. + uint32_t retransmit_delay = getDirectRetransmitDelay(packet); + uint32_t packet_airtime_ms = _radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2); + uint32_t silence_ms = (uint32_t)(packet_airtime_ms * getAirtimeBudgetFactor()); + _mgr->queueOutbound(packet, 1, futureMillis((int)(silence_ms + retransmit_delay + 100))); + return true; + } + + return false; +} } \ No newline at end of file diff --git a/src/Dispatcher.h b/src/Dispatcher.h index dd032f130d..e501d4351c 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -114,7 +114,10 @@ typedef uint32_t DispatcherAction; * and scheduling of outbound Packets. */ class Dispatcher { +protected: Packet* outbound; // current outbound packet + +private: unsigned long outbound_expiry, outbound_start, total_air_time, rx_air_time; unsigned long next_tx_time; unsigned long cad_busy_start; @@ -169,6 +172,11 @@ class Dispatcher { virtual int getAGCResetInterval() const { return 0; } // disabled by default virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } + /** + * \returns maximum number of direct-route resend attempts (0 = disabled, default = 2, max = 5). + */ + virtual uint8_t getMaxResendAttempts() const { return 2; } + public: void begin(); void loop(); @@ -176,6 +184,21 @@ class Dispatcher { Packet* obtainNewPacket(); void releasePacket(Packet* packet); void sendPacket(Packet* packet, uint8_t priority, uint32_t delay_millis=0); + /** + * \brief re-send the given packet (for retransmission) if conditions apply. + * \return true, if packet was re-sent. + */ + bool resendPacket(Packet *packet); + + /** + * \returns number of milliseconds delay to apply to retransmitting the given packet. + */ + virtual uint32_t getRetransmitDelay(const Packet *packet) { return 0; }; + + /** + * \returns number of milliseconds delay to apply to retransmitting the given packet, for DIRECT mode. + */ + virtual uint32_t getDirectRetransmitDelay(const Packet *packet) { return 0; }; unsigned long getTotalAirTime() const { return total_air_time; } unsigned long getReceiveAirTime() const {return rx_air_time; } diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 7252974a92..31b77fc058 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -72,7 +72,54 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { return ACTION_RELEASE; } - if (pkt->isRouteDirect() && pkt->getPathHashCount() > 0) { + if (pkt->isRouteDirect()) { + const bool is_next_hop = (pkt->getPathHashCount() > 0) && self_id.isHashMatch(pkt->path, pkt->getPathHashSize()); + // Check if this is a retransmit of a packet we recently sent; + // if yes, the next hop successfully forwarded it, so remove our scheduled retransmit from outbound queue. + // This runs for ANY path_len (including 0 = downstream relay forwarding to final destination), + // so that we cancel pending retries as soon as we overhear a relay forwarding our packet. + + // Only do this when the packet is NOT addressed to us as next hop to avoid dropping fresh packets. + if (!is_next_hop) { + pkt->calculatePacketHash(); + const uint8_t *recv_hash = pkt->hash; + + // First check the current outbound packet being prepared/sent + if (outbound && outbound->sending_attempts > 0 && outbound->isRouteDirect()) { + const uint8_t *outbound_hash = outbound->calculatePacketHash(); + if (memcmp(recv_hash, outbound_hash, MAX_HASH_SIZE) == 0) { + MESH_DEBUG_PRINTLN( + "%s Mesh::onRecvPacket(): downstream forwarded current outbound, canceling (attempt=%d)", + getLogDateTime(), outbound->sending_attempts); + releasePacket(outbound); + outbound = NULL; + return ACTION_RELEASE; + } + } + + if (_mgr->getOutboundTotal() > 0) { + for (int i = _mgr->getOutboundTotal() - 1; i >= 0; i--) { + Packet *queued_pkt = _mgr->getOutboundByIdx(i); + if (queued_pkt && queued_pkt->sending_attempts > 0 && queued_pkt->isRouteDirect()) { + const uint8_t *queued_hash = queued_pkt->calculatePacketHash(); + if (memcmp(recv_hash, queued_hash, MAX_HASH_SIZE) == 0) { + MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): downstream forwarded packet detected, canceling " + "retransmit (attempt=%d)", + getLogDateTime(), queued_pkt->sending_attempts); + Packet *removed = _mgr->removeOutboundByIdx(i); + if (removed) _mgr->free(removed); + return ACTION_RELEASE; // don't process further: confirmed successful forwarding + } + } + } + } + + } + + // For path_len=0 direct packets, no further path-based processing applies; + // fall through to general switch-case handling (e.g. ACK delivery via onAckRecv). + if (pkt->path_len < PATH_HASH_SIZE) goto direct_path_done; + // check for 'early received' ACK if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { int i = 0; @@ -83,7 +130,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } } - if (self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) && allowPacketForward(pkt)) { + if (is_next_hop && allowPacketForward(pkt)) { if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) { return forwardMultipartDirect(pkt); } else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { @@ -97,6 +144,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (!_tables->hasSeen(pkt)) { removeSelfFromPath(pkt); + MESH_DEBUG_PRINTLN("Mesh::onRecvPacket(): prepare to repeat packet %s", pkt->getHashHex()); + uint32_t d = getDirectRetransmitDelay(pkt); return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority } @@ -104,6 +153,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { return ACTION_RELEASE; // this node is NOT the next hop (OR this packet has already been forwarded), so discard. } +direct_path_done: if (pkt->isRouteFlood() && filterRecvFloodPacket(pkt)) return ACTION_RELEASE; DispatcherAction action = ACTION_RELEASE; @@ -275,7 +325,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } break; } - case PAYLOAD_TYPE_RAW_CUSTOM: { + case PAYLOAD_TYPE_RAW_CUSTOM: { if (pkt->isRouteDirect() && !_tables->hasSeen(pkt)) { onRawDataRecv(pkt); //action = routeRecvPacket(pkt); don't flood route these (yet) diff --git a/src/Mesh.h b/src/Mesh.h index d53d6d25fa..a8ad93ac3a 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -58,12 +58,12 @@ class Mesh : public Dispatcher { /** * \returns number of milliseconds delay to apply to retransmitting the given packet. */ - virtual uint32_t getRetransmitDelay(const Packet* packet); + virtual uint32_t getRetransmitDelay(const Packet* packet) override; /** * \returns number of milliseconds delay to apply to retransmitting the given packet, for DIRECT mode. */ - virtual uint32_t getDirectRetransmitDelay(const Packet* packet); + virtual uint32_t getDirectRetransmitDelay(const Packet* packet) override; /** * \returns number of extra (Direct) ACK transmissions wanted. diff --git a/src/MeshCore.h b/src/MeshCore.h index b4c57faf32..159f5ce87a 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -21,7 +21,7 @@ #define MAX_PATH_SIZE 64 #define MAX_TRANS_UNIT 255 -#if MESH_DEBUG && ARDUINO +#if (MESH_DEBUG && ARDUINO) || defined(MESHCORE_SIMULATOR) #include #define MESH_DEBUG_PRINT(F, ...) Serial.printf("DEBUG: " F, ##__VA_ARGS__) #define MESH_DEBUG_PRINTLN(F, ...) Serial.printf("DEBUG: " F "\n", ##__VA_ARGS__) diff --git a/src/Packet.cpp b/src/Packet.cpp index aad3e2f48e..3a12f018cc 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -1,13 +1,19 @@ #include "Packet.h" +#include "Utils.h" #include #include namespace mesh { +static const uint8_t ZERO_HASH[MAX_HASH_SIZE] = { 0 }; + Packet::Packet() { header = 0; path_len = 0; payload_len = 0; + memcpy(hash, ZERO_HASH, MAX_HASH_SIZE); + memset(hash_hex, 0, sizeof(hash_hex)); + sending_attempts = 0; } bool Packet::isValidPathLen(uint8_t path_len) { @@ -38,15 +44,24 @@ int Packet::getRawLength() const { return 2 + getPathByteLen() + payload_len + (hasTransportCodes() ? 4 : 0); } -void Packet::calculatePacketHash(uint8_t* hash) const { - SHA256 sha; - uint8_t t = getPayloadType(); - sha.update(&t, 1); - if (t == PAYLOAD_TYPE_TRACE) { - sha.update(&path_len, sizeof(path_len)); // CAVEAT: TRACE packets can revisit same node on return path +uint8_t *Packet::calculatePacketHash() const { + if (memcmp(this->hash, ZERO_HASH, MAX_HASH_SIZE) == 0) { + SHA256 sha; + uint8_t t = getPayloadType(); + sha.update(&t, 1); + if (t == PAYLOAD_TYPE_TRACE) { + sha.update(&path_len, sizeof(path_len)); // CAVEAT: TRACE packets can revisit same node on return path + } + sha.update(payload, payload_len); + sha.finalize((uint8_t *)this->hash, MAX_HASH_SIZE); } - sha.update(payload, payload_len); - sha.finalize(hash, MAX_HASH_SIZE); + return (uint8_t *)this->hash; +} + +const char* Packet::getHashHex() const { + calculatePacketHash(); + Utils::toHex(hash_hex, hash, MAX_HASH_SIZE); + return hash_hex; } uint8_t Packet::writeTo(uint8_t dest[]) const { diff --git a/src/Packet.h b/src/Packet.h index 0886a06c4e..044ff59b39 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -49,12 +49,17 @@ class Packet { uint8_t path[MAX_PATH_SIZE]; uint8_t payload[MAX_PACKET_PAYLOAD]; int8_t _snr; + uint8_t hash[MAX_HASH_SIZE]; + mutable char hash_hex[MAX_HASH_SIZE * 2 + 1]; + uint8_t sending_attempts; /** * \brief calculate the hash of payload + type - * \param dest_hash destination to store the hash (must be MAX_HASH_SIZE bytes) + * \return hash pointer */ - void calculatePacketHash(uint8_t* dest_hash) const; + uint8_t *calculatePacketHash() const; + + const char* getHashHex() const; /** * \returns one of ROUTE_ values diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index cae8bfd8a4..83b9abe902 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -89,7 +89,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - // next: 291 + file.read((uint8_t *)&_prefs->max_resend_attempts, sizeof(_prefs->max_resend_attempts)); // 291 + // next: 292 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -119,6 +120,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // sanitise settings _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean + _prefs->max_resend_attempts = constrain(_prefs->max_resend_attempts, 0, 5); file.close(); } @@ -180,7 +182,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - // next: 291 + file.write((uint8_t *)&_prefs->max_resend_attempts, sizeof(_prefs->max_resend_attempts)); // 291 + // next: 292 file.close(); } @@ -494,6 +497,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->multi_acks = atoi(&config[11]); savePrefs(); strcpy(reply, "OK"); + } else if (memcmp(config, "max.resend ", 11) == 0) { + int v = atoi(&config[11]); + if (v < 0 || v > 5) { + strcpy(reply, "ERROR: max.resend must be 0-5"); + } else { + _prefs->max_resend_attempts = (uint8_t)v; + savePrefs(); + strcpy(reply, "OK"); + } } else if (memcmp(config, "allow.read.only ", 16) == 0) { _prefs->allow_read_only = memcmp(&config[16], "on", 2) == 0; savePrefs(); @@ -748,6 +760,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4); } else if (memcmp(config, "multi.acks", 10) == 0) { sprintf(reply, "> %d", (uint32_t) _prefs->multi_acks); + } else if (memcmp(config, "max.resend", 10) == 0) { + sprintf(reply, "> %d", (uint32_t) _prefs->max_resend_attempts); } else if (memcmp(config, "allow.read.only", 15) == 0) { sprintf(reply, "> %s", _prefs->allow_read_only ? "on" : "off"); } else if (memcmp(config, "flood.advert.interval", 21) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index ffdc7c6536..d88b4fc68b 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -61,6 +61,7 @@ struct NodePrefs { // persisted to file uint8_t rx_boosted_gain; // power settings uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; + uint8_t max_resend_attempts; // 0 = disabled, 1-5, default 2 }; class CommonCLICallbacks { diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 0b79cfb422..36716574b7 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -7,16 +7,21 @@ #endif #define MAX_PACKET_HASHES (128+32) +#define MAX_PACKET_ACKS 64 class SimpleMeshTables : public mesh::MeshTables { uint8_t _hashes[MAX_PACKET_HASHES*MAX_HASH_SIZE]; int _next_idx; + uint32_t _acks[MAX_PACKET_ACKS]; + int _next_ack_idx; uint32_t _direct_dups, _flood_dups; public: SimpleMeshTables() { memset(_hashes, 0, sizeof(_hashes)); _next_idx = 0; + memset(_acks, 0, sizeof(_acks)); + _next_ack_idx = 0; _direct_dups = _flood_dups = 0; } @@ -32,12 +37,31 @@ class SimpleMeshTables : public mesh::MeshTables { #endif bool hasSeen(const mesh::Packet* packet) override { - uint8_t hash[MAX_HASH_SIZE]; - packet->calculatePacketHash(hash); + if (packet->getPayloadType() == PAYLOAD_TYPE_ACK) { + uint32_t ack; + memcpy(&ack, packet->payload, 4); + for (int i = 0; i < MAX_PACKET_ACKS; i++) { + if (ack == _acks[i]) { + if (packet->isRouteDirect()) { + _direct_dups++; // keep some stats + } else { + _flood_dups++; + } + MESH_DEBUG_PRINTLN("SimpleMeshTables::hasSeen(): packet %s already seen", packet->getHashHex()); + return true; + } + } + + _acks[_next_ack_idx] = ack; + _next_ack_idx = (_next_ack_idx + 1) % MAX_PACKET_ACKS; // cyclic table + return false; + } + + packet->calculatePacketHash(); const uint8_t* sp = _hashes; for (int i = 0; i < MAX_PACKET_HASHES; i++, sp += MAX_HASH_SIZE) { - if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { + if (memcmp(packet->hash, sp, MAX_HASH_SIZE) == 0) { if (packet->isRouteDirect()) { _direct_dups++; // keep some stats } else { @@ -47,20 +71,30 @@ class SimpleMeshTables : public mesh::MeshTables { } } - memcpy(&_hashes[_next_idx*MAX_HASH_SIZE], hash, MAX_HASH_SIZE); + memcpy(&_hashes[_next_idx * MAX_HASH_SIZE], packet->hash, MAX_HASH_SIZE); _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; // cyclic table return false; } void clear(const mesh::Packet* packet) override { - uint8_t hash[MAX_HASH_SIZE]; - packet->calculatePacketHash(hash); + if (packet->getPayloadType() == PAYLOAD_TYPE_ACK) { + uint32_t ack; + memcpy(&ack, packet->payload, 4); + for (int i = 0; i < MAX_PACKET_ACKS; i++) { + if (ack == _acks[i]) { + _acks[i] = 0; + break; + } + } + } else { + packet->calculatePacketHash(); - uint8_t* sp = _hashes; - for (int i = 0; i < MAX_PACKET_HASHES; i++, sp += MAX_HASH_SIZE) { - if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { - memset(sp, 0, MAX_HASH_SIZE); - break; + uint8_t* sp = _hashes; + for (int i = 0; i < MAX_PACKET_HASHES; i++, sp += MAX_HASH_SIZE) { + if (memcmp(packet->hash, sp, MAX_HASH_SIZE) == 0) { + memset(sp, 0, MAX_HASH_SIZE); + break; + } } } } diff --git a/src/helpers/StaticPoolPacketManager.cpp b/src/helpers/StaticPoolPacketManager.cpp index b8926df0cc..7677ea368c 100644 --- a/src/helpers/StaticPoolPacketManager.cpp +++ b/src/helpers/StaticPoolPacketManager.cpp @@ -80,6 +80,8 @@ mesh::Packet* StaticPoolPacketManager::allocNew() { } void StaticPoolPacketManager::free(mesh::Packet* packet) { + memset(packet->hash, 0, MAX_HASH_SIZE); // clear stale cached hash so calculatePacketHash() recomputes on next use + packet->hash_hex[0] = '\0'; unused.add(packet, 0, 0); } From 5b387eed26f3d2df56eee20181879d21f9ebd06b Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Tue, 2 Jun 2026 18:31:00 +0200 Subject: [PATCH 03/11] Added comments to explain changes related to the repeated-sending feature --- src/Dispatcher.cpp | 25 ++++++++++++++++++++++++- src/helpers/SimpleMeshTables.h | 15 +++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 9195fc12d6..6e7a34eb3a 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -147,7 +147,22 @@ void Dispatcher::loop() { checkRecv(); checkSend(); - // Do noise floor calibration LAST, when no critical operations are pending + // Noise floor calibration is placed here intentionally – at the very end of loop(), + // after checkRecv() and checkSend() have completed. This avoids two classes of problem: + // + // 1. Calibration disrupts radio reception: triggerNoiseFloorCalibrate() temporarily + // takes the radio out of normal RX mode. Running it at the top of loop() (the + // previous location) risked aborting an incoming packet that had not yet been read + // out by _radio->loop() / checkRecv(). + // + // 2. Interference with outbound retransmissions: the repeated-sending feature queues + // direct-routed packets for re-transmission (resendPacket()). Calibrating while + // packets are pending in the outbound queue would disrupt those time-sensitive + // transmissions. The guard '_mgr->getOutboundCount() == 0' ensures calibration + // is deferred until the queue is empty. + // + // Calibration is a low-priority background task and must only run when the radio is + // demonstrably idle. if (millisHasNowPassed(next_floor_calib_time)) { if (!_radio->isReceiving() && _mgr->getOutboundCount(_ms->getMillis()) == 0) { _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); @@ -199,6 +214,14 @@ bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) { } void Dispatcher::checkRecv() { + // Drain the entire RX buffer in one loop() pass instead of processing only a single + // packet per call. This is critical for the repeated-sending / retransmit-cancellation + // mechanism: when a downstream relay forwards our packet we must detect that forwarding + // echo (via hash comparison in onRecvPacket()) before the scheduled retransmit timer + // fires. With the old single-read design the echo might not be consumed until a later + // loop() iteration, by which time the retransmit had already been sent unnecessarily. + // Draining all pending packets here minimises that race window. + // As a secondary benefit this prevents RX-FIFO overflow under high packet load. while (true) { Packet *pkt = nullptr; diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 36716574b7..3f60d684f9 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -37,6 +37,21 @@ class SimpleMeshTables : public mesh::MeshTables { #endif bool hasSeen(const mesh::Packet* packet) override { + // ACK packets receive dedicated deduplication for three reasons: + // + // 1. The first 4 payload bytes (ack_crc) already uniquely identify the ACK – they + // are the CRC of the original packet being acknowledged. A direct uint32_t + // comparison is therefore both correct and far cheaper than computing a full + // SHA256 via calculatePacketHash() (which the general path below would do). + // + // 2. With repeated sending (max_resend_attempts > 0) and multi-ACKs the same ACK + // can arrive multiple times. Storing each occurrence in the shared _hashes[] + // table (160 entries) would evict entries needed for long-lived flood-packet + // deduplication. The dedicated _acks[] table (MAX_PACKET_ACKS entries) keeps + // ACK and flood deduplication budgets separate. + // + // 3. clear() for ACKs is a simple 4-byte linear scan; using the general table + // would require SHA256 + a full memcmp loop over MAX_PACKET_HASHES entries. if (packet->getPayloadType() == PAYLOAD_TYPE_ACK) { uint32_t ack; memcpy(&ack, packet->payload, 4); From e9ba4000f4c3d4a2012df4d4be2f709b9d2a55db Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Thu, 18 Jun 2026 13:16:03 +0200 Subject: [PATCH 04/11] Implemented special resend handling for TRACE packages and moved logic to isRetryMatch method for packet retransmission handling --- src/Mesh.cpp | 20 ++++++++++---------- src/Packet.cpp | 26 ++++++++++++++++++++++++++ src/Packet.h | 9 +++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 31b77fc058..1fb7fae627 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -74,20 +74,21 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (pkt->isRouteDirect()) { const bool is_next_hop = (pkt->getPathHashCount() > 0) && self_id.isHashMatch(pkt->path, pkt->getPathHashSize()); - // Check if this is a retransmit of a packet we recently sent; - // if yes, the next hop successfully forwarded it, so remove our scheduled retransmit from outbound queue. - // This runs for ANY path_len (including 0 = downstream relay forwarding to final destination), - // so that we cancel pending retries as soon as we overhear a relay forwarding our packet. // Only do this when the packet is NOT addressed to us as next hop to avoid dropping fresh packets. if (!is_next_hop) { - pkt->calculatePacketHash(); - const uint8_t *recv_hash = pkt->hash; + // Check if this is a retransmit of a packet we recently sent; + // if yes, the next hop successfully forwarded it, so remove our scheduled retransmit from outbound queue. + // This runs for ANY path_len (including 0 = downstream relay forwarding to final destination), + // so that we cancel pending retries as soon as we overhear a relay forwarding our packet. + // + // For TRACE packets we cannot use the packet hash, because TRACE appends SNR bytes + // and increments path_len per hop, which changes the hash. Packet::isRetryMatch() + // handles this by comparing payload and SNR prefix for TRACE, and hash for all others. // First check the current outbound packet being prepared/sent if (outbound && outbound->sending_attempts > 0 && outbound->isRouteDirect()) { - const uint8_t *outbound_hash = outbound->calculatePacketHash(); - if (memcmp(recv_hash, outbound_hash, MAX_HASH_SIZE) == 0) { + if (pkt->isRetryMatch(outbound)) { MESH_DEBUG_PRINTLN( "%s Mesh::onRecvPacket(): downstream forwarded current outbound, canceling (attempt=%d)", getLogDateTime(), outbound->sending_attempts); @@ -101,8 +102,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { for (int i = _mgr->getOutboundTotal() - 1; i >= 0; i--) { Packet *queued_pkt = _mgr->getOutboundByIdx(i); if (queued_pkt && queued_pkt->sending_attempts > 0 && queued_pkt->isRouteDirect()) { - const uint8_t *queued_hash = queued_pkt->calculatePacketHash(); - if (memcmp(recv_hash, queued_hash, MAX_HASH_SIZE) == 0) { + if (pkt->isRetryMatch(queued_pkt)) { MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): downstream forwarded packet detected, canceling " "retransmit (attempt=%d)", getLogDateTime(), queued_pkt->sending_attempts); diff --git a/src/Packet.cpp b/src/Packet.cpp index 3a12f018cc..5d1984ad14 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -64,6 +64,32 @@ const char* Packet::getHashHex() const { return hash_hex; } +bool Packet::isRetryMatch(const Packet* outbound) const { + // Only packets of the same payload type can be retries of each other. + if (this->getPayloadType() != outbound->getPayloadType()) return false; + + // TRACE packets append an SNR byte to path[] and increment path_len per hop. + // A forwarded TRACE therefore has the same payload and a longer path with the + // previous hop's path as a prefix. Accept any downstream hop, not just the + // immediate next hop, so retries are canceled as soon as we overhear a later + // forward of the same TRACE. + if (this->getPayloadType() == PAYLOAD_TYPE_TRACE) { + if (this->payload_len != outbound->payload_len) return false; + if (memcmp(this->payload, outbound->payload, this->payload_len) != 0) return false; + if (this->path_len <= outbound->path_len) return false; + if (outbound->path_len > 0 && + memcmp(this->path, outbound->path, outbound->path_len) != 0) { + return false; + } + return true; + } + + // Default behaviour for all other payload types: compare cached hashes. + const uint8_t* h1 = this->calculatePacketHash(); + const uint8_t* h2 = outbound->calculatePacketHash(); + return memcmp(h1, h2, MAX_HASH_SIZE) == 0; +} + uint8_t Packet::writeTo(uint8_t dest[]) const { uint8_t i = 0; dest[i++] = header; diff --git a/src/Packet.h b/src/Packet.h index 044ff59b39..d6d6a3946c 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -59,6 +59,15 @@ class Packet { */ uint8_t *calculatePacketHash() const; + /** + * \brief Check if this received packet is a downstream forwarding of the + * supplied outbound packet, so that a pending retransmit can be cancelled. + * For TRACE packets the payload and SNR path prefix are compared, + * because TRACE changes path_len (and thus its hash) at every hop. + * For all other types the cached packet hash is compared. + */ + bool isRetryMatch(const Packet* outbound) const; + const char* getHashHex() const; /** From 389ea15e66d8f803e7c6e54015397619bf4e5f05 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 19 Jun 2026 21:20:12 +0200 Subject: [PATCH 05/11] TRACE packet handling, prevent repeated sending to the last hop as cancellation cannot be determined --- src/Mesh.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 1fb7fae627..1bf04f80d8 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -57,6 +57,14 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { // append SNR (Not hash!) pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4); + // Last forwarding hop before the TRACE destination: the destination does not forward, + // so a retry could never be cancelled by overhearing a downstream forward. To avoid + // flooding the destination, exhaust the retry budget now (no retransmissions from + // this hop). Higher layers or the sender must retry the entire TRACE if needed. + if (offset + (1 << path_sz) >= len) { + pkt->sending_attempts = getMaxResendAttempts(); + } + uint32_t d = getDirectRetransmitDelay(pkt); return ACTION_RETRANSMIT_DELAYED(5, d); // schedule with priority 5 (for now), maybe make configurable? } From 5096227ecf60b138ad355a9c27bae8e3add520b1 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 19 Jun 2026 21:26:13 +0200 Subject: [PATCH 06/11] Reduce max resend attempts limit to 3 in CLI commands and related files to prevent too many package repetitions --- docs/cli_commands.md | 2 +- examples/companion_radio/MyMesh.cpp | 4 ++-- examples/companion_radio/NodePrefs.h | 2 +- src/Dispatcher.h | 2 +- src/helpers/CommonCLI.cpp | 6 +++--- src/helpers/CommonCLI.h | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 2a1ebf9ecb..f830e58e5a 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -472,7 +472,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `set max.resend ` **Parameters:** -- `value`: Maximum number of resend attempts for direct-routed packets (0–5). `0` disables resending entirely. +- `value`: Maximum number of resend attempts for direct-routed packets (0–3). `0` disables resending entirely. **Default:** `2` diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 09bbec2dab..7eebb67a47 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -937,7 +937,7 @@ void MyMesh::begin(bool has_display) { _prefs.tx_power_dbm = constrain(_prefs.tx_power_dbm, -9, MAX_LORA_TX_POWER); _prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1 _prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours - _prefs.max_resend_attempts = constrain(_prefs.max_resend_attempts, 0, 5); + _prefs.max_resend_attempts = constrain(_prefs.max_resend_attempts, 0, 3); #ifdef BLE_PIN_CODE // 123456 by default if (_prefs.ble_pin == 0) { @@ -1441,7 +1441,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (len >= 5) { _prefs.multi_acks = cmd_frame[4]; if (len >= 6) { - _prefs.max_resend_attempts = constrain(cmd_frame[5], 0, 5); + _prefs.max_resend_attempts = constrain(cmd_frame[5], 0, 3); } } } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 6acf5554e1..04ec00b1c9 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -34,5 +34,5 @@ struct NodePrefs { // persisted to file uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) char default_scope_name[31]; uint8_t default_scope_key[16]; - uint8_t max_resend_attempts; // 0 = disabled, 1-5, default 2 + uint8_t max_resend_attempts; // 0 = disabled, 1-3, default 2 }; \ No newline at end of file diff --git a/src/Dispatcher.h b/src/Dispatcher.h index e501d4351c..19d162cb1f 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -173,7 +173,7 @@ class Dispatcher { virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } /** - * \returns maximum number of direct-route resend attempts (0 = disabled, default = 2, max = 5). + * \returns maximum number of direct-route resend attempts (0 = disabled, default = 2, max = 3). */ virtual uint8_t getMaxResendAttempts() const { return 2; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 19564192a1..8125aaf805 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -122,7 +122,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // sanitise settings _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean - _prefs->max_resend_attempts = constrain(_prefs->max_resend_attempts, 0, 5); + _prefs->max_resend_attempts = constrain(_prefs->max_resend_attempts, 0, 3); file.close(); } @@ -513,8 +513,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "OK"); } else if (memcmp(config, "max.resend ", 11) == 0) { int v = atoi(&config[11]); - if (v < 0 || v > 5) { - strcpy(reply, "ERROR: max.resend must be 0-5"); + if (v < 0 || v > 3) { + strcpy(reply, "ERROR: max.resend must be 0-3"); } else { _prefs->max_resend_attempts = (uint8_t)v; savePrefs(); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 33f6eb28aa..2961517454 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -63,7 +63,7 @@ struct NodePrefs { // persisted to file uint8_t rx_boosted_gain; // power settings uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; - uint8_t max_resend_attempts; // 0 = disabled, 1-5, default 2 + uint8_t max_resend_attempts; // 0 = disabled, 1-3, default 2 }; class CommonCLICallbacks { From 3856e49e9b4ca92b3bc9c217b20f0a34e0609a0b Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 26 Jun 2026 22:50:56 +0200 Subject: [PATCH 07/11] Bugfix: check on path hash count > 0 to determine a packet resending --- src/Dispatcher.cpp | 6 +++--- src/Mesh.cpp | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 39506116bc..5f26ac9bba 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -437,9 +437,9 @@ unsigned long Dispatcher::futureMillis(int millis_from_now) const { bool Dispatcher::resendPacket(mesh::Packet *packet) { // prepare error correction via potential retransmit: - // re-send only direct routed packets, with remaining path hops whose retransmits can be recognized; - // the final hop will ACK separately, so out-of-scope here - if (packet->isRouteDirect() && packet->path_len > 0 && packet->sending_attempts < getMaxResendAttempts()) { + // re-send only direct routed packets that carry at least one relay hash, so that a + // downstream relay's forward can be overheard to cancel this re-send. + if (packet->isRouteDirect() && packet->getPathHashCount() > 0 && packet->sending_attempts < getMaxResendAttempts()) { packet->sending_attempts++; MESH_DEBUG_PRINTLN("Dispatcher::resendPacket %s attempt=%d", packet->getHashHex(), diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 1d45fa3f16..dcd357100f 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -126,9 +126,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } - // For path_len=0 direct packets, no further path-based processing applies; - // fall through to general switch-case handling (e.g. ACK delivery via onAckRecv). - if (pkt->path_len < PATH_HASH_SIZE) goto direct_path_done; + // For direct packets with no relay hashes (zero-hop, addressed straight to us), + // no further path-based processing applies + if (pkt->getPathHashCount() == 0) goto direct_path_done; // check for 'early received' ACK if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { From 84bc3faf7a9d183c8c4f9ca5b2739a2534ca482b Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sun, 5 Jul 2026 20:25:35 +0200 Subject: [PATCH 08/11] Implement final hop ACK handling for direct packets to allow cancellable resends --- src/Dispatcher.cpp | 8 +++++- src/Mesh.cpp | 65 +++++++++++++++++++++++++++++++++++++++++++--- src/Mesh.h | 1 + src/Packet.cpp | 1 + src/Packet.h | 3 +++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 5f26ac9bba..bda4a8cee2 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -439,7 +439,13 @@ bool Dispatcher::resendPacket(mesh::Packet *packet) { // prepare error correction via potential retransmit: // re-send only direct routed packets that carry at least one relay hash, so that a // downstream relay's forward can be overheard to cancel this re-send. - if (packet->isRouteDirect() && packet->getPathHashCount() > 0 && packet->sending_attempts < getMaxResendAttempts()) { + // The final relay hop (path empty after removeSelfFromPath, flagged via + // final_hop_ack_resend) is the exception: there is no downstream forward to overhear, but + // the destination ACKs receipt. Allow exactly one resend there (sending_attempts == 0), + // cancellable by the returning ACK (see Mesh::cancelPendingFinalHopResend). + if (packet->isRouteDirect() && packet->sending_attempts < getMaxResendAttempts() && + (packet->getPathHashCount() > 0 || + (packet->final_hop_ack_resend && packet->sending_attempts == 0))) { packet->sending_attempts++; MESH_DEBUG_PRINTLN("Dispatcher::resendPacket %s attempt=%d", packet->getHashHex(), diff --git a/src/Mesh.cpp b/src/Mesh.cpp index a9b4c6966a..cd3b881f51 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -128,8 +128,20 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } // For direct packets with no relay hashes (zero-hop, addressed straight to us), - // no further path-based processing applies - if (pkt->getPathHashCount() == 0) goto direct_path_done; + // no further path-based processing applies. + // A destination's receipt-ACK for a TXT_MSG we delivered as the final relay hop arrives + // exactly this way: zero-path direct, because the destination ACKs its immediate + // neighbour (it has no usable multi-hop return path to the originator, so the ACK only + // travels one hop). The ACK-relay branch below is gated on is_next_hop, which requires + // getPathHashCount() > 0 — unreachable for such an ACK. Cancel any pending final-hop + // resend here, before the early-exit, so the resend does not fire after a delivery the + // destination has already acknowledged. + if (pkt->getPathHashCount() == 0) { + if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { + cancelPendingFinalHopResend(); + } + goto direct_path_done; + } // check for 'early received' ACK if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { @@ -145,6 +157,12 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) { return forwardMultipartDirect(pkt); } else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { + // This ACK is addressed back along the return path with us as the next hop, i.e. it + // transits back through the very relay that delivered the original message. That is + // proof the destination received it: cancel any pending final-hop resend so we do not + // needlessly retransmit to the destination. + cancelPendingFinalHopResend(); + if (!_tables->wasSeen(pkt)) { // don't retransmit! _tables->markSeen(pkt); removeSelfFromPath(pkt); @@ -155,12 +173,25 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (!_tables->wasSeen(pkt)) { _tables->markSeen(pkt); + + // Detect the final relay hop: only our own hash remained in the path, so after + // removeSelfFromPath() the destination is reached implicitly (it lives in the payload + // dest_hash, not as a relay hash) and getPathHashCount() becomes 0. The normal resend + // guard (getPathHashCount() > 0) would then suppress any retransmit, leaving a lost + // final-hop TX unrecoverable at the relay layer. TXT_MSG destinations ACK receipt, so + // mark this packet to allow exactly one ACK-cancellable resend (see resendPacket() and + // cancelPendingFinalHopResend()): the resend self-cancels on the returning ACK, so the + // destination is not flooded. + if (pkt->getPathHashCount() == 1 && pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { + pkt->final_hop_ack_resend = true; + } + removeSelfFromPath(pkt); MESH_DEBUG_PRINTLN("Mesh::onRecvPacket(): prepare to repeat packet %s", pkt->getHashHex()); uint32_t d = getDirectRetransmitDelay(pkt); - return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority + return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority } } return ACTION_RELEASE; // this node is NOT the next hop (OR this packet has already been forwarded), so discard. @@ -399,6 +430,34 @@ void Mesh::removeSelfFromPath(Packet* pkt) { } } +void Mesh::cancelPendingFinalHopResend() { + // The destination has ACKed receipt. ACKs are produced in delivery order and (for the + // direct zero-path ACKs a companion sends) travel only one hop back to us, so the OLDEST + // pending final-hop resend corresponds to the message this ACK acknowledges (FIFO). Cancel + // exactly one — the earliest still-queued resend. + // + // The sending_attempts > 0 guard is essential: it selects only actual resends (the attempt + // counter is bumped in resendPacket() before re-queueing) and never the original final-hop + // forward, which is also flagged final_hop_ack_resend while it sits in the queue waiting to + // be TXed. Without it, an ACK arriving in that brief pre-TX window would cancel the delivery + // itself. (send_queue.add() appends, so index 0 is the oldest entry.) + // + // An in-flight resend (the 'outbound' packet, already on-air) is deliberately NOT touched: + // aborting a mid-TX send risks radio state, and the duplicate it delivers is harmless (the + // destination dedups via wasSeen). Only queued, not-yet-sent resends are cancelled. + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + Packet* queued = _mgr->getOutboundByIdx(i); + if (queued && queued->final_hop_ack_resend && queued->sending_attempts > 0 && + queued->isRouteDirect() && queued->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { + MESH_DEBUG_PRINTLN("%s Mesh::cancelPendingFinalHopResend(): ACK heard, canceling oldest " + "final-hop resend (queued) %s", getLogDateTime(), queued->getHashHex()); + Packet* removed = _mgr->removeOutboundByIdx(i); + if (removed) _mgr->free(removed); + return; + } + } +} + DispatcherAction Mesh::routeRecvPacket(Packet* packet) { uint8_t n = packet->getPathHashCount(); if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit() diff --git a/src/Mesh.h b/src/Mesh.h index 097aa555f6..ff961ffcd1 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -31,6 +31,7 @@ class Mesh : public Dispatcher { void removeSelfFromPath(Packet* packet); void routeDirectRecvAcks(Packet* packet, uint32_t delay_millis); + void cancelPendingFinalHopResend(); // drop a queued final-hop resend when the destination's ACK transits back //void routeRecvAcks(Packet* packet, uint32_t delay_millis); DispatcherAction forwardMultipartDirect(Packet* pkt); diff --git a/src/Packet.cpp b/src/Packet.cpp index 5d1984ad14..24629a79a9 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -14,6 +14,7 @@ Packet::Packet() { memcpy(hash, ZERO_HASH, MAX_HASH_SIZE); memset(hash_hex, 0, sizeof(hash_hex)); sending_attempts = 0; + final_hop_ack_resend = false; } bool Packet::isValidPathLen(uint8_t path_len) { diff --git a/src/Packet.h b/src/Packet.h index 3fe4807668..49fbec4d29 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -52,6 +52,9 @@ class Packet { uint8_t hash[MAX_HASH_SIZE]; mutable char hash_hex[MAX_HASH_SIZE * 2 + 1]; uint8_t sending_attempts; + bool final_hop_ack_resend; // runtime-only: set when this node forwards the final relay hop + // of an ACK-producing direct packet (e.g. TXT_MSG). Allows exactly + // one ACK-cancellable resend from resendPacket(); not wire-serialized. /** * \brief calculate the hash of payload + type From 2c48f09c854dfda8c7b85a0f8b3c747c45771220 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Tue, 7 Jul 2026 13:57:18 +0200 Subject: [PATCH 09/11] Add non-invasive resend channel check and backoff mechanism for packet retransmission --- examples/companion_radio/MyMesh.cpp | 13 +++++++++++++ examples/companion_radio/MyMesh.h | 1 + examples/simple_repeater/MyMesh.cpp | 12 ++++++++++++ examples/simple_repeater/MyMesh.h | 1 + examples/simple_room_server/MyMesh.cpp | 12 ++++++++++++ examples/simple_room_server/MyMesh.h | 1 + src/Dispatcher.cpp | 21 +++++++++++++++++++-- src/Dispatcher.h | 6 ++++++ src/helpers/StaticPoolPacketManager.cpp | 18 ++++++++++++++++++ src/helpers/StaticPoolPacketManager.h | 2 ++ 10 files changed, 85 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 080ddf10ab..01b5dc3791 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -106,6 +106,10 @@ #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 #define LAZY_CONTACTS_WRITE_DELAY 5000 +#ifndef RESEND_INTERFERENCE_MARGIN + #define RESEND_INTERFERENCE_MARGIN 12 // dB above noise floor that blocks a resend (non-invasive LBT) +#endif + #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" // these are _pushed_ to client app at any time @@ -265,6 +269,15 @@ bool MyMesh::getCADEnabled() const { return true; // hardware CAD before TX (no CLI toggle on companion; enabled by default) } +bool MyMesh::isResendChannelActive() { + // Non-invasive resend LBT (NO CAD, so RX stays open to overhear the downstream forward + // and cancel the resend). Block the resend when a LoRa preamble/header is being received + // (often that forward itself) OR the live channel energy is well above the noise floor + // (foreign interference). isReceivingPacket()/getCurrentRSSI() are IRQ/register reads. + int margin = (int)radio_driver.getCurrentRSSI() - _radio->getNoiseFloor(); + return radio_driver.isReceivingPacket() || (margin >= RESEND_INTERFERENCE_MARGIN); +} + 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); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 4f361bad11..0537ea20a0 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -106,6 +106,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; bool getCADEnabled() const override; + bool isResendChannelActive() override; int calcRxDelay(float score, uint32_t air_time) const override; uint32_t getRetransmitDelay(const mesh::Packet *packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 01c94b6d6d..83e3c96178 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -60,6 +60,10 @@ #define LAZY_CONTACTS_WRITE_DELAY 5000 +#ifndef RESEND_INTERFERENCE_MARGIN + #define RESEND_INTERFERENCE_MARGIN 12 // dB above noise floor that blocks a resend (non-invasive LBT) +#endif + 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 @@ -549,6 +553,14 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { return getRNG()->nextInt(0, 5*t + 1); } +bool MyMesh::isResendChannelActive() { + // Non-invasive resend LBT (NO CAD, so RX stays open to overhear the downstream forward + // and cancel the resend). Block when a LoRa preamble/header is being received (often that + // forward) OR the live channel energy is well above the noise floor (foreign interference). + int margin = (int)radio_driver.getCurrentRSSI() - _radio->getNoiseFloor(); + return radio_driver.isReceivingPacket() || (margin >= RESEND_INTERFERENCE_MARGIN); +} + mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index d659b81adb..11cd93d8dd 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -153,6 +153,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { bool getCADEnabled() const override { return _prefs.cad_enabled; } + bool isResendChannelActive() override; // non-invasive resend LBT (preamble/RSSI, no CAD) int getAGCResetInterval() const override { return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index a34085ec4f..9991f3d2f9 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -10,6 +10,10 @@ #define POST_SYNC_DELAY_SECS 6 +#ifndef RESEND_INTERFERENCE_MARGIN + #define RESEND_INTERFERENCE_MARGIN 12 // dB above noise floor that blocks a resend (non-invasive LBT) +#endif + #define FIRMWARE_VER_LEVEL 1 #define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS @@ -280,6 +284,14 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { return getRNG()->nextInt(0, 5*t + 1); } +bool MyMesh::isResendChannelActive() { + // Non-invasive resend LBT (NO CAD, so RX stays open to overhear the downstream forward + // and cancel the resend). Block when a LoRa preamble/header is being received (often that + // forward) OR the live channel energy is well above the noise floor (foreign interference). + int margin = (int)radio_driver.getCurrentRSSI() - _radio->getNoiseFloor(); + return radio_driver.isReceivingPacket() || (margin >= RESEND_INTERFERENCE_MARGIN); +} + bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; if (packet->isRouteFlood()) { diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 2741b26e17..8f21f3f3ca 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -147,6 +147,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { bool getCADEnabled() const override { return _prefs.cad_enabled; } + bool isResendChannelActive() override; // non-invasive resend LBT (preamble/RSSI, no CAD) int getAGCResetInterval() const override { return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds } diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index bda4a8cee2..bfd4a80480 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -16,6 +16,10 @@ namespace mesh { #define MIN_TX_BUDGET_RESERVE_MS 100 // min budget (ms) required before allowing next TX #define MIN_TX_BUDGET_AIRTIME_DIV 2 // require at least 1/N of estimated airtime as budget before TX +#ifndef RESEND_BACKOFF_STRETCH_MS + #define RESEND_BACKOFF_STRETCH_MS 1000 // linear per-attempt resend backoff (ride out interference) +#endif + #ifndef NOISE_FLOOR_CALIB_INTERVAL #define NOISE_FLOOR_CALIB_INTERVAL 2000 // 2 seconds #endif @@ -326,7 +330,16 @@ void Dispatcher::checkSend() { } if (!millisHasNowPassed(next_tx_time)) return; - if (_radio->isReceiving()) { + + // Pick the channel-busy check by packet kind. Resends (direct, already attempted) + // use a NON-invasive gate via isResendChannelActive() so the radio stays in RX and + // can still overhear the downstream forward → resend cancellation. First sends keep + // the CAD-based carrier sense (collision avoidance worth the momentary deafness). + Packet* pending = _mgr->peekNextOutbound(_ms->getMillis()); + bool resend_lbt = pending && pending->isRouteDirect() && pending->sending_attempts > 0; + bool channel_busy = resend_lbt ? isResendChannelActive() : _radio->isReceiving(); + + if (channel_busy) { if (cad_busy_start == 0) { cad_busy_start = _ms->getMillis(); // record when CAD busy state started } @@ -459,7 +472,11 @@ bool Dispatcher::resendPacket(mesh::Packet *packet) { uint32_t retransmit_delay = getDirectRetransmitDelay(packet); uint32_t packet_airtime_ms = _radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2); uint32_t silence_ms = (uint32_t)(packet_airtime_ms * getAirtimeBudgetFactor()); - _mgr->queueOutbound(packet, 1, futureMillis((int)(silence_ms + retransmit_delay + 100))); + // Linear backoff per attempt (sending_attempts was just incremented to 1,2,3…): later + // resends land further out so they cross longer interference bursts, and the extra time + // gives the downstream forward more chance to be overheard → resend cancellation. + uint32_t backoff = (packet->sending_attempts - 1) * RESEND_BACKOFF_STRETCH_MS; + _mgr->queueOutbound(packet, 1, futureMillis((int)(silence_ms + retransmit_delay + 100 + backoff))); return true; } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 882c01da75..226a84b4ff 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -91,6 +91,7 @@ class PacketManager { virtual void queueOutbound(Packet* packet, uint8_t priority, uint32_t scheduled_for) = 0; virtual Packet* getNextOutbound(uint32_t now) = 0; // by priority + virtual Packet* peekNextOutbound(uint32_t now) { return NULL; } // same as getNextOutbound but non-consuming (default: none) virtual int getOutboundCount(uint32_t now) const = 0; virtual int getOutboundTotal() const = 0; virtual int getFreeCount() const = 0; @@ -172,6 +173,11 @@ class Dispatcher { virtual uint32_t getCADFailMaxDuration() const; virtual int getInterferenceThreshold() const { return 0; } // disabled by default virtual bool getCADEnabled() const { return false; } // hardware CAD disabled by default + // Channel-busy check used ONLY for resend TX gating (direct packets with sending_attempts>0). + // Default falls back to CAD carrier sense; examples override with a NON-invasive check + // (preamble/header IRQ + RSSI margin) so RX stays open to overhear the downstream forward + // and cancel the resend. Non-const because _radio->isReceiving() is non-const. + virtual bool isResendChannelActive() { return _radio->isReceiving(); } virtual int getAGCResetInterval() const { return 0; } // disabled by default virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } diff --git a/src/helpers/StaticPoolPacketManager.cpp b/src/helpers/StaticPoolPacketManager.cpp index 7677ea368c..8765af849b 100644 --- a/src/helpers/StaticPoolPacketManager.cpp +++ b/src/helpers/StaticPoolPacketManager.cpp @@ -43,6 +43,20 @@ mesh::Packet* PacketQueue::get(uint32_t now) { return top; } +mesh::Packet* PacketQueue::peek(uint32_t now) const { + // Same selection as get() (highest-priority, non-future entry) but without removing it. + uint8_t min_pri = 0xFF; + int best_idx = -1; + for (int j = 0; j < _num; j++) { + if ((int32_t)(_schedule_table[j] - now) > 0) continue; // scheduled for future... ignore + if (_pri_table[j] < min_pri) { + min_pri = _pri_table[j]; + best_idx = j; + } + } + return (best_idx >= 0) ? _table[best_idx] : NULL; +} + mesh::Packet* PacketQueue::removeByIdx(int i) { if (i >= _num) return NULL; // invalid index @@ -97,6 +111,10 @@ mesh::Packet* StaticPoolPacketManager::getNextOutbound(uint32_t now) { return send_queue.get(now); } +mesh::Packet* StaticPoolPacketManager::peekNextOutbound(uint32_t now) { + return send_queue.peek(now); +} + int StaticPoolPacketManager::getOutboundCount(uint32_t now) const { return send_queue.countBefore(now); } diff --git a/src/helpers/StaticPoolPacketManager.h b/src/helpers/StaticPoolPacketManager.h index 59715b4e01..1f3615ee91 100644 --- a/src/helpers/StaticPoolPacketManager.h +++ b/src/helpers/StaticPoolPacketManager.h @@ -11,6 +11,7 @@ class PacketQueue { public: PacketQueue(int max_entries); mesh::Packet* get(uint32_t now); + mesh::Packet* peek(uint32_t now) const; // same selection as get(), but non-consuming bool add(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for); int count() const { return _num; } int countBefore(uint32_t now) const; @@ -28,6 +29,7 @@ class StaticPoolPacketManager : public mesh::PacketManager { void free(mesh::Packet* packet) override; void queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override; mesh::Packet* getNextOutbound(uint32_t now) override; + mesh::Packet* peekNextOutbound(uint32_t now) override; int getOutboundCount(uint32_t now) const override; int getOutboundTotal() const override; int getFreeCount() const override; From 110b9b409fd20219c6f22db4d5a3a16092d9e440 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sat, 11 Jul 2026 11:39:43 +0200 Subject: [PATCH 10/11] fix(resend): reset sending_attempts/final_hop_ack_resend on pool free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sending_attempts and final_hop_ack_resend were only zeroed by the Packet ctor, never on pool reuse. free()/allocNew()/obtainNewPacket() all omit the reset, so a recycled pool slot keeps a stale sending_attempts >= getMaxResendAttempts(). resendPacket() then silently skips resends for every subsequent direct packet routed through that slot. Effect: direct-packet resends work right after boot (fresh pool) but degrade to zero once the pool has turned over (~pool_size sends), while forwards (which don't check sending_attempts) keep working — matching observed 'repeated-sending seems disabled after long runtime' on hardware. Reset both fields in StaticPoolPacketManager::free(), the single chokepoint for returning a packet to the unused pool. Co-Authored-By: Claude --- src/helpers/StaticPoolPacketManager.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/helpers/StaticPoolPacketManager.cpp b/src/helpers/StaticPoolPacketManager.cpp index 8765af849b..1bf7f2ebb8 100644 --- a/src/helpers/StaticPoolPacketManager.cpp +++ b/src/helpers/StaticPoolPacketManager.cpp @@ -96,6 +96,13 @@ mesh::Packet* StaticPoolPacketManager::allocNew() { void StaticPoolPacketManager::free(mesh::Packet* packet) { memset(packet->hash, 0, MAX_HASH_SIZE); // clear stale cached hash so calculatePacketHash() recomputes on next use packet->hash_hex[0] = '\0'; + // Reset per-send resend state. sending_attempts and final_hop_ack_resend are otherwise only + // zeroed by the Packet ctor, so a reused pool slot would keep a stale sending_attempts >= + // getMaxResendAttempts(). resendPacket() would then silently skip resends for every subsequent + // direct packet routed through that slot — resends degrade to zero once the pool has turned over + // (~pool_size direct sends), even though forwards (which don't check sending_attempts) keep working. + packet->sending_attempts = 0; + packet->final_hop_ack_resend = false; unused.add(packet, 0, 0); } From 02a9e3444014186f24c98a80fd69870f790076e7 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sat, 11 Jul 2026 17:43:18 +0200 Subject: [PATCH 11/11] docs: add README for repeated sending feature with detailed explanation and configuration --- README-repeated-sending.md | 57 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 README-repeated-sending.md diff --git a/README-repeated-sending.md b/README-repeated-sending.md new file mode 100644 index 0000000000..a4d1e95f3b --- /dev/null +++ b/README-repeated-sending.md @@ -0,0 +1,57 @@ +# Repeated Sending (branch `feature/repeated-sending-2`) + +Reliable delivery of direct-routed packets via **cancellable retransmissions**. The feature augments DIRECT/TRACE routing with an automatic, self-limiting retransmission that switches itself off one hop downstream as soon as successful forwarding is detected. + +## Problem + +A direct-routed packet (DIRECT / TRACE) that travels to its destination via one or more relays is considered "sent" by the originator as soon as it is on air. If it is lost on the first hop (collision, interference, fading), there is no retry at the MeshCore layer — the packet is gone, and the application layer must re-schedule the entire message. Flood traffic does not have this problem because it is widely broadcast anyway; DIRECT packets, however, are the cost-conscious path for targeted messages. + +## Solution + +After every DIRECT packet is sent, the Dispatcher schedules a retransmission. The key trick: **the originator listens for a downstream relay forwarding the packet**. As soon as it receives that forward (the "forwarding echo"), it knows its packet survived at least the next hop and cancels the pending retransmission. The result is an error-correction mechanism that produces **no** extra packet in the success case, and in the failure case retransmits precisely where the packet actually got stuck. + +Flow in detail: + +1. **Schedule the resend** – `Dispatcher::resendPacket()` runs after TX only for DIRECT packets with at least one relay hash in the path (`getPathHashCount() > 0`). It does not wait for a timeout; instead it re-queues the packet after a computed **silence period** (packet airtime × budget factor + jitter + linear backoff per attempt). The delay gives the downstream repeater time to forward — and gives the originator time to hear that forward *before* it retransmits itself. +2. **Detect the forward and cancel** – In `Mesh::onRecvPacket()`, every received DIRECT packet is checked: is it a downstream forward of one of our own pending resends (`Packet::isRetryMatch()`)? If so, the resend is removed from the outbound queue (`removeOutboundByIdx`). The RX side drains the entire FIFO in a single `loop()` pass to keep the window between forwarding echo and scheduled resend as small as possible. +3. **Non-invasive LBT** – Resends use *no* hardware CAD, but `isResendChannelActive()`: a pure IRQ/register read (preamble detection + RSSI margin above the noise floor). RX stays open so the forwarding echo is not missed. First sends still use normal CAD (collision avoidance is worth the momentary deafness). + +### Final-hop handling + +On the final relay hop there is no downstream forward to overhear — the destination does not forward. Two special paths prevent the last hop from being left unprotected, or the destination from being flooded: + +- **TXT_MSG with ACK** – the destination acknowledges receipt with an ACK. This hop is allowed exactly one ACK-cancellable resend (flag `final_hop_ack_resend`). `Mesh::cancelPendingFinalHopResend()` cancels the oldest final-hop resend still sitting in the queue as soon as the ACK returns (FIFO, since ACKs are produced in delivery order). A resend already on air is deliberately not aborted (the destination dedups the duplicate via `wasSeen`). +- **TRACE** – TRACE packets append an SNR byte per hop, which changes their hash. `isRetryMatch()` therefore compares payload + SNR-path prefix instead of the hash for TRACE. At the final forwarding hop the retry budget is exhausted directly (`sending_attempts = getMaxResendAttempts()`), since a non-cancellable resend here would only burden the destination. + +### Considerate noise-floor calibration + +`Dispatcher::loop()` moves noise-floor calibration to the end of `loop()` and only runs it when the radio is demonstrably idle (`!isReceiving()`) **and** the outbound queue is empty (`getOutboundCount() == 0`). Calibration briefly takes the radio out of RX — that would disrupt pending, time-critical resends or abort a packet not yet read out. + +## Configuration + +| Location | Value | Meaning | +|---|---|---| +| CLI `set max.resend <0–3>` / `get max.resend` | Default `2` | Maximum resend attempts for DIRECT packets. `0` disables the feature entirely. | +| `NodePrefs` (persisted) | Byte offset `295` | Loaded/saved to file via `CommonCLI`; sanitised to 0–3. | +| Companion-radio `CMD_SET_*` frame | Byte 6 in the path block | App-protocol path to set `max_resend_attempts` from the companion. | +| `RESEND_INTERFERENCE_MARGIN` (compile-time) | Default `12` dB | Margin above the noise floor at which a resend is blocked (non-invasive LBT). | +| `RESEND_BACKOFF_STRETCH_MS` (compile-time) | Default `1000` ms | Linear backoff per resend attempt, to ride out longer interference bursts. | + +## Implementation (core files) + +- `src/Dispatcher.cpp` / `.h` – `resendPacket()`, `isResendChannelActive()`, `getMaxResendAttempts()`, relocated noise-floor calibration, RX draining. +- `src/Mesh.cpp` / `.h` – forwarding detection and resend cancellation in `onRecvPacket()`, `cancelPendingFinalHopResend()`, final-hop marking. +- `src/Packet.cpp` / `.h` – cached packet hash (`hash`, `hash_hex`), `isRetryMatch()` (TRACE-specific vs. hash-based), fields `sending_attempts` / `final_hop_ack_resend`. +- `src/helpers/SimpleMeshTables.h` – dedicated ACK dedup table (`_acks[]`, via `ack_crc`), so multi-ACK/resend duplicates do not evict the flood-dedup entries. +- `src/helpers/StaticPoolPacketManager.*` – `peek()` / `peekNextOutbound()` (non-consuming) and reset of `sending_attempts` / `final_hop_ack_resend` in `free()` (see below). +- `src/helpers/CommonCLI.*`, `docs/cli_commands.md`, `examples/*/MyMesh.*`, `NodePrefs.h` – config knobs and persistence. + +## Note: pool-reuse fix + +`sending_attempts` and `final_hop_ack_resend` were originally zeroed only in the `Packet` constructor, **not** when the packet was returned to the pool (`free()`). A recycled pool slot thus kept a stale `sending_attempts >= getMaxResendAttempts()`, causing `resendPacket()` to skip the resend for every subsequent DIRECT packet routed through that slot. Symptom on hardware: resends work right after boot (fresh pool) but degrade to zero once the pool has turned over (~`pool_size` sends) — forwards kept working because they do not check `sending_attempts`. Commit `110b9b40` resets both fields in the chokepoint `StaticPoolPacketManager::free()`. + +## Status & scope + +- Three feature commits on top of a merge of `upstream/dev`: `84bc3faf` (final-hop ACK), `2c48f09c` (non-invasive resend LBT + backoff), `110b9b40` (pool-reuse fix). +- Applies exclusively to **DIRECT/TRACE-routed** traffic. Flood traffic is unaffected. +- Only active when `max.resend > 0` (default `2`).