diff --git a/README-flood-suppression.md b/README-flood-suppression.md new file mode 100644 index 0000000000..0baf3b748d --- /dev/null +++ b/README-flood-suppression.md @@ -0,0 +1,273 @@ +# Flood Suppression — Redundancy-Aware Rebroadcast Cancellation + +A `simple_repeater` feature that cancels a repeater's **own scheduled flood +re-broadcast when neighbouring repeaters have already forwarded the same flood** +— i.e. when its re-broadcast would be redundant. It cuts on-air flood traffic and +collisions while preserving reach. + +It is implemented entirely at the **application layer** (`simple_repeater`); the +core library (`Mesh`, `Dispatcher`, `Packet`) is not modified. + +--- + +## Mechanism + +A flood propagates by every repeater re-broadcasting it once. In a dense mesh +many of those re-broadcasts cover nodes that have already received the flood from +someone else — pure redundancy that only consumes airtime and causes collisions. + +This feature turns each repeater into a *listener before it transmits*: + +1. **One identity per flood.** `Packet::calculatePacketHash` is path-independent + for floods (it hashes only `payloadType + payload`). So the original, every + overheard forward, and the node's own scheduled outbound re-broadcast all + share **one hash**. + +2. **Count overheard forwards at RX-arrival time.** In `MyMesh::logRx` — which + fires after parse but *before* `calcRxDelay`/`queueInbound` (`Dispatcher.cpp`) + — every received flood copy is attributed to its hash. The first copy is + recorded; each later copy is an **overheard forward** by a neighbour and + increments a per-hash counter. + +3. **SNR-weighted counter (correct sign).** The weight of each overheard forward + depends on its RX SNR, which is a proxy for how central vs. edge the node is: + | RX SNR of the overheard forward | Weight | Meaning | + |---|---|---| + | `>= snr.hi` | **+2** | Strong forward nearby → you are central, your rebroadcast is redundant | + | `< snr.lo` | **0** | Weak forward → you are at the edge, keep extending reach | + | otherwise | **+1** | Neutral | + +4. **Cancel when redundant.** Once the weighted count reaches the threshold **C**, + the hash entry is flagged `suppressed` and the already scheduled outbound + re-broadcast is removed from the TX queue (`cancelPendingFloodOutbound` → + `_mgr->removeOutboundByIdx` + `releasePacket`). + +5. **Scheduling gate.** `allowPacketForward` refuses to schedule a rebroadcast + whose hash is already flagged `suppressed`. This covers the ordering case where + a later copy arrives and is flagged before the first copy is processed. + +6. **TX-delay bias.** `getRetransmitDelay` widens the TX window for central relays + (RX SNR `>= snr.hi`) so they have more time to observe overheard forwards and + be cancelled; edge relays keep the short delay and extend reach quickly. + +Per-hash bookkeeping lives in a small ring with TTL eviction +(`src/helpers/FloodSuppression.h`), purged from `MyMesh::loop()`. + +### Why the counter runs in `logRx` (arrival time) + +`allowPacketForward` and `filterRecvFloodPacket` are not suitable counting hooks: +the former is called only for the first copy, the latter runs after the inbound +delay. `logRx` runs for **every** received packet, after parse, **before** +`calcRxDelay` — so overheard forwards are counted the instant they arrive, not +after their own RX delay. This makes the cancellation deadline +`own_TX_fire_time` instead of `own_TX_fire_time − neighbour_calcRxDelay`, i.e. +cancels reliably land before the redundant TX goes out. + +--- + +## Configuration + +There is **one master switch** and three tuning parameters. The threshold **C is +not user-configurable** — it is derived from the neighbour table (adaptive) with a +static fallback (see *Adaptive mode*). + +`NodePrefs` fields (`src/helpers/CommonCLI.h`), persisted at file bytes 295–298 +(`src/helpers/CommonCLI.cpp`): + +| Field | Type | Default | Meaning | +|---|---|---|---| +| `flood_suppress` | `uint8_t` | `1` (on) | **Master switch.** `0` = feature fully off; `1` = on (adaptive + static fallback). | +| `flood_suppress_snr_hi` | `int8_t` (dB) | `9` | Overheard forward with SNR `>=` this counts **double**. | +| `flood_suppress_snr_lo` | `int8_t` (dB) | `0` | Overheard forward with SNR `<` this counts **0** (preserve edge). | +| `flood_suppress_delay_x` | `uint8_t` | `2` | Extra TX-delay multiplier for central flood relays. | + +The feature is **on by default**; `set flood.suppress off` (or YAML +`flood_suppress: 0`) disables it completely. + +> **Real-HW note:** adding these trailing bytes changes the persisted prefs binary +> layout. Older prefs files simply leave the fields at the constructor defaults +> (on) — no migration step required. + +### CLI (dot-notation) + +| Command | Effect | +|---|---| +| `set flood.suppress on` / `off` | master switch (`get flood.suppress`) | +| `set flood.suppress.snr.hi ` | `-30..30` (`get flood.suppress.snr.hi`) | +| `set flood.suppress.snr.lo ` | `-30..30` (`get flood.suppress.snr.lo`) | +| `set flood.suppress.delay.factor ` | `0..8` (`get flood.suppress.delay.factor`) | + +--- + +## Adaptive mode (self-tuning, zero-admin) + +With the master switch **on**, the threshold **C** and `snr.hi` are **derived from +the repeater's neighbour table** (`simple_repeater`'s `neighbours[]`, seeded from +zero-hop repeater adverts / node-discovery and kept fresh by overheard forwards), +with a safe **static fallback** +when no neighbour data is available. No per-topology tuning is required. + +`MyMesh::updateAdaptiveFloodParams()` runs throttled (~every 1 min) from `loop()` +and caches the **effective** values; the consumption sites read +`effectiveFloodSuppressC()` / `effectiveFloodSuppressSnrHi()`. The whole derivation +is under `#if MAX_NEIGHBOURS` (the table is a build flag). + +**Derivation** (only **fresh** neighbours counted — `heard_timestamp` age ≤ 600 s, +i.e. heard within the last 10 min): + +A neighbour's `heard_timestamp` is set when it is first learned (zero-hop advert or +node-discovery reply) **and kept current by every overheard forward**: `logRx` calls +`touchNeighbourByHash`, which matches the received flood's *last* path hash — the +immediate RF neighbour that relayed it — against the table and refreshes +`heard_timestamp` plus a smoothed SNR (running mean, x4 fixed-point). This matters +because adverts can be spaced many hours apart (default 47 h, up to ~150 h): without +the activity refresh the whole table would age past 600 s and adaptive would collapse +to the static fallback within 10 min of boot. The refresh can only update +*already-known* neighbours — a forwarded flood carries only a path hash, not a full +identity, so seeding brand-new neighbours still needs an advert / node-discovery. + +| Parameter | Derived from | Rule | +|---|---|---| +| `effective_c` | neighbour **density** `n` (fresh count) | `n < 3 → 0` (edge node — don't suppress) · `3–4 → 3` · `≥ 5 → 2` (dense core — aggressive) | +| `effective_snr_hi` | link-SNR **p75** of fresh neighbours | `clamp(p75, snr.lo+4, snr.lo+12)`; needs ≥ 4 samples, else the configured `snr.hi` | + +A 2-cycle debounce on `c` prevents flapping when the neighbour count fluctuates (at +a 1-min recompute cadence an adopted change lands within ~2 min; the recompute cost +itself is negligible, so the cadence bounds *reaction latency*, not CPU load). + +**Static fallback** — when the neighbour table is unavailable the feature still +works with a built-in threshold (`FLOOD_SUPPRESS_FALLBACK_C = 2` in `MyMesh.cpp`) +plus the configured `snr.hi`/`snr.lo`/`delay.factor`: + +| Condition | `effective_c` | +|---|---| +| master switch **off** | `0` (feature disabled) | +| master on, ≥ 1 fresh neighbour (adaptive active) | derived from density (above) | +| master on, no fresh neighbours / `MAX_NEIGHBOURS` undefined / cold start | `FLOOD_SUPPRESS_FALLBACK_C` (= 2) | + +So a node that knows its neighbourhood adapts (incl. turning off if it is sparse); +a node that does not yet know it (cold start, or no table compiled in) uses the +gentle static fallback. The counter mechanism itself protects genuinely sparse +nodes regardless — too few overheard forwards ever reach the threshold. + +### Self-contained boot discovery + +Adaptive needs the neighbour table populated soon after boot. Rather than depend on +another feature being enabled, flood suppression brings its **own** boot discovery, +analogous to `feature/repeater-swarm-2`: + +- `sendNodeDiscoverReq(uint32_t delay_millis)` accepts a future, jittered send + (de-synchronises a fleet reboot); `examples/simple_repeater/main.cpp` fires it at + ~21 s after the boot advert, gated on `flood_suppress`. The table then fills + within ~30–60 s on hardware. + +### Simulator caveat (mcsim) + +The neighbour table does **not** populate in the simulator: all repeaters boot +synchronously, so their periodic adverts collide and no one receives them, and the +sim (`sim_main.cpp`) deliberately omits the boot discovery for the same reason. +Consequently adaptive stays on the **static fallback** in sim — which still +demonstrates the suppression effect (see measured result) and verifies the safe +fallback, but the *adaptive* c/hi tuning itself must be measured on hardware (boot +discovery with jitter de-synchronises real reboots). The overheard-forward liveness +refresh (`touchNeighbourByHash`) does **not** change this: it only refreshes +already-known neighbours, and in sim none are ever seeded, so sim still runs on the +static fallback. The refresh is a hardware-only improvement. + +--- + +## Code locations (firmware) + +| File | Change | +|---|---| +| `src/helpers/FloodSuppression.h` | **New.** Per-hash ring: `{hash, weighted_count, first_snr, strongest_overheard, first_seen, suppressed, active}` + `find` / `touch` / `purge`. | +| `examples/simple_repeater/MyMesh.h` | Helper include; `_flood_supp` + adaptive state (`_fs_eff_c`, `_fs_eff_hi`, `_fs_adaptive_active`, …); `cancelPendingFloodOutbound`, `updateAdaptiveFloodParams`, `effectiveFloodSuppressC/Hi`, `touchNeighbourByHash`; `sendNodeDiscoverReq(delay_millis)`. | +| `examples/simple_repeater/MyMesh.cpp` | `logRx` (count + SNR-bias + cancel + neighbour-liveness refresh via `touchNeighbourByHash`), `allowPacketForward` (gate), `cancelPendingFloodOutbound`, `touchNeighbourByHash` (refresh known neighbour from an overheard forward's last path hash + smoothed SNR), `getRetransmitDelay` (delay bias), `loop()` (purge + adaptive recompute @ 1 min), `updateAdaptiveFloodParams` + effective accessors + `FLOOD_SUPPRESS_FALLBACK_C`, `sendNodeDiscoverReq(delay)`, constructor defaults. Consumption reads *effective* values. | +| `examples/simple_repeater/main.cpp` | Boot discovery: `sendNodeDiscoverReq(…)` gated on `flood_suppress`. | +| `src/helpers/CommonCLI.h` / `CommonCLI.cpp` | `NodePrefs` fields + persisted read/write + defaults + `set/get flood.suppress*` CLI handlers. | + +`companion_radio`, `simple_room_server` and `simple_sensor` are unaffected — only +`simple_repeater` overrides `logRx`/`allowPacketForward` for suppression. + +--- + +## Simulator integration (mcsim) + +The feature is exercised through the simulator, plumbed end-to-end: + +- **Properties** `firmware/flood_suppress`, `firmware/flood_suppress_{snr_hi,snr_lo,delay_x}` + (`crates/mcsim-model/src/properties/definitions.rs`, registered in `registry.rs`, + re-exported in `mod.rs`, applied in `crates/mcsim-model/src/lib.rs`). +- **Config structs** `RepeaterConfig` (`crates/mcsim-firmware/src/lib.rs`) and the + FFI `NodeConfig` (`crates/mcsim-firmware/src/dll.rs`) — both gained the four + fields; `_reserved` shrank 36 → 32 bytes to keep the C ABI identical to + `SimNodeConfig` (`simulator/common/include/sim_api.h`). +- **Forwarding** in `simulator/repeater/sim_main.cpp`, guarded by + `SIM_FW_HAS_FLOOD_SUPPRESS`. +- **Feature detection** in `crates/mcsim-firmware/build.rs` — defines the macro + when `CommonCLI.h` contains a `flood_suppress*` field. The sim build also defines + `MAX_NEIGHBOURS=50` (matching HW variants) so the neighbour table compiles in sim. + +### A/B testing + +Topology YAMLs are merged (later overrides earlier), so a tiny overlay toggles the +feature without duplicating the topology: + +```yaml +# fsupp_baseline.yaml — feature OFF (unsuppressed baseline) +defaults: + node: + firmware: + flood_suppress: 0 +``` + +```bash +# baseline (off) +cargo run -- run examples/topologies/multi_path.yaml examples/behaviors/broadcast.yaml \ + examples/topologies/fsupp_baseline.yaml \ + --seed 42 --duration 120s --metrics-output json --metrics-file baseline.json \ + --metric mcsim.flood.* --metric mcsim.radio.tx_packets/route_type \ + --metric mcsim.radio.tx_airtime_us/route_type --metric mcsim.radio.rx_collided + +# on (default; no overlay needed — or use fsupp_on.yaml to pin the params) +cargo run -- run examples/topologies/multi_path.yaml examples/behaviors/broadcast.yaml \ + --seed 42 --duration 120s ... # same metrics +``` + +**Relevant metrics** +- `mcsim.flood.coverage` — gauge `reached_nodes / total_nodes`; the reach signal. +- `mcsim.radio.tx_packets{route_type=flood}` / `mcsim.radio.tx_airtime_us{route_type=flood}` — flood cost. +- `mcsim.radio.rx_collided` — collision count. +- (`mcsim.flood.nodes_reached` is a histogram that mixes channel broadcasts with + repeater advert floods — treat its tail as noise, not as a reach signal.) + +### Measured result (`multi_path.yaml` + `broadcast.yaml`, seed 42, 120 s) + +In the sim adaptive stays on the static fallback (`FLOOD_SUPPRESS_FALLBACK_C = 2`), +so this is the fallback-path effect: + +| Config | Flood TX | Flood airtime | Collisions | Coverage | +|---|---|---|---|---| +| `off` (baseline) | 237 | 38.0 M | 142 | 0.308 | +| `on` (default) | 163 (**−31 %**) | **−31 %** | 57 (**−60 %**) | 0.308 | + +`coverage` is stable at `0.308 = 4/13` (= all four companion recipients reached) — +**reach is preserved**; the reduction is in *redundant copies*, exactly the intent. + +--- + +## Tuning guidance + +In adaptive mode `c` is self-tuned, so these mainly adjust the SNR-weighting and +the cancel window (and serve as the static fallback when no neighbour data exists). + +- `flood.suppress.snr.hi` is the main aggressiveness lever and should sit in the + upper portion of the topology's link-SNR range: if almost every link exceeds it, + every overheard forward counts double and the threshold is reached after a single + forward (very aggressive → may over-suppress). Raise it to suppress only the + genuinely redundant, central relays. +- `flood.suppress.snr.lo` should sit below the weakest link you still want to *use* + for reach, so edge relays are never suppressed by their own weak inbound. +- `flood.suppress.delay.factor` widens the cancel window for central relays + (higher → more time to observe overheard forwards and be cancelled). +- Monotonic: lower `snr.hi` → more aggressive; higher → gentler. diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 5598dba550..c833a778e9 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -692,6 +692,34 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### [Experimental] Flood suppression — redundancy-aware rebroadcast cancellation +**Repeater Only:** Yes + +Cancels a repeater's own scheduled flood rebroadcast when neighbouring repeaters have +already forwarded the same flood (i.e. its rebroadcast would be redundant), cutting +on-air flood traffic and collisions while preserving reach. The cancellation threshold +**C is not user-configurable** — it is derived from the neighbour table (adaptive) with +a static fallback. These options are the master switch plus the SNR-weighting and +TX-delay tuning; see [`../README-flood-suppression.md`](../README-flood-suppression.md) for the full mechanism. + +**Usage:** +- `get flood.suppress` / `set flood.suppress ` +- `get flood.suppress.snr.hi` / `set flood.suppress.snr.hi ` +- `get flood.suppress.snr.lo` / `set flood.suppress.snr.lo ` +- `get flood.suppress.delay.factor` / `set flood.suppress.delay.factor ` + +**Parameters:** +- `state` (`flood.suppress`): `on`|`off` — master switch (disables the feature entirely when `off`) +- `dB` (`flood.suppress.snr.hi`): `-30..30` — overheard forward with SNR `>=` this counts **double** (central/redundant relay) +- `dB` (`flood.suppress.snr.lo`): `-30..30` — overheard forward with SNR `<` this counts **0** (preserve edge reach) +- `n` (`flood.suppress.delay.factor`): `0..8` — extra TX-delay multiplier for central flood relays (widens the cancel window so a redundant rebroadcast is more likely to be observed and cancelled) + +**Defaults:** `flood.suppress` = `on` · `flood.suppress.snr.hi` = `9` · `flood.suppress.snr.lo` = `0` · `flood.suppress.delay.factor` = `2` + +**Note:** _Experimental feature —_ still being tuned and measured on hardware. + +--- + ### ACL #### Add, update or remove permissions for a companion diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b66e19522a..096292225f 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -87,6 +87,33 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn #endif } +// Refresh a *known* neighbour's liveness from an overheard forward, without waiting +// for its (rare) advert. A forwarded FLOOD carries only the forwarders' path +// *hashes*, not full identities, so this can only update an entry already seeded by +// an advert / node-discovery (putNeighbour) -- it cannot create a new one (empty +// slots have no identity to match, hence the heard_timestamp == 0 skip). The LAST +// path hash is the most recent forwarder, i.e. our immediate RF neighbour. +// SNR is stored as a running mean (x4 fixed-point, same scale as NeighbourInfo::snr) +// so a single outlier copy does not skew the link-quality estimate used by +// updateAdaptiveFloodParams(). +void MyMesh::touchNeighbourByHash(const mesh::Packet* packet) { +#if MAX_NEIGHBOURS + uint8_t count = packet->getPathHashCount(); + if (count < 1) return; // no forwarder hash -> immediate sender not identifiable + uint8_t hs = packet->getPathHashSize(); + const uint8_t* last = packet->path + (count - 1) * hs; // most recent forwarder == our RF neighbour + int8_t new_snr = (int8_t)(packet->getSNR() * 4); // x4, same scale as NeighbourInfo::snr + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp == 0) continue; // empty slot: no identity to match (cannot seed here) + if (neighbours[i].id.isHashMatch(last, hs)) { + neighbours[i].heard_timestamp = getRTCClock()->getCurrentTime(); + neighbours[i].snr = (neighbours[i].snr + new_snr) / 2; // smoothed link quality (x4) + return; // at most one slot matches a given hash + } + } +#endif +} + uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) { ClientInfo* client = NULL; if (data[0] == 0) { // blank password, just check if sender is in ACL @@ -426,9 +453,107 @@ void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, ui } } +void MyMesh::cancelPendingFloodOutbound(const uint8_t* hash) { + // Remove our own already-scheduled flood rebroadcast for this hash (if any). + // At most one such outbound exists per flood; the hash is path-independent, + // so it matches the inbound copies we counted. + int n = _mgr->getOutboundTotal(); + for (int i = 0; i < n; i++) { + mesh::Packet* p = _mgr->getOutboundByIdx(i); + if (p && p->isRouteFlood()) { + uint8_t h[MAX_HASH_SIZE]; + p->calculatePacketHash(h); + if (memcmp(h, hash, MAX_HASH_SIZE) == 0) { + mesh::Packet* removed = _mgr->removeOutboundByIdx(i); + if (removed) releasePacket(removed); // return to pool + return; // a node schedules at most one rebroadcast per flood + } + } + } +} + +// Static C used when the neighbour table is unavailable (no MAX_NEIGHBOURS, cold start, or no +// fresh neighbours yet): a moderate threshold — the counter still won't fire for genuinely sparse +// nodes (too few overheard forwards reach it), so this is safe as a zero-admin default. +static const uint8_t FLOOD_SUPPRESS_FALLBACK_C = 2; + +// Effective params: the master switch gates everything; adaptive values apply when neighbour data +// is available, otherwise the static fallback (configured snr_hi/lo/delay + FLOOD_SUPPRESS_FALLBACK_C). +uint8_t MyMesh::effectiveFloodSuppressC() const { + if (!_prefs.flood_suppress) return 0; + return _fs_adaptive_active ? _fs_eff_c : FLOOD_SUPPRESS_FALLBACK_C; +} +int8_t MyMesh::effectiveFloodSuppressSnrHi() const { + if (!_prefs.flood_suppress) return _prefs.flood_suppress_snr_hi; // moot: effective c == 0 + return _fs_adaptive_active ? _fs_eff_hi : _prefs.flood_suppress_snr_hi; +} + +// Derive effective c (from neighbour density) and snr_hi (from link-SNR p75). Runs throttled from +// loop(); sets _fs_adaptive_active. Under #if MAX_NEIGHBOURS (else adaptive stays inactive and +// effectiveFloodSuppressC falls back to FLOOD_SUPPRESS_FALLBACK_C). +void MyMesh::updateAdaptiveFloodParams() { +#if MAX_NEIGHBOURS + int n = 0; + int8_t snr_x4[MAX_NEIGHBOURS]; + uint32_t now = getRTCClock()->getCurrentTime(); // seconds (RTC) + const uint32_t NEIGHBOUR_FRESH_S = 600; // 10 min: table has no aging + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp == 0) continue; // empty slot + if ((now - neighbours[i].heard_timestamp) > NEIGHBOUR_FRESH_S) continue; // stale + snr_x4[n++] = neighbours[i].snr; // stored x4 + } + + if (n < 1) { + _fs_adaptive_active = false; // no fresh neighbours -> static fallback + return; + } + _fs_adaptive_active = true; + + // c from density: <3 fresh => 0 (edge node, don't suppress); 3-4 => 3; >=5 => 2. + uint8_t derived_c = (n < 3) ? 0 : (n <= 4) ? 3 : 2; + + // snr_hi = p75 of fresh link SNRs (dB), clamped to [lo+4, lo+12]; needs >=4 samples. + int8_t derived_hi = _prefs.flood_suppress_snr_hi; // else keep configured + if (n >= 4) { + for (int i = 1; i < n; i++) { // insertion sort (<=50 elems) + int8_t v = snr_x4[i]; int j = i - 1; + while (j >= 0 && snr_x4[j] > v) { snr_x4[j + 1] = snr_x4[j]; j--; } + snr_x4[j + 1] = v; + } + int8_t hi_db = (int8_t)(snr_x4[((n - 1) * 3) / 4] / 4); // p75, x4 -> dB + int8_t lo = _prefs.flood_suppress_snr_lo; + if (hi_db < lo + 4) hi_db = lo + 4; + if (hi_db > lo + 12) hi_db = lo + 12; + derived_hi = hi_db; + } + + // Debounce c: adopt a change only after a 2nd confirming cycle (avoid flapping). + uint8_t new_c = (derived_c == _fs_pending_c) ? derived_c : _fs_eff_c; + _fs_pending_c = derived_c; + + if (new_c != _fs_eff_c || derived_hi != _fs_eff_hi) { + MESH_DEBUG_PRINTLN("%s flood-suppress adaptive: neighbours=%d -> c=%d (was %d), snr_hi=%d (was %d)", + getLogDateTime(), n, new_c, _fs_eff_c, (int)derived_hi, (int)_fs_eff_hi); + } + _fs_eff_c = new_c; + _fs_eff_hi = derived_hi; +#else + _fs_adaptive_active = false; // no neighbour table compiled in -> static fallback +#endif +} + bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; if (packet->isRouteFlood()) { + if (effectiveFloodSuppressC() > 0) { + // If overheard forwards already made our rebroadcast redundant, do not + // schedule it at all (covers the case where the 2nd copy arrived and was + // flagged suppressed before the 1st copy was processed/scheduled). + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + FloodSuppressionEntry* e = _flood_supp.find(hash, millis()); + if (e && e->suppressed) return false; + } if (packet->getPathHashCount() >= _prefs.flood_max) return false; if (packet->getRouteType() == ROUTE_TYPE_FLOOD && packet->getPathHashCount() >= _prefs.flood_max_unscoped) return false; if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= _prefs.flood_max_advert) return false; @@ -473,6 +598,48 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { } void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { + // Refresh known-neighbour liveness from this overheard forward. logRx fires for + // EVERY received packet (allowPacketForward does not -- it runs only for the + // first copy), so this is the reliable place to keep heard_timestamp current. + // Adverts may be hours apart; forwarded floods are frequent, so the neighbour + // table no longer goes entirely stale between adverts. + if (pkt->isRouteFlood()) { + touchNeighbourByHash(pkt); + } + + // --- Redundancy-aware FLOOD suppression --------------------------------- + // Count overheard forwards at RX-ARRIVAL time (here, before calcRxDelay). + // The packet hash is path-independent for floods, so every copy of one flood + // shares an identity. First copy -> record; later copies -> a neighbour has + // already re-broadcast, so accumulate an SNR-weighted count and, once it + // reaches the threshold C, cancel our own (redundant) scheduled rebroadcast. + if (effectiveFloodSuppressC() > 0 && pkt->isRouteFlood()) { + uint8_t hash[MAX_HASH_SIZE]; + pkt->calculatePacketHash(hash); + bool is_new = false; + FloodSuppressionEntry* e = _flood_supp.touch(hash, millis(), &is_new); + if (e) { + int8_t snr_x4 = (int8_t)(pkt->getSNR() * 4.0f); + if (is_new) { + _fs_seen++; // distinct flood heard -> candidate for our rebroadcast + e->first_snr_x4 = snr_x4; // record distance-to-source proxy + } else if (!e->suppressed) { + // an overheard forward by a neighbour: SNR-weighted (correct sign). + int8_t lo_x4 = (int8_t)(_prefs.flood_suppress_snr_lo * 4); + int8_t hi_x4 = (int8_t)(effectiveFloodSuppressSnrHi() * 4); + uint8_t w = (snr_x4 < lo_x4) ? 0 : (snr_x4 >= hi_x4) ? 2 : 1; + if (w) { + e->weighted_count += w; + if (snr_x4 > e->strongest_overheard_x4) e->strongest_overheard_x4 = snr_x4; + } + if (e->weighted_count >= effectiveFloodSuppressC()) { + e->suppressed = true; + _fs_suppressed++; // our rebroadcast was made redundant + cancelPendingFloodOutbound(hash); // our rebroadcast is redundant + } + } + } + } #ifdef WITH_BRIDGE if (_prefs.bridge_pkt_src == 1) { bridge.sendPacket(pkt); @@ -542,7 +709,15 @@ int MyMesh::calcRxDelay(float score, uint32_t air_time) const { uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); + uint32_t delay = getRNG()->nextInt(0, 5*t + 1); + // Central flood relays (strong RX SNR) wait longer -> wider window to observe + // overheard forwards and be cancelled as redundant. Edge relays keep the short + // delay so they extend reach quickly. + if (effectiveFloodSuppressC() > 0 && packet->isRouteFlood() + && packet->getSNR() >= effectiveFloodSuppressSnrHi()) { + delay *= (1 + _prefs.flood_suppress_delay_x); + } + return delay; } uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); @@ -827,19 +1002,29 @@ void MyMesh::onControlDataRecv(mesh::Packet* packet) { } } -void MyMesh::sendNodeDiscoverReq() { +void MyMesh::sendNodeDiscoverReq(uint32_t delay_millis) { uint8_t data[10]; data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // prefix_only=0 data[1] = (1 << ADV_TYPE_REPEATER); getRNG()->random(&data[2], 4); // tag memcpy(&pending_discover_tag, &data[2], 4); - pending_discover_until = futureMillis(60000); + + // When scheduled in the future (e.g. fired after the boot advert), add a small random jitter + // so a fleet reboot doesn't synchronise all discover requests, and shift the reply window + // past the actual send time so responses arriving after the delayed TX aren't dropped. + uint32_t effective_delay = delay_millis; + if (delay_millis > 0) { + uint8_t jb[1]; getRNG()->random(jb, 1); + effective_delay += (uint32_t)jb[0] * 16u; // 0..4080 ms jitter + } + pending_discover_until = futureMillis(60000 + effective_delay); + uint32_t since = 0; memcpy(&data[6], &since, 4); auto pkt = createControlData(data, sizeof(data)); if (pkt) { - sendZeroHop(pkt); + sendZeroHop(pkt, effective_delay); } } @@ -860,6 +1045,13 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc { last_millis = 0; uptime_millis = 0; + _fs_eff_c = 0; // adaptive: off until neighbour table fills + _fs_eff_hi = 9; + _fs_pending_c = 0; + _fs_adaptive_active = false; // until neighbour data is available -> static fallback + _fs_next_recompute_ms = 0; + _fs_seen = 0; + _fs_suppressed = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; set_radio_at = revert_radio_at = 0; @@ -893,6 +1085,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max_advert = 8; _prefs.interference_threshold = 0; // disabled _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') + _prefs.flood_suppress = 1; // redundancy-aware flood suppression ON by default (adaptive + static fallback) + _prefs.flood_suppress_snr_hi = 9; // dB: strong overheard forward => counts double + _prefs.flood_suppress_snr_lo = 0; // dB: weak overheard forward => ignored (preserve edge) + _prefs.flood_suppress_delay_x = 2; // extra TX-delay multiplier for central flood relays // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -1152,6 +1348,11 @@ void MyMesh::formatPacketStatsReply(char *reply) { getNumRecvFlood(), getNumRecvDirect()); } +void MyMesh::formatFloodSuppressRatioReply(char *reply) { + if (!_prefs.flood_suppress) return; // plain "> off" when the master switch is off + StatsFormatHelper::formatFloodSuppressRatio(reply, _fs_suppressed, _fs_seen); +} + void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); @@ -1169,6 +1370,8 @@ void MyMesh::clearStats() { radio_driver.resetStats(); resetStats(); ((SimpleMeshTables *)getTables())->resetStats(); + _fs_seen = 0; + _fs_suppressed = 0; } void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { @@ -1269,6 +1472,13 @@ void MyMesh::loop() { mesh::Mesh::loop(); + _flood_supp.purge(millis()); // evict stale flood-suppression entries + + if (_prefs.flood_suppress && millisHasNowPassed(_fs_next_recompute_ms)) { + updateAdaptiveFloodParams(); // derive _fs_eff_c/_fs_eff_hi from neighbour table + _fs_next_recompute_ms = futureMillis(60UL * 1000); // every 1 min (reaction latency; cost is negligible) + } + if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { mesh::Packet *pkt = createSelfAdvert(); uint32_t delay_millis = 0; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 0b2e7491b7..748caebc10 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -99,6 +100,15 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { RegionEntry* recv_pkt_region; TransportKey default_scope; RateLimiter discover_limiter, anon_limiter; + FloodSuppressionTable _flood_supp; // redundancy-aware FLOOD suppression state + // Adaptive (neighbour-derived) effective params, recomputed in loop() under #if MAX_NEIGHBOURS. + uint8_t _fs_eff_c; // derived threshold C (0 = off); used when _fs_adaptive_active + int8_t _fs_eff_hi; // derived snr_hi (dB); used when _fs_adaptive_active + bool _fs_adaptive_active; // neighbour data available this cycle? (else static fallback) + uint8_t _fs_pending_c; // debounce: candidate c awaiting a 2nd confirming cycle + uint32_t _fs_next_recompute_ms; + uint32_t _fs_seen; // distinct floods heard (denominator of suppression ratio) + uint32_t _fs_suppressed; // floods whose rebroadcast was made redundant (numerator) uint32_t pending_discover_tag; unsigned long pending_discover_until; bool region_load_active; @@ -120,6 +130,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #endif void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); + void touchNeighbourByHash(const mesh::Packet* packet); // refresh a KNOWN neighbour's liveness/SNR from an overheard forward + void cancelPendingFloodOutbound(const uint8_t* hash); // cancel our scheduled flood rebroadcast (if any) + void updateAdaptiveFloodParams(); // derive _fs_eff_c/_fs_eff_hi from neighbour table + uint8_t effectiveFloodSuppressC() const; // adaptive? _fs_eff_c : flood_suppress_c + int8_t effectiveFloodSuppressSnrHi() const; // adaptive? _fs_eff_hi : flood_suppress_snr_hi uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); @@ -182,7 +197,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); void begin(FILESYSTEM* fs); - void sendNodeDiscoverReq(); + void sendNodeDiscoverReq(uint32_t delay_millis = 0); const char* getFirmwareVer() override { return FIRMWARE_VERSION; } const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } const char* getRole() override { return FIRMWARE_ROLE; } @@ -217,6 +232,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; + void formatFloodSuppressRatioReply(char *reply) override; void startRegionsLoad() override; bool saveRegions() override; void onDefaultRegionChanged(const RegionEntry* r) override; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index a714db68ec..23604d95d2 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -119,6 +119,14 @@ void setup() { the_mesh.sendSelfAdvertisement(16000, false); #endif + // When flood suppression is enabled, actively discover direct neighbours shortly + // after boot so the neighbour list — which adaptive c/snr_hi derivation relies on — fills + // fast (~30-60s), instead of waiting for periodic adverts. Fired after the boot self-advert; + // jitter inside sendNodeDiscoverReq de-synchronises a fleet reboot. + if (the_mesh.getNodePrefs()->flood_suppress) { + the_mesh.sendNodeDiscoverReq(16000 + 5000); // ~21s + jitter + } + board.onBootComplete(); } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c95e3e34b0..08ba26afc8 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -93,7 +93,11 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.read((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - // next: 295 + file.read((uint8_t *)&_prefs->flood_suppress, sizeof(_prefs->flood_suppress)); // 295 + file.read((uint8_t *)&_prefs->flood_suppress_snr_hi, sizeof(_prefs->flood_suppress_snr_hi)); // 296 + file.read((uint8_t *)&_prefs->flood_suppress_snr_lo, sizeof(_prefs->flood_suppress_snr_lo)); // 297 + file.read((uint8_t *)&_prefs->flood_suppress_delay_x, sizeof(_prefs->flood_suppress_delay_x)); // 298 + // next: 299 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -125,6 +129,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean + _prefs->flood_suppress = constrain(_prefs->flood_suppress, 0, 1); // boolean (master switch) + _prefs->flood_suppress_snr_hi = constrain(_prefs->flood_suppress_snr_hi, -30, 30); + _prefs->flood_suppress_snr_lo = constrain(_prefs->flood_suppress_snr_lo, -30, 30); + _prefs->flood_suppress_delay_x = constrain(_prefs->flood_suppress_delay_x, 0, 8); file.close(); } @@ -190,7 +198,11 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.write((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 file.write((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - // next: 295 + file.write((uint8_t *)&_prefs->flood_suppress, sizeof(_prefs->flood_suppress)); // 295 + file.write((uint8_t *)&_prefs->flood_suppress_snr_hi, sizeof(_prefs->flood_suppress_snr_hi)); // 296 + file.write((uint8_t *)&_prefs->flood_suppress_snr_lo, sizeof(_prefs->flood_suppress_snr_lo)); // 297 + file.write((uint8_t *)&_prefs->flood_suppress_delay_x, sizeof(_prefs->flood_suppress_delay_x)); // 298 + // next: 299 file.close(); } @@ -510,6 +522,22 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->cad_enabled = memcmp(&config[4], "on", 2) == 0; savePrefs(); strcpy(reply, "OK"); + } else if (memcmp(config, "flood.suppress ", 15) == 0) { + _prefs->flood_suppress = memcmp(&config[15], "on", 2) == 0; + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "flood.suppress.snr.hi ", 22) == 0) { + int db = atoi(&config[22]); + if (db >= -30 && db <= 30) { _prefs->flood_suppress_snr_hi = db; savePrefs(); strcpy(reply, "OK"); } + else strcpy(reply, "Error, must be -30..30 dB"); + } else if (memcmp(config, "flood.suppress.snr.lo ", 22) == 0) { + int db = atoi(&config[22]); + if (db >= -30 && db <= 30) { _prefs->flood_suppress_snr_lo = db; savePrefs(); strcpy(reply, "OK"); } + else strcpy(reply, "Error, must be -30..30 dB"); + } else if (memcmp(config, "flood.suppress.delay.factor ", 28) == 0) { + int n = atoi(&config[28]); + if (n >= 0 && n <= 8) { _prefs->flood_suppress_delay_x = n; savePrefs(); strcpy(reply, "OK"); } + else strcpy(reply, "Error, must be 0..8"); } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { _prefs->agc_reset_interval = atoi(&config[19]) / 4; savePrefs(); @@ -812,6 +840,15 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold); } else if (memcmp(config, "cad", 3) == 0) { sprintf(reply, "> %s", _prefs->cad_enabled ? "on" : "off"); + } else if (memcmp(config, "flood.suppress.delay.factor", 27) == 0) { + sprintf(reply, "> %d", (uint32_t) _prefs->flood_suppress_delay_x); + } else if (memcmp(config, "flood.suppress.snr.hi", 21) == 0) { + sprintf(reply, "> %d dB", (int) _prefs->flood_suppress_snr_hi); + } else if (memcmp(config, "flood.suppress.snr.lo", 21) == 0) { + sprintf(reply, "> %d dB", (int) _prefs->flood_suppress_snr_lo); + } else if (memcmp(config, "flood.suppress", 14) == 0) { + sprintf(reply, "> %s", _prefs->flood_suppress ? "on" : "off"); + _callbacks->formatFloodSuppressRatioReply(reply + strlen(reply)); } else if (memcmp(config, "agc.reset.interval", 18) == 0) { sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4); } else if (memcmp(config, "multi.acks", 10) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index f3abcf4772..37cff5aae5 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -65,6 +65,13 @@ struct NodePrefs { // persisted to file uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; uint8_t cad_enabled; // hardware Channel Activity Detection before TX (boolean) + // Redundancy-aware FLOOD suppression (simple_repeater). One master switch + SNR/delay params. + // The threshold C is derived from the neighbour table (adaptive) with a static fallback + // when no neighbour data is available; it is not user-configurable. + uint8_t flood_suppress; // master switch (0=off, 1=on); default on + int8_t flood_suppress_snr_hi; // dB: overheard forward with SNR>=this counts double (central/redundant) + int8_t flood_suppress_snr_lo; // dB: overheard forward with SNR on/off" reply. + virtual void formatFloodSuppressRatioReply(char *reply) { } virtual mesh::LocalIdentity& getSelfId() = 0; virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0; virtual void clearStats() = 0; diff --git a/src/helpers/FloodSuppression.h b/src/helpers/FloodSuppression.h new file mode 100644 index 0000000000..cf19be675c --- /dev/null +++ b/src/helpers/FloodSuppression.h @@ -0,0 +1,99 @@ +#pragma once + +#include // MAX_HASH_SIZE +#include + +// --- Redundancy-aware FLOOD suppression ----------------------------------- +// +// Per-flood (packet-hash) bookkeeping used by simple_repeater to suppress +// redundant re-broadcasts. The packet hash (Packet::calculatePacketHash) is +// path-independent for FLOOD packets, so the original, every overheard forward +// and our own scheduled outbound re-broadcast all share ONE hash identity. +// +// On each received flood copy (counted in MyMesh::logRx, i.e. at RX-arrival +// time, BEFORE calcRxDelay) we accumulate a weighted overheard-copy count. +// When it reaches the threshold C the entry is flagged `suppressed` and the +// already-scheduled outbound re-broadcast is cancelled (redundant). +// +// Weighting gives the SNR "distance" bias its correct sign: +// - a STRONG overheard forward (you are central / redundant) counts more, +// - a WEAK overheard forward (you are at the edge, extending reach) is +// ignored (weight 0) so reach is preserved. +// +// The table is a small ring with TTL eviction (swept from loop()). It is app +// local and touches neither the core dedup table nor the persisted prefs. + +#ifndef FLOOD_SUPPRESS_TABLE_SIZE + #define FLOOD_SUPPRESS_TABLE_SIZE 32 +#endif + +#ifndef FLOOD_SUPPRESS_TTL_MILLIS + #define FLOOD_SUPPRESS_TTL_MILLIS 10000 +#endif + +struct FloodSuppressionEntry { + uint8_t hash[MAX_HASH_SIZE]; + uint8_t weighted_count; // weighted number of overheard forwards + int8_t first_snr_x4; // SNR of the first copy seen (x4, signed) + int8_t strongest_overheard_x4; // strongest overheard forward SNR (x4) + uint32_t first_seen_ms; // for TTL eviction + bool suppressed; // our rebroadcast already cancelled/suppressed + bool active; +}; + +class FloodSuppressionTable { + FloodSuppressionEntry _entries[FLOOD_SUPPRESS_TABLE_SIZE]; + int _next_idx; + +public: + FloodSuppressionTable() { clear(); } + + void clear() { + memset(_entries, 0, sizeof(_entries)); + _next_idx = 0; + } + + // Lookup a live (active, non-expired) entry. Returns NULL if none. + FloodSuppressionEntry* find(const uint8_t* hash, uint32_t now) { + for (int i = 0; i < FLOOD_SUPPRESS_TABLE_SIZE; i++) { + FloodSuppressionEntry& e = _entries[i]; + if (e.active && !_expired(e, now) && memcmp(hash, e.hash, MAX_HASH_SIZE) == 0) { + return &e; + } + } + return NULL; + } + + // Find or create an entry. *is_new is set true when a fresh entry was created. + FloodSuppressionEntry* touch(const uint8_t* hash, uint32_t now, bool* is_new) { + FloodSuppressionEntry* e = find(hash, now); + if (e) { if (is_new) *is_new = false; return e; } + + e = &_entries[_next_idx]; // LRU ring overwrite + _next_idx = (_next_idx + 1) % FLOOD_SUPPRESS_TABLE_SIZE; + memcpy(e->hash, hash, MAX_HASH_SIZE); + e->weighted_count = 0; + e->first_snr_x4 = 0; + e->strongest_overheard_x4 = -128; // sentinel: "none seen" + e->first_seen_ms = now; + e->suppressed = false; + e->active = true; + if (is_new) *is_new = true; + return e; + } + + // Evict expired entries. Call from loop(). + void purge(uint32_t now) { + for (int i = 0; i < FLOOD_SUPPRESS_TABLE_SIZE; i++) { + if (_entries[i].active && _expired(_entries[i], now)) { + _entries[i].active = false; + } + } + } + +private: + static bool _expired(const FloodSuppressionEntry& e, uint32_t now) { + // uint32 subtraction is wrap-safe for any ttl well below the wrap period. + return (uint32_t)(now - e.first_seen_ms) > FLOOD_SUPPRESS_TTL_MILLIS; + } +}; diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index bf619133e9..aea642029d 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -1,6 +1,7 @@ #pragma once #include "Mesh.h" +#include // strlen (used by formatFloodSuppressRatio) class StatsFormatHelper { public: @@ -52,4 +53,12 @@ class StatsFormatHelper { driver.getPacketsRecvErrors() ); } + + // Appends ", suppressed / (%)" to reply (which already holds the + // flood.suppress on/off state). Reports the share of distinct floods heard whose rebroadcast + // this node suppressed; pct is 0 when none were heard. + static void formatFloodSuppressRatio(char* reply, uint32_t n_suppressed, uint32_t n_seen) { + uint32_t pct = (n_seen > 0) ? (n_suppressed * 100U) / n_seen : 0; + sprintf(reply + strlen(reply), ", suppressed %u/%u (%u%%)", n_suppressed, n_seen, pct); + } };