diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index e045fc1..b443989 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -234,7 +234,39 @@ Called every ~5 seconds from the main event loop: 2. **RX mode watchdog**: Flags error if radio stuck outside RX for >8 seconds 3. **AGC reset**: Periodic warm sleep + recalibration (configurable interval, default off) -### 4.7 Encryption +### 4.7 Adaptive Contention Window + +Replaces Arduino MeshCore's static `txdelay`/`rxdelay` with two complementary mechanisms: + +**EMA Delay Factor (proactive)** + +`ContentionTracker` measures observed duplicates per retransmitted packet using a 16-entry ring buffer. Each entry tracks a packet (identified by FNV-1a hash) and records how many dupes arrive within a 10-second observation window. When the window closes, the entry is finalized and an EMA is updated with alpha = 1/8. The resulting estimate feeds the delay factor formula: + +``` +factor = 0.05 + 0.116 * sqrt(est) +``` + +Capped at 2.0. During warmup (fewer than 4 finalized entries), factor defaults to 0.5. Sparse nodes converge toward near-zero delay; dense nodes get proportionally higher delay. The factor scales the flood TX delay computed by `calcRxDelay()`. + +**Per-Dupe Reactive Backoff** + +When a duplicate of a pending outbound packet is heard, TX is rescheduled to `now + backoff_multiplier * airtime`. Each dupe triggers a full delay (not diminishing). Cumulative reactive extension is hard-capped at 2000 ms per packet; after the cap, CAD handles remaining channel activity. `backoff_multiplier` is configurable via `set backoff.multiplier X` (range 0.0–2.0). + +**Direct Packets** + +Direct (source-routed) packets bypass adaptive scaling entirely. They use minimal fixed jitter: `20 + rand(0, airtime / 10)` ms. + +**CLI** + +- `get txdelay` — shows current adaptive state (EMA estimate, delay factor, backoff multiplier). +- `set backoff.multiplier X` — controls per-dupe reactive delay (0.0–2.0). +- `txdelay`, `rxdelay`, `direct.txdelay` — accepted for prefs compatibility but ignored at runtime. + +**ContentionTracker Resource Usage** + +~172 bytes RAM. 16-entry ring buffer, FNV-1a packet hash, 10-second observation window, EMA with alpha = 1/8. + +### 4.8 Encryption - **Peer-to-peer**: ECDH shared secret (Curve25519) → AES-128-ECB encrypt → 2-byte HMAC-SHA256 MAC - **Group channels**: SHA-256 of channel name → AES key diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 16d01eb..79b798c 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -122,8 +122,11 @@ void CommonCLI::loadPrefs(const char* path) { _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0.0f, 20.0f); _prefs->tx_delay_factor = constrain(_prefs->tx_delay_factor, 0.0f, 2.0f); _prefs->direct_tx_delay_factor = constrain(_prefs->direct_tx_delay_factor, 0.0f, 2.0f); - /* Migrate uninitialized pad bytes (0.0f or NaN) to default 0.5 */ - if (_prefs->backoff_multiplier == 0.0f || _prefs->backoff_multiplier != _prefs->backoff_multiplier) { + /* Migrate uninitialized pad bytes: NaN or out-of-range → default 0.5. + * 0.0 is valid (disables reactive backoff). Old firmware upgrading + * with zeroed pad bytes will get 0.0 = disabled; user can set explicitly. */ + if (_prefs->backoff_multiplier != _prefs->backoff_multiplier || + _prefs->backoff_multiplier < 0.0f || _prefs->backoff_multiplier > 10.0f) { _prefs->backoff_multiplier = 0.5f; } _prefs->backoff_multiplier = constrain(_prefs->backoff_multiplier, 0.0f, 2.0f); diff --git a/zephcore/include/mesh/ContentionTracker.h b/zephcore/include/mesh/ContentionTracker.h index 7dcb59f..16f37df 100644 --- a/zephcore/include/mesh/ContentionTracker.h +++ b/zephcore/include/mesh/ContentionTracker.h @@ -34,8 +34,9 @@ public: * Caller should attempt reactive backoff when true. */ bool recordDupeIfTracked(uint32_t hash32, uint32_t now_ms); - /* Check if reactive extension is within cap for this entry. - * Returns the max additional ms allowed, 0 if cap reached. */ + /* Per-dupe reactive delay: returns backoff_multiplier × airtime, + * clamped by hard cap minus cumulative extension so far. + * Returns 0 when hard cap reached or backoff disabled. */ uint16_t getReactiveHeadroom(uint32_t hash32, uint32_t airtime_ms) const; /* Record that we added reactive extension to this entry. */ @@ -66,6 +67,7 @@ private: static constexpr float FLOOD_SCALE = 0.116f; /* (0.5 - 0.05) / sqrt(15) */ static constexpr float MAX_FLOOD_FACTOR = 2.0f; static constexpr float DEFAULT_BACKOFF_MULT = 0.5f; + static constexpr uint32_t REACTIVE_HARD_CAP_MS = 2000; static constexpr uint32_t STALE_MS = 300000; /* 5 minutes */ struct Entry { diff --git a/zephcore/src/ContentionTracker.cpp b/zephcore/src/ContentionTracker.cpp index b50ba71..02237f9 100644 --- a/zephcore/src/ContentionTracker.cpp +++ b/zephcore/src/ContentionTracker.cpp @@ -108,11 +108,15 @@ uint16_t ContentionTracker::getReactiveHeadroom(uint32_t hash32, uint32_t airtim int idx = findEntry(hash32); if (idx < 0) return 0; - uint32_t cap = (uint32_t)(_backoff_multiplier * (float)airtime_ms); - if (_ring[idx].reactive_added_ms >= cap) return 0; + uint32_t per_dupe = (uint32_t)(_backoff_multiplier * (float)airtime_ms); + if (per_dupe == 0) return 0; - uint32_t remaining = cap - _ring[idx].reactive_added_ms; - return remaining > 0xFFFF ? 0xFFFF : (uint16_t)remaining; + /* Hard cap on total reactive extension per packet */ + if (_ring[idx].reactive_added_ms >= REACTIVE_HARD_CAP_MS) return 0; + + uint32_t remaining = REACTIVE_HARD_CAP_MS - _ring[idx].reactive_added_ms; + if (per_dupe > remaining) per_dupe = remaining; + return per_dupe > 0xFFFF ? 0xFFFF : (uint16_t)per_dupe; } void ContentionTracker::addReactiveExtension(uint32_t hash32, uint16_t added_ms) diff --git a/zephcore/src/Mesh.cpp b/zephcore/src/Mesh.cpp index ea23ee1..20e724c 100644 --- a/zephcore/src/Mesh.cpp +++ b/zephcore/src/Mesh.cpp @@ -47,15 +47,13 @@ void Mesh::extendPendingRetransmit(uint32_t hash32) if (pkt && pkt->isRouteFlood() && ContentionTracker::computePacketHash32(pkt) == hash32) { uint32_t airtime = _radio->getEstAirtimeFor(pkt->getRawLength()); - uint16_t headroom = _contention.getReactiveHeadroom(hash32, airtime); - if (headroom == 0) break; - uint32_t extra = _rng->nextInt(0, (int)headroom + 1); - /* Reschedule from NOW, not from the original EMA-based schedule. - * Hearing a dupe means the channel was just used — defer from - * this moment, don't compound on top of the base delay. */ - _mgr->rescheduleOutbound(i, now + extra); - _contention.addReactiveExtension(hash32, (uint16_t)extra); - notifyTxQueued(extra); + uint16_t delay = _contention.getReactiveHeadroom(hash32, airtime); + if (delay == 0) break; + /* Reschedule from NOW: heard a dupe, defer by one + * backoff_multiplier × airtime window per dupe. */ + _mgr->rescheduleOutbound(i, now + delay); + _contention.addReactiveExtension(hash32, delay); + notifyTxQueued(delay); break; } }