From fcfca64cc542e85b82644cd47522af286e904b94 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 13 Jul 2026 17:56:08 -0700 Subject: [PATCH] Refine trace and group data retries --- docs/cli_commands.md | 50 ++++-- docs/halo_keymind_settings.md | 16 +- examples/simple_repeater/MyMesh.cpp | 25 +-- examples/simple_room_server/MyMesh.cpp | 8 +- examples/simple_sensor/SensorMesh.cpp | 8 +- platformio.ini | 2 + src/Dispatcher.cpp | 5 + src/Dispatcher.h | 1 + src/Mesh.cpp | 141 +++++++++++++---- src/Mesh.h | 12 ++ src/helpers/CommonCLI.cpp | 38 ++++- src/helpers/CommonCLI.h | 2 + test/mocks/Ed25519.h | 10 ++ test/mocks/Stream.h | 6 + test/test_trace_retry/test_trace_retry.cpp | 169 +++++++++++++++++++++ 15 files changed, 427 insertions(+), 66 deletions(-) create mode 100644 test/mocks/Ed25519.h create mode 100644 test/test_trace_retry/test_trace_retry.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 4fe70d78..64d00b7b 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1261,7 +1261,7 @@ set direct.retry.heard off - `infra`: fewer, slower retries for stable fixed infrastructure. - `rooftop`: default long retry window for weak rooftop links. - `mobile`: long retry count with shorter spacing for moving or changing links; flood retry count is `15`. -- Changing `direct.retry.count`, `direct.retry.base`, `direct.retry.step`, `direct.retry.margin`, `flood.retry.count`, or `flood.retry.path` makes the preset report as `custom`. +- Changing `direct.retry.count`, `direct.retry.base`, `direct.retry.step`, `direct.retry.margin`, `flood.retry.count`, `flood.retry.path`, or `flood.retry.group.path` makes the preset report as `custom`. **Examples:** ``` @@ -1278,7 +1278,7 @@ set retry.preset mobile Flood retry resends flood-routed packets when the same packet is not heard from another qualifying repeater. -The count, path, and advert controls work on repeater, room-server, and sensor +The count, path, group-data path, and advert controls work on repeater, room-server, and sensor firmware. Flood forwarding must also be enabled for retries to run. Prefix, ignore, bridge, and bucket controls are repeater-only. @@ -1329,6 +1329,30 @@ set flood.retry.path off --- +#### View or change the group-data flood retry path gate +**Usage:** +- `get flood.retry.group.path` +- `set flood.retry.group.path ` + +**Parameters:** +- `count`: Maximum flood path hash count eligible for retry for group data packets (`PAYLOAD_TYPE_GRP_DATA`/type 6), from `0` to `63`. +- `off`: Disable only the group-data-specific gate. The general `flood.retry.path` gate still applies. + +**Default:** `1` for `infra`, `rooftop`, and `mobile` presets. + +**Note:** The stricter of `flood.retry.path` and `flood.retry.group.path` is used. A value of `1` allows retry sequences at path counts `0` and `1`; group data at path count `2` or higher is still forwarded normally but does not start a flood retry sequence. A value of `0` allows retries only at the originating sender. + +Setting `flood.retry.path` to `0` also sets `flood.retry.group.path` to `off` because the general zero-hop gate is already stricter. While the general gate remains `0`, attempts to set the group-data gate keep it `off`. Applying a named retry preset restores the group-data default of `1`. + +**Examples:** +``` +get flood.retry.group.path +set flood.retry.group.path 1 +set flood.retry.group.path off +``` + +--- + #### View or change flood retry advert handling **Usage:** - `get flood.retry.advert` @@ -1466,15 +1490,13 @@ set direct.retry.count 15 **Default:** `175` with the `rooftop` preset **Explanation:** -- The first retry waits `base` milliseconds after the failed echo window. -- The failed echo window includes a packet-length add-on. TRACE and - ANON_REQ/type 7 packets keep the existing 4x line-time add-on. TXT_MSG/type 2 +- The first retry waits for `base + packet-length add-on + random forwarding jitter` + after the preceding transmission completes. +- TRACE and + ANON_REQ/type 7 packets use a 3x line-time add-on. TXT_MSG/type 2 packets use 7x. Other direct retry packets use 6x. - Room-server and sensor firmware use this configured base with the same packet-type add-ons. -- For non-TRACE direct paths shorter than 6 remaining hops, the effective wait is scaled by `hops / 6`. -- Non-TRACE direct paths with 6 or more remaining hops use the configured value unchanged. -- TRACE retries shorter than 16 remaining hops use `hops / 16`; 16 or more remaining hops use the configured value unchanged. - Larger values reduce channel pressure and give slow repeaters more time. - Smaller values recover faster but create tighter retry bursts. @@ -1499,16 +1521,14 @@ set direct.retry.base 500 **Default:** `100` with the `rooftop` preset **Explanation:** -- Retry delay is `base + attempt_index * step`. -- This is added after the failed echo window. TRACE and ANON_REQ/type 7 packets - keep the existing 4x packet-length add-on. TXT_MSG/type 2 packets use 7x. +- Retry delay is `base + packet-length add-on + random forwarding jitter + attempt_index * step`. +- TRACE and ANON_REQ/type 7 packets + use a 3x packet-length add-on. TXT_MSG/type 2 packets use 7x. Other direct retry packets use 6x. - Room-server and sensor firmware use this configured step with the same packet-type add-ons. -- For non-TRACE direct paths shorter than 6 remaining hops, that computed delay is scaled by `hops / 6`. -- Non-TRACE direct paths with 6 or more remaining hops use the computed delay unchanged. -- TRACE retries shorter than 16 remaining hops use `hops / 16`; 16 or more remaining hops use the computed delay unchanged. -- With `base=175` and `step=100`, non-TRACE paths with 6 or more remaining hops wait about `175`, `275`, `375`, `475` ms, and so on. +- With `base=175` and `step=100`, the fixed portion is `175`, `275`, `375`, + `475` ms, and so on, before the packet-length add-on and random jitter. - `step=0` keeps every retry at the same delay. - Larger steps spread retries over time and are safer on busy channels. diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 55e8b4b3..6ecd9ed9 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -210,8 +210,8 @@ Direct retry applies to direct-routed packets. A queued resend is canceled when | `direct.retry.heard` | Uses the recent repeater table as the direct retry eligibility gate. | `get direct.retry.heard`, `set direct.retry.heard on/off` | `set direct.retry.heard on` | | `direct.retry.margin` | SNR margin in dB above the SF-specific receive floor. | `get direct.retry.margin`, `set direct.retry.margin <0-40>` | `set direct.retry.margin 5` | | `direct.retry.count` | Maximum direct retry attempts after initial TX. Direct-routed type 2 text packets always use 21 attempts regardless of this setting or the short-path cap. | `get direct.retry.count`, `set direct.retry.count <1-15>` | `set direct.retry.count 15` | -| `direct.retry.base` | Base wait in milliseconds before retry; packet-length add-on is 4x for TRACE and ANON_REQ/type 7, 7x for TXT_MSG/type 2, and 6x for other direct retry packets. Non-TRACE paths under 6 remaining hops scale by `hops / 6`, TRACE paths under 16 by `hops / 16`. | `get direct.retry.base`, `set direct.retry.base <10-5000>` | `set direct.retry.base 175` | -| `direct.retry.step` | Milliseconds added per retry attempt before the same short-path scaling. | `get direct.retry.step`, `set direct.retry.step <0-5000>` | `set direct.retry.step 100` | +| `direct.retry.base` | Base wait in milliseconds before retry; packet-length add-on is 3x for TRACE and ANON_REQ/type 7, 7x for TXT_MSG/type 2, and 6x for other direct retry packets. | `get direct.retry.base`, `set direct.retry.base <10-5000>` | `set direct.retry.base 175` | +| `direct.retry.step` | Milliseconds added per retry attempt after the base, packet-length add-on, and random forwarding jitter. | `get direct.retry.step`, `set direct.retry.step <0-5000>` | `set direct.retry.step 100` | | `direct.retry.cr` | Adaptive coding-rate thresholds for repeater direct retry packets. Repeaters use `CR4`, `CR5`, `CR7`, or `CR8`, then escalate by attempt: CR4, CR5, CR7, CR7, then CR8 from a CR4 start; CR5, CR7, CR7, then CR8 from a CR5 start. Non-repeaters start at the current radio CR and follow the same escalation pattern, clamped at CR8. | `get direct.retry.cr`, `set direct.retry.cr ,,,`, `set direct.retry.cr off` | `set direct.retry.cr 10.0,7.5,2.5,0` | The default adaptive coding-rate profile is `10.0,7.5,2.5,2.5`. @@ -257,6 +257,7 @@ the bucket rules below instead. | --- | --- | --- | --- | | `flood.retry.count` | Base flood retry attempts after initial TX. Path count 0 doubles it, path count 1 uses 1.5x rounded up, path count 2+ uses the base, and actual attempts cap at `15`; `0` disables flood retry. | `get flood.retry.count`, `set flood.retry.count <0-15>` | `set flood.retry.count 7` | | `flood.retry.path` | Maximum path hash count eligible for flood retry, or `off` to disable the gate. | `get flood.retry.path`, `set flood.retry.path <0-63/off>` | `set flood.retry.path 1` | +| `flood.retry.group.path` | Additional path gate for group data (`type=6`) flood retries. The stricter of this and `flood.retry.path` applies; `off` disables only this additional gate. Setting the general path gate to `0` forces this setting to `off`; a named preset restores the default of `1`. | `get flood.retry.group.path`, `set flood.retry.group.path <0-63/off>` | `set flood.retry.group.path 1` | | `flood.retry.advert` | Allows or blocks retry for node advert packets (`type=4`). Default is `off`. | `get flood.retry.advert`, `set flood.retry.advert on/off` | `set flood.retry.advert off` | | `flood.retry.prefixes` | Target prefixes. If set, only same-packet echoes from matching last-hop prefixes cancel a retry. | `get flood.retry.prefixes`, `set flood.retry.prefixes ` | `set flood.retry.prefixes BEEBB0,425E5C` | | `flood.retry.ignore` | Ignored prefixes. In non-bridge retry, ignored last-hop echoes do not cancel retry. | `get flood.retry.ignore`, `set flood.retry.ignore ` | `set flood.retry.ignore 71CE82,C7618C` | @@ -266,17 +267,18 @@ the bucket rules below instead. The shared retry preset sets these flood defaults: -| Preset | Retry count | Path gate | -| --- | ---: | ---: | -| `infra` | `1` | `1` | -| `rooftop` | `3` | `2` | -| `mobile` | `15` | `1` | +| Preset | Retry count | Path gate | Group-data path gate | +| --- | ---: | ---: | ---: | +| `infra` | `1` | `1` | `1` | +| `rooftop` | `3` | `2` | `1` | +| `mobile` | `15` | `1` | `1` | Example for path-gated retry: ```text set retry.preset rooftop set flood.retry.path 1 +set flood.retry.group.path 1 set flood.retry.advert off set flood.retry.ignore 71CE82,C7618C ``` diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 8c5117b5..a566fdad 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1827,18 +1827,22 @@ bool MyMesh::hasFloodRetryTargetPrefix(const mesh::Packet* packet) const { uint8_t MyMesh::getFloodRetryMaxPathLength(const mesh::Packet* packet) const { uint8_t gate = _prefs.flood_retry_max_path; - if (gate == FLOOD_RETRY_PATH_GATE_DISABLED) { - return FLOOD_RETRY_PATH_GATE_DISABLED; - } - if (gate > 63) { - gate = FLOOD_RETRY_ROOFTOP_MAX_PATH; + if (gate != FLOOD_RETRY_PATH_GATE_DISABLED) { + if (gate > 63) { + gate = FLOOD_RETRY_ROOFTOP_MAX_PATH; + } + + uint8_t raw_hops = packet != NULL ? packet->getPathHashCount() : 0; + uint8_t effective_hops = floodRetryEffectivePathLength(packet); + uint8_t ignored_hops = raw_hops > effective_hops ? raw_hops - effective_hops : 0; + uint16_t adjusted_gate = (uint16_t)gate + ignored_hops; + gate = adjusted_gate > 63 ? 63 : (uint8_t)adjusted_gate; } - uint8_t raw_hops = packet != NULL ? packet->getPathHashCount() : 0; - uint8_t effective_hops = floodRetryEffectivePathLength(packet); - uint8_t ignored_hops = raw_hops > effective_hops ? raw_hops - effective_hops : 0; - uint16_t adjusted_gate = (uint16_t)gate + ignored_hops; - return adjusted_gate > 63 ? 63 : (uint8_t)adjusted_gate; + uint8_t group_data_gate = _prefs.flood_retry_group_max_path == FLOOD_RETRY_PATH_GATE_DISABLED + ? FLOOD_RETRY_PATH_GATE_DISABLED + : constrain(_prefs.flood_retry_group_max_path, 0, 63); + return applyGroupDataFloodRetryPathGate(packet, gate, group_data_gate); } uint8_t MyMesh::getFloodRetryMaxAttempts(const mesh::Packet* packet) const { @@ -2388,6 +2392,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT; _prefs.flood_retry_attempts = FLOOD_RETRY_ROOFTOP_COUNT; _prefs.flood_retry_max_path = FLOOD_RETRY_ROOFTOP_MAX_PATH; + _prefs.flood_retry_group_max_path = FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT; _prefs.flood_retry_bridge_enabled = 0; _prefs.flood_retry_advert_enabled = FLOOD_RETRY_ADVERT_DEFAULT; _prefs.flood_channel_data_enabled = 1; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index ebc562fe..ef761c78 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -347,10 +347,13 @@ bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const { } uint8_t MyMesh::getFloodRetryMaxPathLength(const mesh::Packet* packet) const { - (void)packet; - return _prefs.flood_retry_max_path == FLOOD_RETRY_PATH_GATE_DISABLED + uint8_t general_gate = _prefs.flood_retry_max_path == FLOOD_RETRY_PATH_GATE_DISABLED ? FLOOD_RETRY_PATH_GATE_DISABLED : constrain(_prefs.flood_retry_max_path, 0, 63); + uint8_t group_data_gate = _prefs.flood_retry_group_max_path == FLOOD_RETRY_PATH_GATE_DISABLED + ? FLOOD_RETRY_PATH_GATE_DISABLED + : constrain(_prefs.flood_retry_group_max_path, 0, 63); + return applyGroupDataFloodRetryPathGate(packet, general_gate, group_data_gate); } uint8_t MyMesh::getFloodRetryMaxAttempts(const mesh::Packet* packet) const { @@ -753,6 +756,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max_unscoped = 64; _prefs.flood_max_advert = 8; _prefs.flood_channel_data_enabled = 1; + _prefs.flood_retry_group_max_path = FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT; _prefs.interference_threshold = 0; // disabled _prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards) _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 7e8243c9..60e719fc 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -405,10 +405,13 @@ bool SensorMesh::allowFloodRetry(const mesh::Packet* packet) const { } uint8_t SensorMesh::getFloodRetryMaxPathLength(const mesh::Packet* packet) const { - (void)packet; - return _prefs.flood_retry_max_path == FLOOD_RETRY_PATH_GATE_DISABLED + uint8_t general_gate = _prefs.flood_retry_max_path == FLOOD_RETRY_PATH_GATE_DISABLED ? FLOOD_RETRY_PATH_GATE_DISABLED : constrain(_prefs.flood_retry_max_path, 0, 63); + uint8_t group_data_gate = _prefs.flood_retry_group_max_path == FLOOD_RETRY_PATH_GATE_DISABLED + ? FLOOD_RETRY_PATH_GATE_DISABLED + : constrain(_prefs.flood_retry_group_max_path, 0, 63); + return applyGroupDataFloodRetryPathGate(packet, general_gate, group_data_gate); } uint8_t SensorMesh::getFloodRetryMaxAttempts(const mesh::Packet* packet) const { @@ -849,6 +852,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.disable_fwd = true; _prefs.flood_max = 64; _prefs.flood_channel_data_enabled = 1; + _prefs.flood_retry_group_max_path = FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT; _prefs.interference_threshold = 0; // disabled _prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards) _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') diff --git a/platformio.ini b/platformio.ini index d22d0a86..69ced38a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -200,6 +200,8 @@ build_src_filter = +<../src/Utils.cpp> +<../src/Packet.cpp> +<../src/Dispatcher.cpp> + +<../src/Identity.cpp> + +<../src/Mesh.cpp> +<../src/helpers/StaticPoolPacketManager.cpp> +<../src/helpers/ota/MerkleTree.cpp> +<../src/helpers/ota/MotaContainer.cpp> diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 1a82ffd2..757fdf57 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -393,6 +393,8 @@ void Dispatcher::processRecvPacket(Packet* pkt) { if (!queueOutboundPacket(pkt, priority, _delay)) { onSendFail(pkt); releasePacket(pkt); + } else if (pkt->isRouteDirect() && pkt->getPayloadType() == PAYLOAD_TYPE_TRACE) { + onTracePacketQueuedForSend(pkt); } } } @@ -554,6 +556,9 @@ bool Dispatcher::sendPacket(Packet* packet, uint8_t priority, uint32_t delay_mil releasePacket(packet); return false; } + if (packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE) { + onTracePacketQueuedForSend(packet); + } return true; } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index a3254653..3c4801a1 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -232,6 +232,7 @@ 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 onTracePacketQueuedForSend(Packet* packet) { } virtual void onSendComplete(Packet* packet) { } virtual void onSendFail(Packet* packet) { } virtual const char* getLogDateTime() { return ""; } diff --git a/src/Mesh.cpp b/src/Mesh.cpp index f72f1642..d89dff5a 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -161,6 +161,7 @@ void Mesh::begin() { _direct_retries[i].retry_delay = 0; _direct_retries[i].retry_attempts_sent = 0; memset(_direct_retries[i].retry_key, 0, sizeof(_direct_retries[i].retry_key)); + memset(_direct_retries[i].trace_replacement_key, 0, sizeof(_direct_retries[i].trace_replacement_key)); memset(_direct_retries[i].next_hop_hash, 0, sizeof(_direct_retries[i].next_hop_hash)); _direct_retries[i].next_hop_hash_len = 0; _direct_retries[i].payload_type = 0; @@ -359,7 +360,7 @@ uint8_t Mesh::getDirectRetryPacketAirtimeFactor(const Packet* packet) const { uint8_t payload_type = packet->getPayloadType(); if (payload_type == PAYLOAD_TYPE_TRACE || payload_type == PAYLOAD_TYPE_ANON_REQ) { - return 4; + return 3; } if (payload_type == PAYLOAD_TYPE_TXT_MSG) { return 7; @@ -399,6 +400,18 @@ uint8_t Mesh::getFloodRetryMaxPathLength(const Packet* packet) const { (void)packet; return FLOOD_RETRY_MAX_PATH_DEFAULT; } +uint8_t Mesh::applyGroupDataFloodRetryPathGate(const Packet* packet, + uint8_t general_gate, + uint8_t group_data_gate) { + if (packet == NULL || packet->getPayloadType() != PAYLOAD_TYPE_GRP_DATA + || group_data_gate == FLOOD_RETRY_PATH_GATE_DISABLED) { + return general_gate; + } + if (general_gate == FLOOD_RETRY_PATH_GATE_DISABLED || group_data_gate < general_gate) { + return group_data_gate; + } + return general_gate; +} uint8_t Mesh::getFloodRetryMaxAttempts(const Packet* packet) const { (void)packet; return FLOOD_RETRY_MAX_ATTEMPTS_DEFAULT; @@ -424,6 +437,10 @@ void Mesh::onSendComplete(Packet* packet) { armFloodRetryOnSendComplete(packet); } +void Mesh::onTracePacketQueuedForSend(Packet* packet) { + replaceQueuedTraceRetries(packet); +} + void Mesh::onSendFail(Packet* packet) { clearPendingDirectRetryOnSendFail(packet); clearPendingFloodRetryOnSendFail(packet); @@ -913,6 +930,7 @@ void Mesh::clearDirectRetrySlot(int idx) { _direct_retries[idx].retry_delay = 0; _direct_retries[idx].retry_attempts_sent = 0; memset(_direct_retries[idx].retry_key, 0, sizeof(_direct_retries[idx].retry_key)); + memset(_direct_retries[idx].trace_replacement_key, 0, sizeof(_direct_retries[idx].trace_replacement_key)); memset(_direct_retries[idx].next_hop_hash, 0, sizeof(_direct_retries[idx].next_hop_hash)); _direct_retries[idx].next_hop_hash_len = 0; _direct_retries[idx].payload_type = 0; @@ -926,6 +944,26 @@ void Mesh::clearDirectRetrySlot(int idx) { if (rebuild_timeout) rebuildNextDirectRetryTimeout(); } +void Mesh::retireDirectRetrySlot(int idx) { + if (idx < 0 || idx >= MAX_DIRECT_RETRY_SLOTS || !_direct_retries[idx].active) { + return; + } + + Packet* retry = _direct_retries[idx].queued ? _direct_retries[idx].packet : NULL; + if (retry != NULL && retry != getOutboundInFlight()) { + for (int j = 0; j < _mgr->getOutboundTotal(); j++) { + if (_mgr->getOutboundByIdx(j) != retry) continue; + Packet* pending = _mgr->removeOutboundByIdx(j); + if (pending != NULL) { + _direct_retries[idx].packet = NULL; + releasePacket(pending); + } + break; + } + } + clearDirectRetrySlot(idx); +} + void Mesh::rebuildNextDirectRetryTimeout() { bool found = false; uint32_t shortest_delay = 0; @@ -996,6 +1034,62 @@ void Mesh::calculateDirectRetryKey(const Packet* packet, uint8_t* dest_key) cons Utils::sha256(dest_key, MAX_HASH_SIZE, &type, 1, packet->payload, packet->payload_len); } +bool Mesh::calculateTraceReplacementKey(const Packet* packet, uint8_t* dest_key) const { + if (packet == NULL || dest_key == NULL || !packet->isRouteDirect() + || packet->getPayloadType() != PAYLOAD_TYPE_TRACE || packet->payload_len < 9) { + return false; + } + + uint8_t prefix[3] = { + PAYLOAD_TYPE_TRACE, + (uint8_t)(packet->path_len & 0xFF), + (uint8_t)(packet->path_len >> 8) + }; + // Ignore tag/auth (payload bytes 0..7), which change for a new request. + // Keep flags, route, and current progress so an older trace that has already + // advanced is not mistaken for the stale retry being replaced. + Utils::sha256(dest_key, MAX_HASH_SIZE, prefix, sizeof(prefix), + &packet->payload[8], packet->payload_len - 8); + return true; +} + +void Mesh::replaceQueuedTraceRetries(const Packet* packet) { + uint8_t replacement_key[MAX_HASH_SIZE]; + if (!calculateTraceReplacementKey(packet, replacement_key)) return; + + int replacement_slot = -1; + bool found_prior = false; + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active || _direct_retries[i].payload_type != PAYLOAD_TYPE_TRACE + || memcmp(replacement_key, _direct_retries[i].trace_replacement_key, MAX_HASH_SIZE) != 0) { + continue; + } + if (_direct_retries[i].trigger_packet == packet) { + replacement_slot = i; + } else { + found_prior = true; + } + } + + if (!found_prior) return; + + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (i == replacement_slot || !_direct_retries[i].active + || _direct_retries[i].payload_type != PAYLOAD_TYPE_TRACE + || memcmp(replacement_key, _direct_retries[i].trace_replacement_key, MAX_HASH_SIZE) != 0) { + continue; + } + retireDirectRetrySlot(i); + } + + // An exact duplicate retry key, or a full retry table, can prevent the new + // packet from reserving its slot before it is queued. The prior slots are now + // gone, so register the successfully queued packet as the retry owner. + if (replacement_slot < 0) { + maybeScheduleDirectRetry(packet, getTraceDirectPriority(packet)); + } +} + bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) { if (_active_direct_retry_count == 0) return false; @@ -1351,6 +1445,8 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool uint8_t retry_key[MAX_HASH_SIZE]; calculateDirectRetryKey(packet, retry_key); + uint8_t trace_replacement_key[MAX_HASH_SIZE] = { 0 }; + bool has_trace_replacement_key = calculateTraceReplacementKey(packet, trace_replacement_key); for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { if (_direct_retries[i].active && memcmp(retry_key, _direct_retries[i].retry_key, MAX_HASH_SIZE) == 0) { @@ -1366,6 +1462,17 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool } } if (slot_idx < 0) { + if (has_trace_replacement_key) { + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (_direct_retries[i].active && _direct_retries[i].payload_type == PAYLOAD_TYPE_TRACE + && memcmp(trace_replacement_key, _direct_retries[i].trace_replacement_key, + MAX_HASH_SIZE) == 0) { + // The post-queue hook will retire this older matching TRACE and use + // the slot for the successfully queued replacement. + return; + } + } + } onDirectRetryEvent("dropped_no_slot", packet, 0, 0, next_hop_hash, next_hop_hash_len); onDirectRetryEvent("failure", packet, 0, 0, next_hop_hash, next_hop_hash_len); return; @@ -1374,6 +1481,8 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool // Only store retry metadata here; allocate the retry packet after the initial TX really completes. uint32_t retry_delay = getDirectRetryAttemptDelay(packet, 0); memcpy(_direct_retries[slot_idx].retry_key, retry_key, sizeof(retry_key)); + memcpy(_direct_retries[slot_idx].trace_replacement_key, trace_replacement_key, + sizeof(trace_replacement_key)); _direct_retries[slot_idx].packet = NULL; _direct_retries[slot_idx].trigger_packet = const_cast(packet); _direct_retries[slot_idx].retry_started_at = 0; @@ -1448,20 +1557,7 @@ void Mesh::cancelAllDirectRetries() { for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { if (!_direct_retries[i].active) continue; - - Packet* retry = _direct_retries[i].queued ? _direct_retries[i].packet : NULL; - if (retry != NULL && retry != getOutboundInFlight()) { - for (int j = 0; j < _mgr->getOutboundTotal(); j++) { - if (_mgr->getOutboundByIdx(j) != retry) continue; - Packet* pending = _mgr->removeOutboundByIdx(j); - if (pending != NULL) { - _direct_retries[i].packet = NULL; - releasePacket(pending); - } - break; - } - } - clearDirectRetrySlot(i); + retireDirectRetrySlot(i); } } @@ -1501,20 +1597,7 @@ bool Mesh::cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) { continue; } - Packet* retry = _direct_retries[i].queued ? _direct_retries[i].packet : NULL; - if (retry != NULL && retry != getOutboundInFlight()) { - for (int j = 0; j < _mgr->getOutboundTotal(); j++) { - if (_mgr->getOutboundByIdx(j) == retry) { - Packet* pending = _mgr->removeOutboundByIdx(j); - if (pending != NULL) { - _direct_retries[i].packet = NULL; - releasePacket(pending); - } - break; - } - } - } - clearDirectRetrySlot(i); + retireDirectRetrySlot(i); cancelled = true; } diff --git a/src/Mesh.h b/src/Mesh.h index f17e07ed..cc1dfbe0 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -60,6 +60,7 @@ class Mesh : public Dispatcher { uint32_t retry_delay; uint8_t retry_attempts_sent; uint8_t retry_key[MAX_HASH_SIZE]; + uint8_t trace_replacement_key[MAX_HASH_SIZE]; uint8_t next_hop_hash[MAX_HASH_SIZE]; uint8_t next_hop_hash_len; uint8_t payload_type; @@ -104,7 +105,10 @@ class Mesh : public Dispatcher { void rebuildNextDirectRetryTimeout(); void rebuildNextFloodRetryTimeout(); void clearDirectRetrySlot(int idx); + void retireDirectRetrySlot(int idx); void calculateDirectRetryKey(const Packet* packet, uint8_t* dest_key) const; + bool calculateTraceReplacementKey(const Packet* packet, uint8_t* dest_key) const; + void replaceQueuedTraceRetries(const Packet* packet); bool cancelDirectRetryOnEcho(const Packet* packet); void armDirectRetryOnSendComplete(const Packet* packet); void clearPendingDirectRetryOnSendFail(const Packet* packet); @@ -122,6 +126,7 @@ class Mesh : public Dispatcher { protected: DispatcherAction onRecvPacket(Packet* pkt) override; + void onTracePacketQueuedForSend(Packet* packet) override; void onSendComplete(Packet* packet) override; void onSendFail(Packet* packet) override; bool allowPacketTransmit(const Packet* packet) const override; @@ -236,6 +241,13 @@ protected: */ virtual uint8_t getFloodRetryMaxPathLength(const Packet* packet) const; + /** + * \returns the stricter of the general flood retry gate and the group-data-specific gate. + */ + static uint8_t applyGroupDataFloodRetryPathGate(const Packet* packet, + uint8_t general_gate, + uint8_t group_data_gate); + /** * \returns maximum number of FLOOD retry transmissions after the initial TX. */ diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 6bf3fd51..a6c00704 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -169,6 +169,7 @@ static bool isBasicRetryConfig(const char* config) { || configKeyMatches(config, "direct.retry") || configKeyMatches(config, "flood.retry.count") || configKeyMatches(config, "flood.retry.path") + || configKeyMatches(config, "flood.retry.group.path") || configKeyMatches(config, "flood.retry.advert"); } @@ -430,6 +431,9 @@ static void applyFloodRetryPreset(NodePrefs* prefs, uint8_t preset) { prefs->flood_retry_attempts = FLOOD_RETRY_ROOFTOP_COUNT; prefs->flood_retry_max_path = FLOOD_RETRY_ROOFTOP_MAX_PATH; } + prefs->flood_retry_group_max_path = prefs->flood_retry_max_path == 0 + ? FLOOD_RETRY_PATH_GATE_DISABLED + : FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT; } static bool parseFloodRetryPathGate(const char* value, uint8_t& path_gate) { @@ -936,6 +940,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->ota_max_hops = 3; #endif _prefs->telemetry_access = TELEMETRY_ACCESS_ALL; + _prefs->flood_retry_group_max_path = FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT; // A remainder larger than the smallest legacy MQTT gap (864) means an old fork // file with the zero-filled gap; detect and recover it below. Anything smaller // (upstream/flex 5-byte tails, or the ~384-byte keymind retry tail) takes the @@ -1183,6 +1188,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)_prefs->battery_alert_region, sizeof(_prefs->battery_alert_region)); _prefs->battery_alert_region[sizeof(_prefs->battery_alert_region) - 1] = '\0'; } + if (file.available() >= (int)sizeof(_prefs->flood_retry_group_max_path)) { + file.read((uint8_t *)&_prefs->flood_retry_group_max_path, + sizeof(_prefs->flood_retry_group_max_path)); + } } // sanitise bad pref values @@ -1239,6 +1248,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (_prefs->flood_retry_max_path != FLOOD_RETRY_PATH_GATE_DISABLED) { _prefs->flood_retry_max_path = constrain(_prefs->flood_retry_max_path, 0, 63); } + if (_prefs->flood_retry_group_max_path != FLOOD_RETRY_PATH_GATE_DISABLED) { + _prefs->flood_retry_group_max_path = constrain(_prefs->flood_retry_group_max_path, 0, 63); + } + if (_prefs->flood_retry_max_path == 0) { + _prefs->flood_retry_group_max_path = FLOOD_RETRY_PATH_GATE_DISABLED; + } _prefs->flood_retry_bridge_enabled = constrain(_prefs->flood_retry_bridge_enabled, 0, 1); _prefs->flood_retry_advert_enabled = constrain(_prefs->flood_retry_advert_enabled, 0, 1); _prefs->battery_alert_enabled = constrain(_prefs->battery_alert_enabled, 0, 1); @@ -1402,7 +1417,9 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level)); // 820 file.write((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble)); // 821 file.write((uint8_t *)_prefs->battery_alert_region, sizeof(_prefs->battery_alert_region)); // 822 - // next: 853 + file.write((uint8_t *)&_prefs->flood_retry_group_max_path, + sizeof(_prefs->flood_retry_group_max_path)); // 853 + // next: 854 file.close(); } @@ -2612,6 +2629,21 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint8_t path_gate; if (parseFloodRetryPathGate(&config[17], path_gate)) { _prefs->flood_retry_max_path = path_gate; + if (path_gate == 0) { + _prefs->flood_retry_group_max_path = FLOOD_RETRY_PATH_GATE_DISABLED; + } + _prefs->retry_preset = RETRY_PRESET_CUSTOM; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0-63 or off"); + } + } else if (memcmp(config, "flood.retry.group.path ", 23) == 0) { + uint8_t path_gate; + if (parseFloodRetryPathGate(&config[23], path_gate)) { + _prefs->flood_retry_group_max_path = _prefs->flood_retry_max_path == 0 + ? FLOOD_RETRY_PATH_GATE_DISABLED + : path_gate; _prefs->retry_preset = RETRY_PRESET_CUSTOM; savePrefs(); strcpy(reply, "OK"); @@ -3029,6 +3061,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep char path_gate[8]; formatFloodRetryPathGate(path_gate, _prefs->flood_retry_max_path); sprintf(reply, "> %s", path_gate); + } else if (memcmp(config, "flood.retry.group.path", 22) == 0) { + char path_gate[8]; + formatFloodRetryPathGate(path_gate, _prefs->flood_retry_group_max_path); + sprintf(reply, "> %s", path_gate); } else if (memcmp(config, "flood.retry.prefixes", 20) == 0) { formatFloodRetryPrefixList(tmp, _prefs->flood_retry_prefixes, FLOOD_RETRY_PREFIX_SLOTS); sprintf(reply, "> %s", tmp[0] ? tmp : "none"); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index c76fbc9b..9866ffb5 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -50,6 +50,7 @@ #define FLOOD_RETRY_MOBILE_COUNT 15 #define FLOOD_RETRY_MOBILE_MAX_PATH 1 #define FLOOD_RETRY_ADVERT_DEFAULT 0 +#define FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT 1 #define BATTERY_ALERT_LOW_PERCENT_DEFAULT 20 #define BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT 10 @@ -215,6 +216,7 @@ struct NodePrefs { // persisted to file uint8_t rx_ps_level; // 0 = manual/explicit us timings; 1..10 = level-derived (auto-retunes on SF/BW change) uint8_t rx_ps_preamble; // 0 = auto (derive from SF); else 16 or 32 = explicit override for level calc char battery_alert_region[31]; // named scope for low-battery floods; empty = no alert scope + uint8_t flood_retry_group_max_path; // PAYLOAD_TYPE_GRP_DATA retry path gate; 0xFF = use only the general gate }; #ifdef WITH_MQTT_BRIDGE diff --git a/test/mocks/Ed25519.h b/test/mocks/Ed25519.h new file mode 100644 index 00000000..b667d66a --- /dev/null +++ b/test/mocks/Ed25519.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +class Ed25519 { +public: + static bool verify(const uint8_t*, const uint8_t*, const uint8_t*, int) { + return true; + } +}; diff --git a/test/mocks/Stream.h b/test/mocks/Stream.h index 195a3029..2c4a6c3a 100644 --- a/test/mocks/Stream.h +++ b/test/mocks/Stream.h @@ -1,5 +1,8 @@ #pragma once +#include +#include + // Mock Stream class for native testing // Provides minimal interface needed by Utils.h @@ -7,4 +10,7 @@ class Stream { public: virtual void print(char c) {} virtual void print(const char* str) {} + virtual void println() {} + virtual size_t readBytes(uint8_t*, size_t) { return 0; } + virtual size_t write(const uint8_t*, size_t) { return 0; } }; diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp new file mode 100644 index 00000000..3850d92b --- /dev/null +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -0,0 +1,169 @@ +#include + +#include +#include +#include + +class TraceTestClock : public mesh::MillisecondClock { +public: + unsigned long now = 0; + unsigned long getMillis() override { return now; } +}; + +class TraceTestRTC : public mesh::RTCClock { +public: + uint32_t now = 0; + uint32_t getCurrentTime() override { return now; } + void setCurrentTime(uint32_t time) override { now = time; } +}; + +class TraceTestRNG : public mesh::RNG { +public: + void random(uint8_t* dest, size_t sz) override { memset(dest, 0, sz); } +}; + +class TraceTestRadio : public mesh::Radio { +public: + bool sending = false; + bool complete = false; + + int recvRaw(uint8_t*, int) override { return 0; } + uint32_t getEstAirtimeFor(int) override { return 10; } + float packetScore(float, int) override { return 0; } + bool startSendRaw(const uint8_t*, int) override { + sending = true; + return true; + } + bool isSendComplete() override { return complete; } + void onSendFinished() override { + sending = false; + complete = false; + } + bool isInRecvMode() const override { return !sending; } +}; + +class TraceTestTables : public mesh::MeshTables { +public: + bool wasSeen(const mesh::Packet*) override { return false; } + void markSeen(const mesh::Packet*) override { } + void markSent(const mesh::Packet*) override { } + void clear(const mesh::Packet*) override { } +}; + +class TraceTestMesh : public mesh::Mesh { +public: + TraceTestMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, + mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables) + : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) { } + + uint8_t airtimeFactor(const mesh::Packet* packet) const { + return getDirectRetryPacketAirtimeFactor(packet); + } + + uint8_t floodPathGate(const mesh::Packet* packet, uint8_t general_gate, + uint8_t group_data_gate) const { + return applyGroupDataFloodRetryPathGate(packet, general_gate, group_data_gate); + } +}; + +static mesh::Packet* makeTrace(TraceTestMesh& node, uint32_t tag, uint32_t auth, + const uint8_t* route, uint8_t route_len) { + mesh::Packet* packet = node.createTrace(tag, auth, 0); + EXPECT_NE(packet, nullptr); + if (packet == nullptr) return nullptr; + EXPECT_TRUE(node.sendDirect(packet, route, route_len)); + return packet; +} + +TEST(TraceRetry, TraceAndAnonymousRequestsUseThreeAirtimes) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + + mesh::Packet trace; + trace.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_TRACE << PH_TYPE_SHIFT); + mesh::Packet anon; + anon.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_ANON_REQ << PH_TYPE_SHIFT); + mesh::Packet text; + text.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT); + mesh::Packet other; + other.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_REQ << PH_TYPE_SHIFT); + + EXPECT_EQ(3, node.airtimeFactor(&trace)); + EXPECT_EQ(3, node.airtimeFactor(&anon)); + EXPECT_EQ(7, node.airtimeFactor(&text)); + EXPECT_EQ(6, node.airtimeFactor(&other)); +} + +TEST(FloodRetry, GroupDataUsesTheStricterPathGate) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + + mesh::Packet group_data; + group_data.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT); + mesh::Packet group_text; + group_text.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); + + EXPECT_EQ(1, node.floodPathGate(&group_data, 2, 1)); + EXPECT_EQ(1, node.floodPathGate(&group_data, FLOOD_RETRY_PATH_GATE_DISABLED, 1)); + EXPECT_EQ(1, node.floodPathGate(&group_data, 1, 3)); + EXPECT_EQ(2, node.floodPathGate(&group_data, 2, FLOOD_RETRY_PATH_GATE_DISABLED)); + EXPECT_EQ(0, node.floodPathGate(&group_data, 0, FLOOD_RETRY_PATH_GATE_DISABLED)); + EXPECT_EQ(2, node.floodPathGate(&group_text, 2, 1)); +} + +TEST(TraceRetry, NewTraceReplacesQueuedRetryButAdvancedOldTraceStillQueues) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.begin(); + + const uint8_t route[] = {0x11, 0x22, 0x33}; + mesh::Packet* old_trace = makeTrace(node, 0x11111111, 0xAAAAAAAA, route, sizeof(route)); + ASSERT_NE(old_trace, nullptr); + ASSERT_EQ(1, manager.getOutboundTotal()); + + clock.now = 1; + node.loop(); + ASSERT_TRUE(radio.sending); + radio.complete = true; + clock.now = 2; + node.loop(); + ASSERT_EQ(1, manager.getOutboundTotal()); // old TRACE retry + + mesh::Packet* new_trace = makeTrace(node, 0x22222222, 0xBBBBBBBB, route, sizeof(route)); + ASSERT_NE(new_trace, nullptr); + ASSERT_EQ(1, manager.getOutboundTotal()); + EXPECT_EQ(new_trace, manager.getOutboundByIdx(0)); + + // A packet from the older run that has already advanced is a different + // retry stage. It must remain queueable instead of being treated as the + // stale same-hop retry that the newer run replaced. + mesh::Packet* returning_old = node.createTrace(0x11111111, 0xAAAAAAAA, 0); + ASSERT_NE(returning_old, nullptr); + memcpy(&returning_old->payload[returning_old->payload_len], route, sizeof(route)); + returning_old->payload_len += sizeof(route); + returning_old->header |= ROUTE_TYPE_DIRECT; + returning_old->path_len = 1; + returning_old->path[0] = 4; + ASSERT_TRUE(node.sendPacket(returning_old, 1)); + EXPECT_EQ(2, manager.getOutboundTotal()); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}