Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README-repeated-sending.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Repeated Sending (branch `feature/repeated-sending-2`)

Reliable delivery of direct-routed packets via **cancellable retransmissions**. The feature augments DIRECT/TRACE routing with an automatic, self-limiting retransmission that switches itself off one hop downstream as soon as successful forwarding is detected.

## Problem

A direct-routed packet (DIRECT / TRACE) that travels to its destination via one or more relays is considered "sent" by the originator as soon as it is on air. If it is lost on the first hop (collision, interference, fading), there is no retry at the MeshCore layer — the packet is gone, and the application layer must re-schedule the entire message. Flood traffic does not have this problem because it is widely broadcast anyway; DIRECT packets, however, are the cost-conscious path for targeted messages.

## Solution

After every DIRECT packet is sent, the Dispatcher schedules a retransmission. The key trick: **the originator listens for a downstream relay forwarding the packet**. As soon as it receives that forward (the "forwarding echo"), it knows its packet survived at least the next hop and cancels the pending retransmission. The result is an error-correction mechanism that produces **no** extra packet in the success case, and in the failure case retransmits precisely where the packet actually got stuck.

Flow in detail:

1. **Schedule the resend** – `Dispatcher::resendPacket()` runs after TX only for DIRECT packets with at least one relay hash in the path (`getPathHashCount() > 0`). It does not wait for a timeout; instead it re-queues the packet after a computed **silence period** (packet airtime × budget factor + jitter + linear backoff per attempt). The delay gives the downstream repeater time to forward — and gives the originator time to hear that forward *before* it retransmits itself.
2. **Detect the forward and cancel** – In `Mesh::onRecvPacket()`, every received DIRECT packet is checked: is it a downstream forward of one of our own pending resends (`Packet::isRetryMatch()`)? If so, the resend is removed from the outbound queue (`removeOutboundByIdx`). The RX side drains the entire FIFO in a single `loop()` pass to keep the window between forwarding echo and scheduled resend as small as possible.
3. **Non-invasive LBT** – Resends use *no* hardware CAD, but `isResendChannelActive()`: a pure IRQ/register read (preamble detection + RSSI margin above the noise floor). RX stays open so the forwarding echo is not missed. First sends still use normal CAD (collision avoidance is worth the momentary deafness).

### Final-hop handling

On the final relay hop there is no downstream forward to overhear — the destination does not forward. Two special paths prevent the last hop from being left unprotected, or the destination from being flooded:

- **TXT_MSG with ACK** – the destination acknowledges receipt with an ACK. This hop is allowed exactly one ACK-cancellable resend (flag `final_hop_ack_resend`). `Mesh::cancelPendingFinalHopResend()` cancels the oldest final-hop resend still sitting in the queue as soon as the ACK returns (FIFO, since ACKs are produced in delivery order). A resend already on air is deliberately not aborted (the destination dedups the duplicate via `wasSeen`).
- **TRACE** – TRACE packets append an SNR byte per hop, which changes their hash. `isRetryMatch()` therefore compares payload + SNR-path prefix instead of the hash for TRACE. At the final forwarding hop the retry budget is exhausted directly (`sending_attempts = getMaxResendAttempts()`), since a non-cancellable resend here would only burden the destination.

### Considerate noise-floor calibration

`Dispatcher::loop()` moves noise-floor calibration to the end of `loop()` and only runs it when the radio is demonstrably idle (`!isReceiving()`) **and** the outbound queue is empty (`getOutboundCount() == 0`). Calibration briefly takes the radio out of RX — that would disrupt pending, time-critical resends or abort a packet not yet read out.

## Configuration

| Location | Value | Meaning |
|---|---|---|
| CLI `set max.resend <0–3>` / `get max.resend` | Default `2` | Maximum resend attempts for DIRECT packets. `0` disables the feature entirely. |
| `NodePrefs` (persisted) | Byte offset `295` | Loaded/saved to file via `CommonCLI`; sanitised to 0–3. |
| Companion-radio `CMD_SET_*` frame | Byte 6 in the path block | App-protocol path to set `max_resend_attempts` from the companion. |
| `RESEND_INTERFERENCE_MARGIN` (compile-time) | Default `12` dB | Margin above the noise floor at which a resend is blocked (non-invasive LBT). |
| `RESEND_BACKOFF_STRETCH_MS` (compile-time) | Default `1000` ms | Linear backoff per resend attempt, to ride out longer interference bursts. |

## Implementation (core files)

- `src/Dispatcher.cpp` / `.h` – `resendPacket()`, `isResendChannelActive()`, `getMaxResendAttempts()`, relocated noise-floor calibration, RX draining.
- `src/Mesh.cpp` / `.h` – forwarding detection and resend cancellation in `onRecvPacket()`, `cancelPendingFinalHopResend()`, final-hop marking.
- `src/Packet.cpp` / `.h` – cached packet hash (`hash`, `hash_hex`), `isRetryMatch()` (TRACE-specific vs. hash-based), fields `sending_attempts` / `final_hop_ack_resend`.
- `src/helpers/SimpleMeshTables.h` – dedicated ACK dedup table (`_acks[]`, via `ack_crc`), so multi-ACK/resend duplicates do not evict the flood-dedup entries.
- `src/helpers/StaticPoolPacketManager.*` – `peek()` / `peekNextOutbound()` (non-consuming) and reset of `sending_attempts` / `final_hop_ack_resend` in `free()` (see below).
- `src/helpers/CommonCLI.*`, `docs/cli_commands.md`, `examples/*/MyMesh.*`, `NodePrefs.h` – config knobs and persistence.

## Note: pool-reuse fix

`sending_attempts` and `final_hop_ack_resend` were originally zeroed only in the `Packet` constructor, **not** when the packet was returned to the pool (`free()`). A recycled pool slot thus kept a stale `sending_attempts >= getMaxResendAttempts()`, causing `resendPacket()` to skip the resend for every subsequent DIRECT packet routed through that slot. Symptom on hardware: resends work right after boot (fresh pool) but degrade to zero once the pool has turned over (~`pool_size` sends) — forwards kept working because they do not check `sending_attempts`. Commit `110b9b40` resets both fields in the chokepoint `StaticPoolPacketManager::free()`.

## Status & scope

- Three feature commits on top of a merge of `upstream/dev`: `84bc3faf` (final-hop ACK), `2c48f09c` (non-invasive resend LBT + backoff), `110b9b40` (pool-reuse fix).
- Applies exclusively to **DIRECT/TRACE-routed** traffic. Flood traffic is unaffected.
- Only active when `max.resend > 0` (default `2`).
12 changes: 12 additions & 0 deletions docs/cli_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value>`

**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`
Expand Down
2 changes: 2 additions & 0 deletions examples/companion_radio/DataStore.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand Down
19 changes: 19 additions & 0 deletions examples/companion_radio/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions examples/companion_radio/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions examples/companion_radio/NodePrefs.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
13 changes: 13 additions & 0 deletions examples/simple_repeater/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions examples/simple_repeater/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
13 changes: 13 additions & 0 deletions examples/simple_room_server/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions examples/simple_room_server/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions examples/simple_sensor/SensorMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions examples/simple_sensor/SensorMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading