diff --git a/docs/cli_commands.md b/docs/cli_commands.md index c06f5e12b3..ed90243a31 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -485,6 +485,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–3). `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 0f3a0f9c5b..8394ff9c7d 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 5fb9bf9d37..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); @@ -274,6 +287,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); @@ -881,6 +895,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 @@ -938,6 +953,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, 3); #ifdef BLE_PIN_CODE // 123456 by default if (_prefs.ble_pin == 0) { @@ -1440,6 +1456,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, 3); + } } } } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index f4190f30ac..0537ea20a0 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -106,10 +106,12 @@ 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; 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..04ec00b1c9 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-3, default 2 }; \ No newline at end of file diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b66e19522a..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); @@ -877,6 +889,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 0b2e7491b7..11cd93d8dd 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -153,12 +153,16 @@ 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 } 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 36978e808f..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()) { @@ -633,6 +645,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 6bab9dc2d0..8f21f3f3ca 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -147,12 +147,16 @@ 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 } uint8_t getExtraAckTransmitCount() const override { return _prefs.multi_acks; } + uint8_t getMaxResendAttempts() const override { + return _prefs.max_resend_attempts; + } mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 59c9aa0900..8bcef2499f 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -715,6 +715,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 1d65b8772b..51bcd57b35 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -122,6 +122,7 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { int getInterferenceThreshold() const override; bool getCADEnabled() 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 c0610b7f8a..bfd4a80480 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -4,6 +4,10 @@ #include #endif +#ifdef MESHCORE_SIMULATOR + #include "sim_context.h" +#endif + #include namespace mesh { @@ -12,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 @@ -64,11 +72,6 @@ uint32_t Dispatcher::getCADFailMaxDuration() const { } void Dispatcher::loop() { - if (millisHasNowPassed(next_floor_calib_time)) { - _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); - _radio->setCADEnabled(getCADEnabled()); - next_floor_calib_time = futureMillis(NOISE_FLOOR_CALIB_INTERVAL); - } _radio->loop(); // check for radio 'stuck' in mode other than Rx @@ -112,10 +115,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); @@ -144,6 +150,30 @@ void Dispatcher::loop() { } checkRecv(); checkSend(); + + // 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()); + _radio->setCADEnabled(getCADEnabled()); + next_floor_calib_time = futureMillis(NOISE_FLOOR_CALIB_INTERVAL); + } + } } bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) { @@ -189,76 +219,90 @@ 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]; + // 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; + 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); @@ -286,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 } @@ -340,8 +393,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]); @@ -384,7 +437,49 @@ 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; } +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. + // 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(), + 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()); + // 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; + } + + return false; +} } \ No newline at end of file diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3ec..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; @@ -116,7 +117,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,9 +173,19 @@ 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; } + /** + * \returns maximum number of direct-route resend attempts (0 = disabled, default = 2, max = 3). + */ + virtual uint8_t getMaxResendAttempts() const { return 2; } + public: void begin(); void loop(); @@ -179,6 +193,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 c11f37cacf..cd3b881f51 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -60,6 +60,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? } @@ -75,7 +83,66 @@ 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()); + + // Only do this when the packet is NOT addressed to us as next hop to avoid dropping fresh packets. + if (!is_next_hop) { + // 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()) { + if (pkt->isRetryMatch(outbound)) { + 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()) { + if (pkt->isRetryMatch(queued_pkt)) { + 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 direct packets with no relay hashes (zero-hop, addressed straight to us), + // 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) { int i = 0; @@ -86,10 +153,16 @@ 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) { + // 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); @@ -100,15 +173,31 @@ 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. } +direct_path_done: if (pkt->isRouteFlood() && filterRecvFloodPacket(pkt)) return ACTION_RELEASE; DispatcherAction action = ACTION_RELEASE; @@ -341,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 49a299a6a4..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); @@ -59,12 +60,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 89e60b1f7e..b590c399a4 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -22,7 +22,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..24629a79a9 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -1,13 +1,20 @@ #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; + final_hop_ack_resend = false; } bool Packet::isValidPathLen(uint8_t path_len) { @@ -38,15 +45,50 @@ 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); + } + return (uint8_t *)this->hash; +} + +const char* Packet::getHashHex() const { + calculatePacketHash(); + Utils::toHex(hash_hex, hash, MAX_HASH_SIZE); + 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; } - sha.update(payload, payload_len); - sha.finalize(hash, MAX_HASH_SIZE); + + // 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 { diff --git a/src/Packet.h b/src/Packet.h index c19d9e9d8f..49fbec4d29 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -49,12 +49,29 @@ 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; + 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 - * \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; + + /** + * \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; /** * \returns one of ROUTE_ values diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 972a97e9e6..babba46594 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -804,7 +804,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); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c95e3e34b0..61cf175bea 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -93,7 +93,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.read((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - // next: 295 + file.read((uint8_t *)&_prefs->max_resend_attempts, sizeof(_prefs->max_resend_attempts)); // 295 + // next: 296 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -125,6 +126,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean + _prefs->max_resend_attempts = constrain(_prefs->max_resend_attempts, 0, 3); file.close(); } @@ -190,7 +192,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.write((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 file.write((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - // next: 295 + file.write((uint8_t *)&_prefs->max_resend_attempts, sizeof(_prefs->max_resend_attempts)); // 295 + // next: 296 file.close(); } @@ -518,6 +521,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 > 3) { + strcpy(reply, "ERROR: max.resend must be 0-3"); + } 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(); @@ -816,6 +828,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 f3abcf4772..a3b87fe907 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -64,7 +64,8 @@ struct NodePrefs { // persisted to file uint8_t radio_fem_rxgain; // LoRa FEM RX gain setting uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; - uint8_t cad_enabled; // hardware Channel Activity Detection before TX (boolean) + uint8_t max_resend_attempts; // 0 = disabled, 1-3, default 2 + uint8_t cad_enabled; // hardware Channel Activity Detection before TX (boolean) }; class CommonCLICallbacks { diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 956f36faa6..487a5748ac 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,46 @@ class SimpleMeshTables : public mesh::MeshTables { #endif bool wasSeen(const mesh::Packet* packet) override { - uint8_t hash[MAX_HASH_SIZE]; - packet->calculatePacketHash(hash); + // 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. + // + // Note: wasSeen() is a pure predicate and does NOT insert. Every miss must be + // followed by markSeen() at the call site to record the packet (see Mesh.cpp). + 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::wasSeen(): ACK already seen (crc=%08x)", (unsigned)ack); + return true; + } + } + 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++; } else { @@ -50,21 +89,38 @@ class SimpleMeshTables : public mesh::MeshTables { } void markSeen(const mesh::Packet* packet) override { - uint8_t hash[MAX_HASH_SIZE]; - packet->calculatePacketHash(hash); - memcpy(&_hashes[_next_idx * MAX_HASH_SIZE], hash, MAX_HASH_SIZE); - _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; + if (packet->getPayloadType() == PAYLOAD_TYPE_ACK) { + uint32_t ack; + memcpy(&ack, packet->payload, 4); + _acks[_next_ack_idx] = ack; + _next_ack_idx = (_next_ack_idx + 1) % MAX_PACKET_ACKS; // cyclic table + return; + } + + packet->calculatePacketHash(); + memcpy(&_hashes[_next_idx * MAX_HASH_SIZE], packet->hash, MAX_HASH_SIZE); + _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; // cyclic table } 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..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 @@ -80,6 +94,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); } @@ -95,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;