diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 0e785f4e..a5b17af9 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -467,6 +467,34 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change whether direct retries can fall back to the recently-heard repeater list +**Usage:** +- `get direct.retry.heard` +- `set direct.retry.heard ` + +**Parameters:** +- `state`: `on`|`off` + +**Default:** `off` + +**Note:** When enabled, a repeater can use recently-heard non-duplicate repeater prefixes as a fallback for direct retry eligibility when no suitable neighbor entry is available. + +--- + +#### View or change the SNR margin used for direct retry eligibility +**Usage:** +- `get direct.retry.margin` +- `set direct.retry.margin ` + +**Parameters:** +- `value`: Margin in dB above the SF-specific receive floor (minimum `0`, default `5`) + +**Default:** `5` + +**Note:** The retry gate uses the active SF floor of `SF5=-2.5`, `SF6=-5`, `SF7=-7.5`, `SF8=-10`, `SF9=-12.5`, `SF10=-15`, `SF11=-17.5`, `SF12=-20`, then adds this margin. + +--- + #### [Experimental] View or change the processing delay for received traffic **Usage:** - `get rxdelay` diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 24e88949..33c350e3 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -40,6 +40,9 @@ #ifndef TXT_ACK_DELAY #define TXT_ACK_DELAY 200 #endif +#ifndef HALO_DIRECT_RETRY_DELAY_MIN + #define HALO_DIRECT_RETRY_DELAY_MIN 200 +#endif #define FIRMWARE_VER_LEVEL 2 @@ -60,6 +63,20 @@ #define LAZY_CONTACTS_WRITE_DELAY 5000 +const NeighbourInfo* MyMesh::findNeighbourByHash(const uint8_t* hash, uint8_t hash_len) const { +#if MAX_NEIGHBOURS + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0 && neighbours[i].id.isHashMatch(hash, hash_len)) { + return &neighbours[i]; + } + } +#else + (void)hash; + (void)hash_len; +#endif + return NULL; +} + void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { #if MAX_NEIGHBOURS // check if neighbours enabled // find existing neighbour, else use least recently updated @@ -399,6 +416,8 @@ File MyMesh::openAppend(const char *fname) { static uint8_t max_loop_minimal[] = { 0, /* 1-byte */ 4, /* 2-byte */ 2, /* 3-byte */ 1 }; static uint8_t max_loop_moderate[] = { 0, /* 1-byte */ 2, /* 2-byte */ 1, /* 3-byte */ 1 }; static uint8_t max_loop_strict[] = { 0, /* 1-byte */ 1, /* 2-byte */ 1, /* 3-byte */ 1 }; +// SF5..SF12 receive floors, scaled by 4 so we can keep the retry gate in int8_t quarter-dB units. +static const int8_t direct_retry_floor_x4[] = { -10, -20, -30, -40, -50, -60, -70, -80 }; bool MyMesh::isLooped(const mesh::Packet* packet, const uint8_t max_counters[]) { uint8_t hash_size = packet->getPathHashSize(); @@ -531,6 +550,44 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } +int8_t MyMesh::getDirectRetryMinSNRX4() const { + // Use the live SF so `tempradio` changes immediately affect the retry threshold. + uint8_t sf = constrain(active_sf, (uint8_t)5, (uint8_t)12); + int16_t threshold = direct_retry_floor_x4[sf - 5] + ((int16_t)_prefs.direct_retry_snr_margin_db * 4); + return (int8_t)constrain(threshold, -128, 127); +} +bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { + if (_prefs.disable_fwd) { + return false; + } + + int8_t min_snr_x4 = getDirectRetryMinSNRX4(); + const NeighbourInfo* neighbour = findNeighbourByHash(next_hop_hash, next_hop_hash_len); + // Prefer the explicit neighbor table first; it is the strongest signal that this hop is still reachable. + if (neighbour != NULL && neighbour->snr >= min_snr_x4) { + return true; + } + + if (!_prefs.direct_retry_recent_enabled) { + return false; + } + + // If no neighbor entry exists, fall back to the recent-heard repeater cache keyed by the same path prefix. + const auto* recent = ((const SimpleMeshTables *)getTables())->findRecentRepeaterByHash(next_hop_hash, next_hop_hash_len); + return recent != NULL && recent->snr_x4 >= min_snr_x4; +} +uint32_t MyMesh::getDirectRetryEchoDelay(const mesh::Packet* packet) const { + // Approximate LoRa line rate in kilobits/sec from the live radio params the repeater is using now. + float kbps = (((float) active_sf) * active_bw * ((float) active_cr)) / ((float) (1UL << active_sf)); + if (kbps <= 0.0f) { + return HALO_DIRECT_RETRY_DELAY_MIN; + } + + // Wait roughly long enough for our transmission, the next hop's receive/forward window, and its echo back. + uint32_t bits = ((uint32_t) packet->getRawLength()) * 8; + uint32_t scaled_wait_millis = (uint32_t) ((((float) bits) * 4.0f) / kbps); + return max((uint32_t) HALO_DIRECT_RETRY_DELAY_MIN, scaled_wait_millis); +} bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { // just try to determine region for packet (apply later in allowPacketForward()) @@ -859,6 +916,8 @@ 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.direct_retry_recent_enabled = 0; + _prefs.direct_retry_snr_margin_db = 5; StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); _prefs.node_lat = ADVERT_LAT; _prefs.node_lon = ADVERT_LON; @@ -899,6 +958,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc pending_discover_tag = 0; pending_discover_until = 0; + active_bw = _prefs.bw; + active_sf = _prefs.sf; + active_cr = _prefs.cr; } void MyMesh::begin(FILESYSTEM *fs) { @@ -917,6 +979,9 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + active_bw = _prefs.bw; + active_sf = _prefs.sf; + active_cr = _prefs.cr; radio_set_tx_power(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1314,12 +1379,18 @@ void MyMesh::loop() { if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params set_radio_at = 0; // clear timer radio_set_params(pending_freq, pending_bw, pending_sf, pending_cr); + active_bw = pending_bw; + active_sf = pending_sf; + active_cr = pending_cr; MESH_DEBUG_PRINTLN("Temp radio params"); } if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + active_bw = _prefs.bw; + active_sf = _prefs.sf; + active_cr = _prefs.cr; MESH_DEBUG_PRINTLN("Radio params restored"); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 92958448..2d7d8dc1 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -109,8 +109,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long set_radio_at, revert_radio_at; float pending_freq; float pending_bw; + float active_bw; // live BW, including temporary radio overrides uint8_t pending_sf; + uint8_t active_sf; // live SF, including temporary radio overrides uint8_t pending_cr; + uint8_t active_cr; // live CR, including temporary radio overrides int matching_peer_indexes[MAX_CLIENTS]; #if defined(WITH_RS232_BRIDGE) RS232Bridge bridge; @@ -118,6 +121,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { ESPNowBridge bridge; #endif + const NeighbourInfo* findNeighbourByHash(const uint8_t* hash, uint8_t hash_len) const; + int8_t getDirectRetryMinSNRX4() const; void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); void sendNodeDiscoverReq(); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); @@ -146,6 +151,8 @@ protected: uint32_t getRetransmitDelay(const mesh::Packet* packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; + bool allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const override; + uint32_t getDirectRetryEchoDelay(const mesh::Packet* packet) const override; int getInterferenceThreshold() const override { return _prefs.interference_threshold; diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 9d7a1113..cccbd36c 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -106,6 +106,7 @@ void Dispatcher::loop() { _radio->onSendFinished(); logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); + onSendComplete(outbound); if (outbound->isRouteFlood()) { n_sent_flood++; } else { @@ -118,6 +119,7 @@ void Dispatcher::loop() { _radio->onSendFinished(); logTxFail(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); + onSendFail(outbound); releasePacket(outbound); // return to pool outbound = NULL; @@ -386,4 +388,4 @@ unsigned long Dispatcher::futureMillis(int millis_from_now) const { return _ms->getMillis() + millis_from_now; } -} \ No newline at end of file +} diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 2a99d068..163c6196 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -159,6 +159,8 @@ protected: virtual void logRx(Packet* packet, int len, float score) { } // hooks for custom logging virtual void logTx(Packet* packet, int len) { } virtual void logTxFail(Packet* packet, int len) { } + virtual void onSendComplete(Packet* packet) { } + virtual void onSendFail(Packet* packet) { } virtual const char* getLogDateTime() { return ""; } virtual float getAirtimeBudgetFactor() const; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee140..b9b39c95 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -4,11 +4,32 @@ namespace mesh { void Mesh::begin() { + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + _direct_retries[i].packet = NULL; + _direct_retries[i].trigger_packet = NULL; + _direct_retries[i].retry_at = 0; + _direct_retries[i].retry_delay = 0; + _direct_retries[i].priority = 0; + _direct_retries[i].progress_marker = 0; + _direct_retries[i].expect_path_growth = false; + _direct_retries[i].queued = false; + _direct_retries[i].active = false; + } Dispatcher::begin(); } void Mesh::loop() { Dispatcher::loop(); + + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active || !_direct_retries[i].queued || !millisHasNowPassed(_direct_retries[i].retry_at)) { + continue; + } + + if (!isDirectRetryQueued(_direct_retries[i].packet)) { + clearDirectRetrySlot(i); + } + } } bool Mesh::allowPacketForward(const mesh::Packet* packet) { @@ -22,10 +43,25 @@ uint32_t Mesh::getRetransmitDelay(const mesh::Packet* packet) { uint32_t Mesh::getDirectRetransmitDelay(const Packet* packet) { return 0; // by default, no delay } +bool Mesh::allowDirectRetry(const Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { + return false; +} +uint32_t Mesh::getDirectRetryEchoDelay(const Packet* packet) const { + // Keep the base fallback aligned with the repeater's minimum retry wait. + return 200; +} uint8_t Mesh::getExtraAckTransmitCount() const { return 0; } +void Mesh::onSendComplete(Packet* packet) { + armDirectRetryOnSendComplete(packet); +} + +void Mesh::onSendFail(Packet* packet) { + clearPendingDirectRetryOnSendFail(packet); +} + uint32_t Mesh::getCADFailRetryDelay() const { return _rng->nextInt(1, 4)*120; } @@ -39,6 +75,10 @@ int Mesh::searchChannelsByHash(const uint8_t* hash, GroupChannel channels[], int } DispatcherAction Mesh::onRecvPacket(Packet* pkt) { + if (pkt->isRouteDirect()) { + cancelDirectRetryOnEcho(pkt); + } + if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_TRACE) { if (pkt->path_len < MAX_PATH_SIZE) { uint8_t i = 0; @@ -58,6 +98,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4); uint32_t d = getDirectRetransmitDelay(pkt); + maybeScheduleDirectRetry(pkt, 5); return ACTION_RETRANSMIT_DELAYED(5, d); // schedule with priority 5 (for now), maybe make configurable? } } @@ -98,6 +139,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { removeSelfFromPath(pkt); uint32_t d = getDirectRetransmitDelay(pkt); + maybeScheduleDirectRetry(pkt, 0); return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority } } @@ -372,6 +414,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) { a1->path_len = Packet::copyPath(a1->path, packet->path, packet->path_len); a1->header &= ~PH_ROUTE_MASK; a1->header |= ROUTE_TYPE_DIRECT; + maybeScheduleDirectRetry(a1, 0); sendPacket(a1, 0, delay_millis); } extra--; @@ -382,11 +425,225 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) { a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len); a2->header &= ~PH_ROUTE_MASK; a2->header |= ROUTE_TYPE_DIRECT; + maybeScheduleDirectRetry(a2, 0); sendPacket(a2, 0, delay_millis); } } } +void Mesh::clearDirectRetrySlot(int idx) { + _direct_retries[idx].packet = NULL; + _direct_retries[idx].trigger_packet = NULL; + _direct_retries[idx].retry_at = 0; + _direct_retries[idx].retry_delay = 0; + _direct_retries[idx].priority = 0; + _direct_retries[idx].progress_marker = 0; + _direct_retries[idx].expect_path_growth = false; + _direct_retries[idx].queued = false; + _direct_retries[idx].active = false; +} + +bool Mesh::isDirectRetryQueued(const Packet* packet) const { + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + if (_mgr->getOutboundByIdx(i) == packet) { + return true; + } + } + return false; +} + +void Mesh::calculateDirectRetryKey(const Packet* packet, uint8_t* dest_key) const { + uint8_t type = packet->getPayloadType(); + Utils::sha256(dest_key, MAX_HASH_SIZE, &type, 1, packet->payload, packet->payload_len); +} + +bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { + uint8_t recv_key[MAX_HASH_SIZE]; + calculateDirectRetryKey(packet, recv_key); + + bool cleared = false; + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active || memcmp(recv_key, _direct_retries[i].retry_key, MAX_HASH_SIZE) != 0) { + continue; + } + + bool is_echo = _direct_retries[i].expect_path_growth + ? packet->path_len > _direct_retries[i].progress_marker + : packet->getPathHashCount() < _direct_retries[i].progress_marker; + if (!is_echo) { + continue; + } + + if (_direct_retries[i].queued) { + for (int j = 0; j < _mgr->getOutboundTotal(); j++) { + if (_mgr->getOutboundByIdx(j) == _direct_retries[i].packet) { + Packet* pending = _mgr->removeOutboundByIdx(j); + if (pending) { + releasePacket(pending); + } + break; + } + } + clearDirectRetrySlot(i); + } else { + clearDirectRetrySlot(i); + } + cleared = true; + } + + return cleared; +} + +void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active) { + continue; + } + + if (_direct_retries[i].queued) { + if (_direct_retries[i].packet == packet) { + // The retry packet itself just finished transmitting; Dispatcher will release it after this hook. + clearDirectRetrySlot(i); + } + continue; + } + + if (_direct_retries[i].trigger_packet != packet) { + continue; + } + + // Allocate the retry packet only after TX-complete so busy repeaters do not reserve pool slots early. + Packet* retry = obtainNewPacket(); + if (retry == NULL) { + clearDirectRetrySlot(i); + continue; + } + + *retry = *packet; + + // Start the echo wait only after the initial direct transmission actually completed. + sendPacket(retry, _direct_retries[i].priority, _direct_retries[i].retry_delay); + if (isDirectRetryQueued(retry)) { + _direct_retries[i].packet = retry; + _direct_retries[i].trigger_packet = NULL; + _direct_retries[i].queued = true; + _direct_retries[i].retry_at = futureMillis(_direct_retries[i].retry_delay); + } else { + clearDirectRetrySlot(i); + } + } +} + +void Mesh::clearPendingDirectRetryOnSendFail(const Packet* packet) { + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active) { + continue; + } + + if (_direct_retries[i].queued) { + if (_direct_retries[i].packet == packet) { + // The queued retry itself failed; Dispatcher will release it after this hook. + clearDirectRetrySlot(i); + } + continue; + } + + if (_direct_retries[i].trigger_packet == packet) { + clearDirectRetrySlot(i); + } + } +} + +bool Mesh::getDirectRetryTarget(const Packet* packet, const uint8_t*& next_hop_hash, uint8_t& next_hop_hash_len, + uint8_t& progress_marker, bool& expect_path_growth) const { + switch (packet->getPayloadType()) { + case PAYLOAD_TYPE_ACK: + case PAYLOAD_TYPE_PATH: + case PAYLOAD_TYPE_REQ: + case PAYLOAD_TYPE_RESPONSE: + case PAYLOAD_TYPE_TXT_MSG: + case PAYLOAD_TYPE_ANON_REQ: + if (packet->getPathHashCount() <= 1) { + return false; + } + next_hop_hash = packet->path; + next_hop_hash_len = packet->getPathHashSize(); + progress_marker = packet->getPathHashCount(); + expect_path_growth = false; + return true; + + case PAYLOAD_TYPE_MULTIPART: + if (packet->payload_len < 1 || (packet->payload[0] & 0x0F) != PAYLOAD_TYPE_ACK || packet->getPathHashCount() <= 1) { + return false; + } + next_hop_hash = packet->path; + next_hop_hash_len = packet->getPathHashSize(); + progress_marker = packet->getPathHashCount(); + expect_path_growth = false; + return true; + + case PAYLOAD_TYPE_TRACE: { + if (packet->payload_len < 9) { + return false; + } + + uint8_t hash_size = 1 << (packet->payload[8] & 0x03); + uint8_t route_bytes = packet->payload_len - 9; + uint8_t offset = packet->path_len * hash_size; + if (offset + hash_size > route_bytes) { + return false; + } + if (offset + (2 * hash_size) > route_bytes) { + return false; // no downstream repeater means there will be no forward echo to overhear. + } + + next_hop_hash = &packet->payload[9 + offset]; + next_hop_hash_len = hash_size; + progress_marker = packet->path_len; + expect_path_growth = true; + return true; + } + + default: + return false; + } +} + +void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority) { + const uint8_t* next_hop_hash; + uint8_t next_hop_hash_len; + uint8_t progress_marker; + bool expect_path_growth; + if (!getDirectRetryTarget(packet, next_hop_hash, next_hop_hash_len, progress_marker, expect_path_growth) + || !allowDirectRetry(packet, next_hop_hash, next_hop_hash_len)) { + return; + } + + int slot_idx = -1; + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active) { + slot_idx = i; + break; + } + } + if (slot_idx < 0) { + return; + } + + // Only store retry metadata here; allocate the retry packet after the initial TX really completes. + uint32_t retry_delay = getDirectRetryEchoDelay(packet); + calculateDirectRetryKey(packet, _direct_retries[slot_idx].retry_key); + _direct_retries[slot_idx].packet = NULL; + _direct_retries[slot_idx].trigger_packet = const_cast(packet); + _direct_retries[slot_idx].retry_at = 0; + _direct_retries[slot_idx].retry_delay = retry_delay; + _direct_retries[slot_idx].priority = priority; + _direct_retries[slot_idx].progress_marker = progress_marker; + _direct_retries[slot_idx].expect_path_growth = expect_path_growth; + _direct_retries[slot_idx].queued = false; + _direct_retries[slot_idx].active = true; +} + Packet* Mesh::createAdvert(const LocalIdentity& id, const uint8_t* app_data, size_t app_data_len) { if (app_data_len > MAX_ADVERT_DATA_SIZE) return NULL; @@ -634,7 +891,7 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_si packet->header |= ROUTE_TYPE_FLOOD; packet->setPathHashSizeAndCount(path_hash_size, 0); - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSent(packet); // mark this packet as already sent in case it is rebroadcast back to us uint8_t pri; if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { @@ -663,7 +920,7 @@ void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_m packet->transport_codes[1] = transport_codes[1]; packet->setPathHashSizeAndCount(path_hash_size, 0); - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSent(packet); // mark this packet as already sent in case it is rebroadcast back to us uint8_t pri; if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { @@ -696,7 +953,8 @@ void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uin pri = 0; } } - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSent(packet); // mark this packet as already sent in case it is rebroadcast back to us + maybeScheduleDirectRetry(packet, pri); sendPacket(packet, pri, delay_millis); } @@ -706,7 +964,7 @@ void Mesh::sendZeroHop(Packet* packet, uint32_t delay_millis) { packet->path_len = 0; // path_len of zero means Zero Hop - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSent(packet); // mark this packet as already sent in case it is rebroadcast back to us sendPacket(packet, 0, delay_millis); } @@ -719,9 +977,10 @@ void Mesh::sendZeroHop(Packet* packet, uint16_t* transport_codes, uint32_t delay packet->path_len = 0; // path_len of zero means Zero Hop - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSent(packet); // mark this packet as already sent in case it is rebroadcast back to us sendPacket(packet, 0, delay_millis); } -} \ No newline at end of file +} + diff --git a/src/Mesh.h b/src/Mesh.h index f9f87863..94bfec9f 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -4,6 +4,10 @@ namespace mesh { +#ifndef MAX_DIRECT_RETRY_SLOTS + #define MAX_DIRECT_RETRY_SLOTS 6 +#endif + class GroupChannel { public: uint8_t hash[PATH_HASH_SIZE]; @@ -16,6 +20,7 @@ public: class MeshTables { public: virtual bool hasSeen(const Packet* packet) = 0; + virtual void markSent(const Packet* packet) = 0; virtual void clear(const Packet* packet) = 0; // remove this packet hash from table }; @@ -24,17 +29,42 @@ public: * and provides virtual methods for sub-classes on handling incoming, and also preparing outbound Packets. */ class Mesh : public Dispatcher { + struct DirectRetryEntry { + Packet* packet; + Packet* trigger_packet; + unsigned long retry_at; + uint32_t retry_delay; + uint8_t retry_key[MAX_HASH_SIZE]; + uint8_t priority; + uint8_t progress_marker; + bool expect_path_growth; + bool queued; + bool active; + }; + RTCClock* _rtc; RNG* _rng; MeshTables* _tables; + DirectRetryEntry _direct_retries[MAX_DIRECT_RETRY_SLOTS]; void removeSelfFromPath(Packet* packet); void routeDirectRecvAcks(Packet* packet, uint32_t delay_millis); + void clearDirectRetrySlot(int idx); + bool isDirectRetryQueued(const Packet* packet) const; + void calculateDirectRetryKey(const Packet* packet, uint8_t* dest_key) const; + bool cancelDirectRetryOnEcho(const Packet* packet); + void armDirectRetryOnSendComplete(const Packet* packet); + void clearPendingDirectRetryOnSendFail(const Packet* packet); + bool getDirectRetryTarget(const Packet* packet, const uint8_t*& next_hop_hash, uint8_t& next_hop_hash_len, + uint8_t& progress_marker, bool& expect_path_growth) const; + void maybeScheduleDirectRetry(const Packet* packet, uint8_t priority); //void routeRecvAcks(Packet* packet, uint32_t delay_millis); DispatcherAction forwardMultipartDirect(Packet* pkt); protected: DispatcherAction onRecvPacket(Packet* pkt) override; + void onSendComplete(Packet* packet) override; + void onSendFail(Packet* packet) override; virtual uint32_t getCADFailRetryDelay() const override; @@ -65,6 +95,17 @@ protected: */ virtual uint32_t getDirectRetransmitDelay(const Packet* packet); + /** + * \brief Decide whether a DIRECT packet should get one delayed retry if the next hop echo is not overheard. + * Sub-classes can use neighbour tables or other link-quality data to opt in selectively. + */ + virtual bool allowDirectRetry(const Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const; + + /** + * \returns milliseconds to wait for the next-hop echo before queueing one retry of the DIRECT packet. + */ + virtual uint32_t getDirectRetryEchoDelay(const Packet* packet) const; + /** * \returns number of extra (Direct) ACK transmissions wanted. */ diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 2f7a0fff..c7095b26 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -8,6 +8,13 @@ #define BRIDGE_MAX_BAUD 115200 #endif +// These bytes used to be reserved/unused in persisted prefs, so keep a marker before trusting them. +#define DIRECT_RETRY_PREFS_MAGIC_0 0xD4 +#define DIRECT_RETRY_PREFS_MAGIC_1 0x52 +#define DIRECT_RETRY_RECENT_DEFAULT 0 +#define DIRECT_RETRY_SNR_MARGIN_DB_DEFAULT 5 +#define DIRECT_RETRY_SNR_MARGIN_DB_MAX 40 + // Believe it or not, this std C function is busted on some platforms! static uint32_t _atoi(const char* sp) { uint32_t n = 0; @@ -60,7 +67,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84 file.read((uint8_t *)&_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88 file.read((uint8_t *)&_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104 - file.read(pad, 4); // 108 : 4 bytes unused + file.read((uint8_t *)&_prefs->direct_retry_recent_enabled, sizeof(_prefs->direct_retry_recent_enabled)); // 108 + file.read((uint8_t *)&_prefs->direct_retry_snr_margin_db, sizeof(_prefs->direct_retry_snr_margin_db)); // 109 + file.read((uint8_t *)&_prefs->direct_retry_prefs_magic[0], sizeof(_prefs->direct_retry_prefs_magic)); // 110 file.read((uint8_t *)&_prefs->sf, sizeof(_prefs->sf)); // 112 file.read((uint8_t *)&_prefs->cr, sizeof(_prefs->cr)); // 113 file.read((uint8_t *)&_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114 @@ -102,6 +111,15 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->multi_acks = constrain(_prefs->multi_acks, 0, 1); _prefs->adc_multiplier = constrain(_prefs->adc_multiplier, 0.0f, 10.0f); _prefs->path_hash_mode = constrain(_prefs->path_hash_mode, 0, 2); // NOTE: mode 3 reserved for future + // Old firmware left offset 108..111 undefined, so require the marker before using the new retry prefs. + if (_prefs->direct_retry_prefs_magic[0] != DIRECT_RETRY_PREFS_MAGIC_0 + || _prefs->direct_retry_prefs_magic[1] != DIRECT_RETRY_PREFS_MAGIC_1) { + _prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT; + _prefs->direct_retry_snr_margin_db = DIRECT_RETRY_SNR_MARGIN_DB_DEFAULT; + } else { + _prefs->direct_retry_recent_enabled = constrain(_prefs->direct_retry_recent_enabled, 0, 1); + _prefs->direct_retry_snr_margin_db = constrain(_prefs->direct_retry_snr_margin_db, 0, DIRECT_RETRY_SNR_MARGIN_DB_MAX); + } // sanitise bad bridge pref values _prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1); @@ -150,7 +168,11 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84 file.write((uint8_t *)&_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88 file.write((uint8_t *)&_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104 - file.write(pad, 4); // 108 : 4 byte unused + file.write((uint8_t *)&_prefs->direct_retry_recent_enabled, sizeof(_prefs->direct_retry_recent_enabled)); // 108 + file.write((uint8_t *)&_prefs->direct_retry_snr_margin_db, sizeof(_prefs->direct_retry_snr_margin_db)); // 109 + // Persist a marker so later loads can distinguish real values from legacy garbage in this reserved slot. + uint8_t retry_magic[2] = { DIRECT_RETRY_PREFS_MAGIC_0, DIRECT_RETRY_PREFS_MAGIC_1 }; + file.write(retry_magic, sizeof(retry_magic)); // 110 file.write((uint8_t *)&_prefs->sf, sizeof(_prefs->sf)); // 112 file.write((uint8_t *)&_prefs->cr, sizeof(_prefs->cr)); // 113 file.write((uint8_t *)&_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114 @@ -338,6 +360,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch sprintf(reply, "> %d", (uint32_t)_prefs->flood_max); } else if (memcmp(config, "direct.txdelay", 14) == 0) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->direct_tx_delay_factor)); + } else if (memcmp(config, "direct.retry.heard", 18) == 0) { + sprintf(reply, "> %s", _prefs->direct_retry_recent_enabled ? "on" : "off"); + } else if (memcmp(config, "direct.retry.margin", 19) == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_snr_margin_db); } else if (memcmp(config, "owner.info", 10) == 0) { *reply++ = '>'; *reply++ = ' '; @@ -587,6 +613,27 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { strcpy(reply, "Error, cannot be negative"); } + } else if (memcmp(config, "direct.retry.heard ", 19) == 0) { + if (memcmp(&config[19], "on", 2) == 0) { + _prefs->direct_retry_recent_enabled = 1; + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(&config[19], "off", 3) == 0) { + _prefs->direct_retry_recent_enabled = 0; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be on or off"); + } + } else if (memcmp(config, "direct.retry.margin ", 20) == 0) { + int db = atoi(&config[20]); + if (db >= 0 && db <= DIRECT_RETRY_SNR_MARGIN_DB_MAX) { + _prefs->direct_retry_snr_margin_db = (uint8_t)db; + savePrefs(); + strcpy(reply, "OK"); + } else { + sprintf(reply, "Error, min 0 and max %d", DIRECT_RETRY_SNR_MARGIN_DB_MAX); + } } else if (memcmp(config, "owner.info ", 11) == 0) { config += 11; char *dp = _prefs->owner_info; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 3a4332d1..c1e0c5e9 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -32,7 +32,9 @@ struct NodePrefs { // persisted to file float tx_delay_factor; char guest_password[16]; float direct_tx_delay_factor; - uint32_t guard; + uint8_t direct_retry_recent_enabled; + uint8_t direct_retry_snr_margin_db; + uint8_t direct_retry_prefs_magic[2]; uint8_t sf; uint8_t cr; uint8_t allow_read_only; diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 2f8af52a..217fd5a0 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -8,13 +8,103 @@ #define MAX_PACKET_HASHES 128 #define MAX_PACKET_ACKS 64 +#define MAX_RECENT_REPEATERS 64 +#define MAX_ROUTE_HASH_BYTES 3 class SimpleMeshTables : public mesh::MeshTables { +public: + struct RecentRepeaterInfo { + // Just enough identity to match a next-hop path prefix plus the SNR that heard it. + uint8_t prefix[MAX_ROUTE_HASH_BYTES]; + uint8_t prefix_len; + int8_t snr_x4; + }; + +private: 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; + RecentRepeaterInfo _recent_repeaters[MAX_RECENT_REPEATERS]; + int _next_recent_repeater_idx; + + bool hasSeenAck(uint32_t ack) const { + for (int i = 0; i < MAX_PACKET_ACKS; i++) { + if (ack == _acks[i]) { + return true; + } + } + return false; + } + + void storeAck(uint32_t ack) { + _acks[_next_ack_idx] = ack; + _next_ack_idx = (_next_ack_idx + 1) % MAX_PACKET_ACKS; + } + + bool hasSeenHash(const uint8_t* hash) const { + 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) { + return true; + } + } + return false; + } + + void storeHash(const uint8_t* hash) { + memcpy(&_hashes[_next_idx*MAX_HASH_SIZE], hash, MAX_HASH_SIZE); + _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; + } + + bool extractRecentRepeater(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const { + // Learn repeater prefixes only from packet shapes that expose a trustworthy repeater ID. + if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->payload_len >= PUB_KEY_SIZE) { + memcpy(prefix, packet->payload, MAX_ROUTE_HASH_BYTES); + prefix_len = MAX_ROUTE_HASH_BYTES; + return true; + } + + if (packet->getPayloadType() == PAYLOAD_TYPE_CONTROL + && packet->isRouteDirect() + && packet->getPathHashCount() == 0 + && packet->payload_len >= 6 + MAX_ROUTE_HASH_BYTES + && (packet->payload[0] & 0xF0) == 0x90) { + memcpy(prefix, &packet->payload[6], MAX_ROUTE_HASH_BYTES); + prefix_len = MAX_ROUTE_HASH_BYTES; + return true; + } + + if (packet->isRouteFlood() && packet->getPathHashCount() > 0) { + prefix_len = packet->getPathHashSize(); + if (prefix_len > MAX_ROUTE_HASH_BYTES) { + prefix_len = MAX_ROUTE_HASH_BYTES; + } + + const uint8_t* last_hop = &packet->path[(packet->getPathHashCount() - 1) * packet->getPathHashSize()]; + memcpy(prefix, last_hop, prefix_len); + return true; + } + + return false; + } + + void recordRecentRepeater(const mesh::Packet* packet) { + uint8_t prefix[MAX_ROUTE_HASH_BYTES] = {0}; + uint8_t prefix_len = 0; + if (!extractRecentRepeater(packet, prefix, prefix_len) || prefix_len == 0) { + return; + } + + // Ring buffer is enough here; retry fallback only needs a recent prefix->SNR observation. + RecentRepeaterInfo& slot = _recent_repeaters[_next_recent_repeater_idx]; + memset(slot.prefix, 0, sizeof(slot.prefix)); + memcpy(slot.prefix, prefix, prefix_len); + slot.prefix_len = prefix_len; + slot.snr_x4 = packet->_snr; + _next_recent_repeater_idx = (_next_recent_repeater_idx + 1) % MAX_RECENT_REPEATERS; + } public: SimpleMeshTables() { @@ -23,6 +113,8 @@ public: memset(_acks, 0, sizeof(_acks)); _next_ack_idx = 0; _direct_dups = _flood_dups = 0; + memset(_recent_repeaters, 0, sizeof(_recent_repeaters)); + _next_recent_repeater_idx = 0; } #ifdef ESP32 @@ -31,12 +123,16 @@ public: f.read((uint8_t *) &_next_idx, sizeof(_next_idx)); f.read((uint8_t *) &_acks[0], sizeof(_acks)); f.read((uint8_t *) &_next_ack_idx, sizeof(_next_ack_idx)); + f.read((uint8_t *) &_recent_repeaters[0], sizeof(_recent_repeaters)); + f.read((uint8_t *) &_next_recent_repeater_idx, sizeof(_next_recent_repeater_idx)); } void saveTo(File f) { f.write(_hashes, sizeof(_hashes)); f.write((const uint8_t *) &_next_idx, sizeof(_next_idx)); f.write((const uint8_t *) &_acks[0], sizeof(_acks)); f.write((const uint8_t *) &_next_ack_idx, sizeof(_next_ack_idx)); + f.write((const uint8_t *) &_recent_repeaters[0], sizeof(_recent_repeaters)); + f.write((const uint8_t *) &_next_recent_repeater_idx, sizeof(_next_recent_repeater_idx)); } #endif @@ -44,28 +140,8 @@ public: 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++; - } - return true; - } - } - - _acks[_next_ack_idx] = ack; - _next_ack_idx = (_next_ack_idx + 1) % MAX_PACKET_ACKS; // cyclic table - return false; - } - uint8_t hash[MAX_HASH_SIZE]; - packet->calculatePacketHash(hash); - - 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 (hasSeenAck(ack)) { if (packet->isRouteDirect()) { _direct_dups++; // keep some stats } else { @@ -73,13 +149,46 @@ public: } return true; } + + storeAck(ack); + return false; } - memcpy(&_hashes[_next_idx*MAX_HASH_SIZE], hash, MAX_HASH_SIZE); - _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; // cyclic table + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + + if (hasSeenHash(hash)) { + if (packet->isRouteDirect()) { + _direct_dups++; // keep some stats + } else { + _flood_dups++; + } + return true; + } + + storeHash(hash); + recordRecentRepeater(packet); return false; } + void markSent(const mesh::Packet* packet) override { + // Outbound packets must be marked as already-sent without teaching the recent-heard cache about ourselves. + if (packet->getPayloadType() == PAYLOAD_TYPE_ACK) { + uint32_t ack; + memcpy(&ack, packet->payload, 4); + if (!hasSeenAck(ack)) { + storeAck(ack); + } + return; + } + + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + if (!hasSeenHash(hash)) { + storeHash(hash); + } + } + void clear(const mesh::Packet* packet) override { if (packet->getPayloadType() == PAYLOAD_TYPE_ACK) { uint32_t ack; @@ -107,5 +216,24 @@ public: uint32_t getNumDirectDups() const { return _direct_dups; } uint32_t getNumFloodDups() const { return _flood_dups; } + const RecentRepeaterInfo* findRecentRepeaterByHash(const uint8_t* hash, uint8_t hash_len) const { + if (hash == NULL || hash_len == 0) { + return NULL; + } + + // Search newest-to-oldest so the retry gate prefers the freshest SNR sample for a prefix. + for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + int idx = (_next_recent_repeater_idx - 1 - i + MAX_RECENT_REPEATERS) % MAX_RECENT_REPEATERS; + const RecentRepeaterInfo* info = &_recent_repeaters[idx]; + if (info->prefix_len < hash_len || info->prefix_len == 0) { + continue; + } + if (memcmp(info->prefix, hash, hash_len) == 0) { + return info; + } + } + return NULL; + } + void resetStats() { _direct_dups = _flood_dups = 0; } };