From 0204336668215356097b095e03f09277436e9236 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 10 Jul 2026 12:44:56 +0200 Subject: [PATCH 01/15] Implement redundancy-aware flood suppression for repeaters - Introduced a new FloodSuppressionTable to manage flood suppression state. - Added methods to touch and cancel pending flood broadcasts based on overheard forwards. - Enhanced MyMesh to refresh neighbour liveness from overheard packets. - Implemented adaptive parameters for flood suppression based on neighbour density and SNR. - Updated CLI commands to configure flood suppression settings. - Modified node discovery to expedite neighbour list population after boot. - Ensured flood suppression logic is integrated into packet handling and retransmission delays. --- README-flood-suppression.md | 276 ++++++++++++++++++++++++++++ docs/cli_commands.md | 28 +++ examples/simple_repeater/MyMesh.cpp | 207 ++++++++++++++++++++- examples/simple_repeater/MyMesh.h | 15 +- examples/simple_repeater/main.cpp | 11 ++ src/helpers/CommonCLI.cpp | 40 +++- src/helpers/CommonCLI.h | 7 + src/helpers/FloodSuppression.h | 99 ++++++++++ 8 files changed, 676 insertions(+), 7 deletions(-) create mode 100644 README-flood-suppression.md create mode 100644 src/helpers/FloodSuppression.h diff --git a/README-flood-suppression.md b/README-flood-suppression.md new file mode 100644 index 0000000000..5d228c426d --- /dev/null +++ b/README-flood-suppression.md @@ -0,0 +1,276 @@ +# 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. +- When combined with the neighbour-swarm relay (whose boot discovery is gated on + `direct_swarm_fwd`), **unify the two into one call** gated on + `(direct_swarm_fwd || flood_suppress)` to avoid a duplicate discover REQ. + +### Simulator caveat + +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 c06f5e12b3..82e168396d 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -665,6 +665,34 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Note:** An alternative to `region denyf *`, setting `flood.max.unscoped` to a lower value such as `3` would allow for local unscoped messages to propagate, while preventing noisy neighbors from flooding a local region. +--- + +#### [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 diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b66e19522a..8b1bc2b601 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,46 @@ 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) { + 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; + cancelPendingFloodOutbound(hash); // our rebroadcast is redundant + } + } + } + } #ifdef WITH_BRIDGE if (_prefs.bridge_pkt_src == 1) { bridge.sendPacket(pkt); @@ -542,7 +707,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 +1000,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 +1043,11 @@ 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; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; set_radio_at = revert_radio_at = 0; @@ -893,6 +1081,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 @@ -1269,6 +1461,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..65a26cde19 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,13 @@ 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 pending_discover_tag; unsigned long pending_discover_until; bool region_load_active; @@ -120,6 +128,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 +195,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; } diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 2ce056f521..bd1228d77a 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -100,6 +100,17 @@ void setup() { the_mesh.sendSelfAdvertisement(16000, false); #endif + // When adaptive 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. + // NOTE: if coupled with the neighbour-swarm relay (whose boot discovery is gated on + // direct_swarm_fwd), unify into ONE call gated on (direct_swarm_fwd || flood_suppress_adaptive) + // to avoid a duplicate discover REQ. + if (the_mesh.getNodePrefs()->flood_suppress_adaptive) { + the_mesh.sendNodeDiscoverReq(16000 + 5000); // ~21s + jitter + } + board.onBootComplete(); } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c95e3e34b0..0bbc7a9999 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,14 @@ 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"); } 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..276be9d84b 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 // 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; + } +}; From 41c16898eb1bcb508b28260251b1c58831625eb3 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Tue, 21 Jul 2026 21:48:26 +0200 Subject: [PATCH 02/15] refactor: update flood suppression logic in setup for clarity --- README-flood-suppression.md | 5 +---- examples/simple_repeater/main.cpp | 7 ++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/README-flood-suppression.md b/README-flood-suppression.md index 5d228c426d..0baf3b748d 100644 --- a/README-flood-suppression.md +++ b/README-flood-suppression.md @@ -160,11 +160,8 @@ analogous to `feature/repeater-swarm-2`: (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. -- When combined with the neighbour-swarm relay (whose boot discovery is gated on - `direct_swarm_fwd`), **unify the two into one call** gated on - `(direct_swarm_fwd || flood_suppress)` to avoid a duplicate discover REQ. -### Simulator caveat +### 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 diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 675b02ecaa..23604d95d2 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -119,14 +119,11 @@ void setup() { the_mesh.sendSelfAdvertisement(16000, false); #endif - // When adaptive flood suppression is enabled, actively discover direct neighbours shortly + // 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. - // NOTE: if coupled with the neighbour-swarm relay (whose boot discovery is gated on - // direct_swarm_fwd), unify into ONE call gated on (direct_swarm_fwd || flood_suppress_adaptive) - // to avoid a duplicate discover REQ. - if (the_mesh.getNodePrefs()->flood_suppress_adaptive) { + if (the_mesh.getNodePrefs()->flood_suppress) { the_mesh.sendNodeDiscoverReq(16000 + 5000); // ~21s + jitter } From 0255431c15806e77d8bc0844378e9e774a13c7ce Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sat, 25 Jul 2026 09:19:40 +0200 Subject: [PATCH 03/15] feat: implement flood suppression tracking and reporting --- examples/simple_repeater/MyMesh.cpp | 11 +++++++++++ examples/simple_repeater/MyMesh.h | 3 +++ src/helpers/CommonCLI.cpp | 1 + src/helpers/CommonCLI.h | 3 +++ src/helpers/StatsFormatHelper.h | 9 +++++++++ 5 files changed, 27 insertions(+) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 8b1bc2b601..096292225f 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -621,6 +621,7 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { 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). @@ -633,6 +634,7 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { } if (e->weighted_count >= effectiveFloodSuppressC()) { e->suppressed = true; + _fs_suppressed++; // our rebroadcast was made redundant cancelPendingFloodOutbound(hash); // our rebroadcast is redundant } } @@ -1048,6 +1050,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _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; @@ -1344,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, ""); @@ -1361,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) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 65a26cde19..748caebc10 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -107,6 +107,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { 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; @@ -230,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/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 0bbc7a9999..08ba26afc8 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -848,6 +848,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep 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 276be9d84b..37cff5aae5 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -95,6 +95,9 @@ class CommonCLICallbacks { virtual void formatStatsReply(char *reply) = 0; virtual void formatRadioStatsReply(char *reply) = 0; virtual void formatPacketStatsReply(char *reply) = 0; + // Appends the suppression-ratio suffix (", suppressed N/M (P%)") to the flood.suppress get-reply. + // Default is a no-op so non-repeater roles keep the plain "> 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/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); + } }; From 75f03542a0492a5c8377e3c21ec0a4cd60094f64 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Tue, 28 Jul 2026 17:22:43 +0200 Subject: [PATCH 04/15] feat: implement coverage-based flood suppression logic for near neighbours --- examples/simple_repeater/MyMesh.cpp | 96 +++++++++++++++++++++-------- examples/simple_repeater/MyMesh.h | 6 ++ src/helpers/FloodSuppression.h | 55 ++++++++++++----- 3 files changed, 116 insertions(+), 41 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 096292225f..ecad54f090 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -114,6 +114,50 @@ void MyMesh::touchNeighbourByHash(const mesh::Packet* packet) { #endif } +// Is neighbours[i] a "near" coverage peer? fresh (<= NEIGHBOUR_FRESH_S) and link +// SNR >= flood_suppress_snr_lo. Distant/weak neighbours are edge nodes, excluded +// (same intent as the old SNR-weighting weight-0). +bool MyMesh::isNearNeighbour(int i, uint32_t now) const { +#if MAX_NEIGHBOURS + if (neighbours[i].heard_timestamp == 0) return false; // empty slot + if ((uint32_t)(now - neighbours[i].heard_timestamp) > NEIGHBOUR_FRESH_S) return false; // stale + int8_t lo_x4 = (int8_t)(_prefs.flood_suppress_snr_lo * 4); + return neighbours[i].snr >= lo_x4; +#else + return false; +#endif +} + +// Return the index of a NEAR neighbour whose path-hash matches (or -1). A +// forwarded flood carries only forwarder path hashes, so this matches known +// neighbours only (cannot seed new ones -- same limit as touchNeighbourByHash). +int8_t MyMesh::findNearNeighbour(const uint8_t* h, uint8_t hs, uint32_t now) const { +#if MAX_NEIGHBOURS + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (!isNearNeighbour(i, now)) continue; + if (neighbours[i].id.isHashMatch(h, hs)) return (int8_t)i; + } +#endif + return -1; +} + +// True iff at least one near neighbour exists AND every CURRENT near neighbour +// is recorded as covered in e. Iterating current near neighbours makes this +// robust to a neighbour going stale mid-flood (a stale one simply isn't checked). +bool MyMesh::allNearNeighboursCovered(const FloodSuppressionEntry& e, uint32_t now) const { +#if MAX_NEIGHBOURS + bool any = false; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (!isNearNeighbour(i, now)) continue; + any = true; + if (!e.covers((uint8_t)i)) return false; + } + return any; +#else + return false; +#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 @@ -496,7 +540,6 @@ void MyMesh::updateAdaptiveFloodParams() { 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 @@ -607,36 +650,37 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { 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. + // --- Coverage-test FLOOD suppression ------------------------------------ + // M suppresses its rebroadcast of F iff every NEAR neighbour is already known + // to have F. "Known to have F" = the neighbour appears on the path of some + // forward of F that M overheard -- every hop on a decoded path forwarded F, so + // it has it (certain evidence, no SNR/inference). Walk this copy's full path + // and mark any near neighbours covered; once the covered set spans all current + // near neighbours, cancel our own (redundant) rebroadcast. Runs at RX-arrival + // (before the scheduling decision), so the first copy may even suppress before + // scheduling (caught by allowPacketForward's suppressed-entry early-out). 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 - } + if (e && !e->suppressed) { + if (is_new) _fs_seen++; // distinct flood heard -> candidate for our rebroadcast + uint32_t now = getRTCClock()->getCurrentTime(); // seconds (RTC), for near-neighbour freshness + // every hop on this decoded path forwarded F -> has it; mark near neighbours covered + uint8_t hs = pkt->getPathHashSize(); + uint8_t count = pkt->getPathHashCount(); + const uint8_t* p = pkt->path; + for (uint8_t k = 0; k < count; k++) { + int8_t idx = findNearNeighbour(p, hs, now); + if (idx >= 0) e->addCovered((uint8_t)idx); + p += hs; + } + // suppress iff every current near neighbour is now known to have F + if (allNearNeighboursCovered(*e, now)) { + e->suppressed = true; + _fs_suppressed++; // our rebroadcast was made redundant + cancelPendingFloodOutbound(hash); } } } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 748caebc10..93894088aa 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -101,6 +101,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { TransportKey default_scope; RateLimiter discover_limiter, anon_limiter; FloodSuppressionTable _flood_supp; // redundancy-aware FLOOD suppression state + // Near-neighbour freshness window for the coverage test and the adaptive-density + // count, 60 min + static const uint32_t NEIGHBOUR_FRESH_S = 3600; // 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 @@ -131,6 +134,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { 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 + bool isNearNeighbour(int i, uint32_t now) const; // fresh (<=NEIGHBOUR_FRESH_S) and SNR>=snr_lo + int8_t findNearNeighbour(const uint8_t* h, uint8_t hs, uint32_t now) const; // index of near neighbour matching hash, else -1 + bool allNearNeighboursCovered(const FloodSuppressionEntry& e, uint32_t now) const; // >=1 near && every near neighbour in e.covered 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 diff --git a/src/helpers/FloodSuppression.h b/src/helpers/FloodSuppression.h index cf19be675c..4c6f968509 100644 --- a/src/helpers/FloodSuppression.h +++ b/src/helpers/FloodSuppression.h @@ -3,22 +3,25 @@ #include // MAX_HASH_SIZE #include -// --- Redundancy-aware FLOOD suppression ----------------------------------- +// --- Coverage-test 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). +// M suppresses its rebroadcast of flood F if every NEAR neighbour is already +// known to have F. "Known to have F" = the neighbour appears on the path of +// some forward of F that M overheard -- every hop on a decoded path forwarded F, +// so it has it (certain evidence, no SNR/inference). +// This is sound for directional/co-located antennas -- a downstream neighbour +// that has not received F is on no path M decodes, so it stays uncovered and M +// forwards. // -// 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. +// Per entry we keep a small dedup set of the near-neighbour indices seen on +// overheard forwards' paths (indices into MyMesh::neighbours[]). Suppression +// fires when that set spans all CURRENT near neighbours (checked from MyMesh, +// which owns the neighbour table and the "near" definition: fresh + SNR>=snr_lo). // // 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. @@ -31,14 +34,38 @@ #define FLOOD_SUPPRESS_TTL_MILLIS 10000 #endif +// Max near neighbours recordable as "covered" per flood. Saturating is safe: +// a flood with more near neighbours than this can never confirm coverage, so M +// forwards (conservative). 16 covers typical dense clusters. +#ifndef FLOOD_SUPPRESS_COVERAGE_SET_SIZE + #define FLOOD_SUPPRESS_COVERAGE_SET_SIZE 16 +#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) + uint8_t covered[FLOOD_SUPPRESS_COVERAGE_SET_SIZE]; // near-neighbour indices known to have this flood + uint8_t covered_count; uint32_t first_seen_ms; // for TTL eviction bool suppressed; // our rebroadcast already cancelled/suppressed bool active; + + // Record a near-neighbour index as covered (dedup). Returns true if newly added. + bool addCovered(uint8_t idx) { + for (uint8_t i = 0; i < covered_count; i++) + if (covered[i] == idx) return false; + if (covered_count < FLOOD_SUPPRESS_COVERAGE_SET_SIZE) { + covered[covered_count++] = idx; + return true; + } + return false; // set full -> can't confirm coverage for this idx (safe: M forwards) + } + + // Is the given near-neighbour index already known covered? + bool covers(uint8_t idx) const { + for (uint8_t i = 0; i < covered_count; i++) + if (covered[i] == idx) return true; + return false; + } }; class FloodSuppressionTable { @@ -72,9 +99,7 @@ class FloodSuppressionTable { 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->covered_count = 0; e->first_seen_ms = now; e->suppressed = false; e->active = true; From 1a1aac7c96fc9b09cfc75bb3f43b85ad31113524 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Wed, 29 Jul 2026 22:35:54 +0200 Subject: [PATCH 05/15] feat: implement client-aware flood suppression and neighbour reach tracking --- examples/simple_repeater/MyMesh.cpp | 286 ++++++++++++++++++++++++++-- examples/simple_repeater/MyMesh.h | 30 +++ src/helpers/CommonCLI.cpp | 17 ++ src/helpers/CommonCLI.h | 5 + src/helpers/FloodSuppression.h | 24 ++- src/helpers/NeighbourLinkTable.h | 106 +++++++++++ 6 files changed, 442 insertions(+), 26 deletions(-) create mode 100644 src/helpers/NeighbourLinkTable.h diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index ecad54f090..66e936827b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -158,6 +158,105 @@ bool MyMesh::allNearNeighboursCovered(const FloodSuppressionEntry& e, uint32_t n #endif } +// Is there a FRESH DIRECTED reach edge from neighbours[from_i] to neighbours[to_j]? +// (to_j heard from_i's transmissions.) Edges are recorded from consecutive path +// hops in forwarding order (later heard earlier -> earlier reaches later), so this +// is DIRECTIONAL: RF links can be asymmetric, and we must not infer "to_j heard +// from_i" from a reverse observation. Used to infer coverage: a forwarder fi +// covers its 1-hop graph neighbours (fi reaches N). Keyed by path hash, so robust +// to LRU reordering of neighbours[]. hs is the path-hash width of the current flood. +bool MyMesh::nearReaches(int from_i, int to_j, uint8_t hs, uint32_t now) const { +#if MAX_NEIGHBOURS + uint8_t hfrom[MAX_HASH_SIZE], hto[MAX_HASH_SIZE]; + neighbours[from_i].id.copyHashTo(hfrom, hs); + neighbours[to_j].id.copyHashTo(hto, hs); + return _nbr_links.hasEdge(hfrom, hto, hs, now); +#else + return false; +#endif +} + +// Client-aware suppression gate. ALWAYS active: a dense mesh always has clients +// (possibly unlearned), so there is NO "empty set -> suppress everything" fallback. +// Returns true = "suppressing this flood is safe for attached clients". +// Tier A (TRACE/CONTROL): pure infrastructure -> clients never need -> suppress OK. +// Tier C (REQ/RESPONSE/TXT_MSG/PATH/ANON_REQ): addressed -> forward iff dest is an +// attached client, so suppress OK iff dest is NOT one. +// Tier B (ADVERT/GRP_*/ACK/MULTIPART/...): broadcast, can't address-check, clients may +// need -> NEVER suppress (always forward). +bool MyMesh::clientProtectionAllowsSuppress(const mesh::Packet* pkt, uint32_t now) const { + uint8_t pt = pkt->getPayloadType(); + if (pt == PAYLOAD_TYPE_TRACE || pt == PAYLOAD_TYPE_CONTROL) return true; // Tier A + if (pt == PAYLOAD_TYPE_REQ || pt == PAYLOAD_TYPE_RESPONSE || pt == PAYLOAD_TYPE_TXT_MSG || + pt == PAYLOAD_TYPE_PATH || pt == PAYLOAD_TYPE_ANON_REQ) { + if (pkt->payload_len < 1) return false; // malformed -> forward (safe) + return !attachedClientMatches(pkt->payload[0], now); // Tier C + } + return false; // Tier B +} + +// Seed/refresh a directly-attached leaf client (M is its first hop). Small LRU ring. +// `prefix[0]` is the 1-byte match key; `plen` is how many identity bytes are known +// (4 from an advert, 1 from a message src_hash). On refresh, upgrade the stored +// prefix only if we now know MORE bytes (never downgrade). +void MyMesh::addOrRefreshAttachedClient(const uint8_t* prefix, uint8_t plen, uint32_t now) { + uint8_t h1 = prefix[0]; + for (int i = 0; i < MAX_ATTACHED_CLIENTS; i++) { // refresh existing (match on prefix[0]) + if (_attached[i].active && _attached[i].prefix[0] == h1) { + _attached[i].last_seen = now; + if (plen > _attached[i].prefix_len) { + memcpy(_attached[i].prefix, prefix, plen); + _attached[i].prefix_len = plen; + } + return; + } + } + int slot = 0; uint32_t oldest = 0xFFFFFFFF; // else reuse inactive or oldest + for (int i = 0; i < MAX_ATTACHED_CLIENTS; i++) { + if (!_attached[i].active) { slot = i; break; } + if (_attached[i].last_seen < oldest) { oldest = _attached[i].last_seen; slot = i; } + } + memcpy(_attached[slot].prefix, prefix, plen); + _attached[slot].prefix_len = plen; + _attached[slot].last_seen = now; + _attached[slot].active = true; +} + +bool MyMesh::attachedClientMatches(uint8_t hash1, uint32_t now) const { + for (int i = 0; i < MAX_ATTACHED_CLIENTS; i++) { + if (_attached[i].active && _attached[i].prefix[0] == hash1 && + (uint32_t)(now - _attached[i].last_seen) <= ATTACHED_CLIENT_FRESH_S) { + return true; + } + } + return false; +} + +void MyMesh::removeAttachedClient(uint8_t hash1) { + for (int i = 0; i < MAX_ATTACHED_CLIENTS; i++) { + if (_attached[i].active && _attached[i].prefix[0] == hash1) _attached[i].active = false; + } +} + +void MyMesh::purgeAttachedClients(uint32_t now) { + for (int i = 0; i < MAX_ATTACHED_CLIENTS; i++) { + if (_attached[i].active && (uint32_t)(now - _attached[i].last_seen) > ATTACHED_CLIENT_FRESH_S) { + _attached[i].active = false; + } + } +} + +// Does this 1-byte hash match a known REPEATER neighbour? (Used to avoid seeding a +// repeater as a client; repeaters are handled by the coverage test, not client-protection.) +bool MyMesh::isKnownRepeaterHash1(uint8_t hash1) const { +#if MAX_NEIGHBOURS + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp != 0 && neighbours[i].id.pub_key[0] == hash1) return true; + } +#endif + return false; +} + 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 @@ -650,15 +749,31 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { touchNeighbourByHash(pkt); } - // --- Coverage-test FLOOD suppression ------------------------------------ + // --- Attached-client learning ------------------------------------------- + // A count==0 packet (empty path) means M is the originator's FIRST hop, i.e. the + // originator is a directly-attached neighbour -- a leaf CLIENT if not a known + // repeater. Seed/refresh it from the stable src_hash the payload carries + // (adverts are seeded in onAdvertRecv, which has the parsed identity). Route-type- + // agnostic: a zero-hop DIRECT packet counts too. Pathed packets (count>0) are + // ignored -- path[0]==self at every relay makes the originator ambiguous there. + if (pkt->getPathHashCount() == 0) { + uint8_t pt = pkt->getPayloadType(); + if ((pt == PAYLOAD_TYPE_REQ || pt == PAYLOAD_TYPE_RESPONSE || pt == PAYLOAD_TYPE_TXT_MSG || pt == PAYLOAD_TYPE_PATH) + && pkt->payload_len >= 2) { + uint8_t h1 = pkt->payload[1]; // src_hash (originator == attached client) + if (!isKnownRepeaterHash1(h1)) addOrRefreshAttachedClient(&h1, 1, getRTCClock()->getCurrentTime()); + } + } + + // --- Coverage-test FLOOD suppression (graph-reach) ---------------------- // M suppresses its rebroadcast of F iff every NEAR neighbour is already known - // to have F. "Known to have F" = the neighbour appears on the path of some - // forward of F that M overheard -- every hop on a decoded path forwarded F, so - // it has it (certain evidence, no SNR/inference). Walk this copy's full path - // and mark any near neighbours covered; once the covered set spans all current - // near neighbours, cancel our own (redundant) rebroadcast. Runs at RX-arrival - // (before the scheduling decision), so the first copy may even suppress before - // scheduling (caught by allowPacketForward's suppressed-entry early-out). + // to have F. A neighbour is covered if it FORWARDED F (it is on an overheard + // path -- certain) OR if it was REACHED by a near forwarder fi that has a fresh + // inter-neighbour edge fi<->N (N heard fi's forward -- inferred). Coverage + // accumulates across overheard forwards, so combined reach can cover everyone. + // Runs at RX-arrival (before scheduling), so a later overheard copy can cancel + // a pending rebroadcast early (allowPacketForward also early-outs suppressed + // entries for the copy-before-decision ordering). if (effectiveFloodSuppressC() > 0 && pkt->isRouteFlood()) { uint8_t hash[MAX_HASH_SIZE]; pkt->calculatePacketHash(hash); @@ -666,22 +781,72 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { FloodSuppressionEntry* e = _flood_supp.touch(hash, millis(), &is_new); if (e && !e->suppressed) { if (is_new) _fs_seen++; // distinct flood heard -> candidate for our rebroadcast +#if MAX_NEIGHBOURS uint32_t now = getRTCClock()->getCurrentTime(); // seconds (RTC), for near-neighbour freshness - // every hop on this decoded path forwarded F -> has it; mark near neighbours covered + uint32_t now_ms = millis(); // milliseconds, for edge TTL uint8_t hs = pkt->getPathHashSize(); uint8_t count = pkt->getPathHashCount(); const uint8_t* p = pkt->path; + + // (a) REACH-GRAPH: path hops are in forwarding order, so a consecutive near + // pair (prev_near, idx) means idx heard prev_near -> prev_near REACHES idx + // (directed; RF links can be asymmetric). Record that directed edge. Also + // flag which near neighbours are on THIS path (they forwarded F => have it). + bool on_path[MAX_NEIGHBOURS] = { false }; + int8_t prev_near = -1; for (uint8_t k = 0; k < count; k++) { int8_t idx = findNearNeighbour(p, hs, now); - if (idx >= 0) e->addCovered((uint8_t)idx); + if (idx >= 0) { + on_path[idx] = true; + if (prev_near >= 0) { + // prev_near (earlier) reaches idx (later): idx heard prev_near's forward. + _nbr_links.addEdge(neighbours[prev_near].id.pub_key, neighbours[idx].id.pub_key, hs, now_ms); + } + prev_near = idx; + } else { + prev_near = -1; // a non-near hop breaks adjacency + } p += hs; } - // suppress iff every current near neighbour is now known to have F - if (allNearNeighboursCovered(*e, now)) { + + // (b) COVERAGE: each near forwarder covers itself + the near neighbours it + // REACHES (directed: N heard fi's forward => N has F, inferred). + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (!on_path[i]) continue; // i must be a near forwarder on this path + e->addCovered((uint8_t)i); // i forwarded F => i has F (certain) + for (int j = 0; j < MAX_NEIGHBOURS; j++) { // i reaches its fresh graph neighbours + if (j == i || on_path[j] || !isNearNeighbour(j, now)) continue; + if (nearReaches(i, j, hs, now)) e->addCovered((uint8_t)j); // i reaches j (j heard i) + } + } + + // (c) ISOLATED-NEIGHBOUR FAST-FORWARD: a near neighbour that NO near + // forwarder reaches (in-degree 0) can ONLY be covered by M's own TX. If + // one exists that did NOT forward F, M must forward (it is uncovered) -> + // suppression is impossible and waiting cannot change that, so skip the + // window widening (getRetransmitDelay reads this flag). Also the natural + // cold-start behaviour (sparse graph). + e->must_cover_self = false; + for (int i = 0; i < MAX_NEIGHBOURS && !e->must_cover_self; i++) { + if (!isNearNeighbour(i, now) || on_path[i]) continue; + bool reachable = false; + for (int j = 0; j < MAX_NEIGHBOURS; j++) { // does any near j reach i? + if (j == i || !isNearNeighbour(j, now)) continue; + if (nearReaches(j, i, hs, now)) { reachable = true; break; } + } + if (!reachable) e->must_cover_self = true; + } + + // (d) suppress iff no isolated-uncovered neighbour, everyone covered, and + // client-protection allows it (3-tier, always active -- see + // clientProtectionAllowsSuppress). + if (!e->must_cover_self && allNearNeighboursCovered(*e, now) + && clientProtectionAllowsSuppress(pkt, now)) { e->suppressed = true; _fs_suppressed++; // our rebroadcast was made redundant cancelPendingFloodOutbound(hash); } +#endif } } #ifdef WITH_BRIDGE @@ -756,10 +921,17 @@ uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { 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. + // delay so they extend reach quickly. Skip the widening when M must forward + // regardless (an isolated, uncovered near neighbour) -- waiting cannot change + // that outcome, so forward at the base delay. if (effectiveFloodSuppressC() > 0 && packet->isRouteFlood() && packet->getSNR() >= effectiveFloodSuppressSnrHi()) { - delay *= (1 + _prefs.flood_suppress_delay_x); + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + FloodSuppressionEntry* e = _flood_supp.find(hash, millis()); + if (!(e && e->must_cover_self)) { + delay *= (1 + _prefs.flood_suppress_delay_x); + } } return delay; } @@ -855,11 +1027,18 @@ void MyMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32 const uint8_t *app_data, size_t app_data_len) { mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); // chain to super impl - // if this a zero hop advert (and not via 'Share'), add it to neighbours + // if this a zero hop advert (and not via 'Share'), classify the originator if (packet->getPathHashCount() == 0 && !isShare(packet)) { AdvertDataParser parser(app_data, app_data_len); - if (parser.isValid() && parser.getType() == ADV_TYPE_REPEATER) { // just keep neigbouring Repeaters - putNeighbour(id, timestamp, packet->getSNR()); + if (parser.isValid()) { + if (parser.getType() == ADV_TYPE_REPEATER) { // just keep neighbouring Repeaters + putNeighbour(id, timestamp, packet->getSNR()); + uint8_t h1; id.copyHashTo(&h1, 1); + removeAttachedClient(h1); // reconciled: this node is a repeater, not a client + } else { // CHAT/ROOM/SENSOR/... -> a directly-attached leaf client + uint8_t p[4]; id.copyHashTo(p, 4); // advert carries the full identity -> 4-byte prefix + addOrRefreshAttachedClient(p, 4, getRTCClock()->getCurrentTime()); + } } } } @@ -1397,6 +1576,77 @@ void MyMesh::formatFloodSuppressRatioReply(char *reply) { StatsFormatHelper::formatFloodSuppressRatio(reply, _fs_suppressed, _fs_seen); } +// `clients` reply: one line per attached leaf client ":s" -- the hash is +// the learned identity prefix (8-hex when seeded from an advert, 2-hex when seeded +// only from a message src_hash), `:` age in seconds + `s`. Newline-separated, +// "-none-" if empty. Byte-minimal -- this text travels over LoRa as the REQ->RESPONSE +// payload. Mirrors formatNeighborsReply (same 134-byte guard). +void MyMesh::formatClientsReply(char *reply) { + char *dp = reply; + uint32_t now = getRTCClock()->getCurrentTime(); + for (int i = 0; i < MAX_ATTACHED_CLIENTS && dp - reply < 134; i++) { + if (!_attached[i].active) continue; + if (dp != reply) *dp++ = '\n'; + char hex[9]; + mesh::Utils::toHex(hex, _attached[i].prefix, _attached[i].prefix_len); + uint32_t secs = now - _attached[i].last_seen; + sprintf(dp, "%s:%us", hex, (unsigned)secs); + while (*dp) dp++; + } + if (dp == reply) strcpy(reply, "-none-"); +} + +// `reach ` reply: directed reach edges of one NEAR repeater, as two lines: +// line 1 '<' + reached-by (incoming: near neighbours that reach this node) +// line 2 '>' + reaches (outgoing: near neighbours this node reaches) +// Endpoints are 4-byte/8-hex prefixes resolved from the neighbour table (so they +// cross-reference `neighbors`); '-' marks an empty list. Byte-minimal (LoRa). +// Status words for the non-near cases: notnear / unknown / ambig. +void MyMesh::formatReachReply(char *reply, const uint8_t* hash, uint8_t hash_len) { +#if MAX_NEIGHBOURS + uint32_t now = getRTCClock()->getCurrentTime(); + int8_t me = -1; int near_matches = 0, known_matches = 0; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp == 0) continue; + if (neighbours[i].id.isHashMatch(hash, hash_len)) { + known_matches++; + if (isNearNeighbour(i, now)) { near_matches++; me = i; } + } + } + if (near_matches == 0) { strcpy(reply, known_matches == 0 ? "unknown" : "notnear"); return; } + if (near_matches > 1) { strcpy(reply, "ambig"); return; } + + uint8_t hs = PATH_HASH_SIZE; + char *dp = reply; + *dp++ = '<'; // line 1: reached-by (j -> me) + int n = 0; + for (int j = 0; j < MAX_NEIGHBOURS; j++) { + if (j == me || !isNearNeighbour(j, now) || !nearReaches(j, me, hs, now)) continue; + if (dp - reply > 138) { strcpy(dp, "..."); dp += 3; break; } // overflow guard + if (n > 0) *dp++ = ','; + char hex[9]; mesh::Utils::toHex(hex, neighbours[j].id.pub_key, 4); + for (const char *s = hex; *s; ) *dp++ = *s++; + n++; + } + if (n == 0) *dp++ = '-'; + *dp++ = '\n'; + *dp++ = '>'; // line 2: reaches (me -> j) + n = 0; + for (int j = 0; j < MAX_NEIGHBOURS; j++) { + if (j == me || !isNearNeighbour(j, now) || !nearReaches(me, j, hs, now)) continue; + if (dp - reply > 150) { strcpy(dp, "..."); dp += 3; break; } + if (n > 0) *dp++ = ','; + char hex[9]; mesh::Utils::toHex(hex, neighbours[j].id.pub_key, 4); + for (const char *s = hex; *s; ) *dp++ = *s++; + n++; + } + if (n == 0) *dp++ = '-'; + *dp = 0; +#else + strcpy(reply, "unknown"); +#endif +} + void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); @@ -1517,6 +1767,8 @@ void MyMesh::loop() { mesh::Mesh::loop(); _flood_supp.purge(millis()); // evict stale flood-suppression entries + _nbr_links.purge(millis()); // evict stale inter-neighbour reach edges (~1 day TTL) + purgeAttachedClients(getRTCClock()->getCurrentTime()); // evict stale attached-client entries (~24h) if (_prefs.flood_suppress && millisHasNowPassed(_fs_next_recompute_ms)) { updateAdaptiveFloodParams(); // derive _fs_eff_c/_fs_eff_hi from neighbour table diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 93894088aa..a35667fcec 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -62,6 +63,13 @@ struct RepeaterStats { #define MAX_CLIENTS 32 #endif +#ifndef MAX_ATTACHED_CLIENTS + #define MAX_ATTACHED_CLIENTS 16 +#endif +#ifndef ATTACHED_CLIENT_FRESH_S + #define ATTACHED_CLIENT_FRESH_S (24UL * 3600UL) // ~24h -- attached leaf clients are stable +#endif + struct NeighbourInfo { mesh::Identity id; uint32_t advert_timestamp; @@ -69,6 +77,17 @@ struct NeighbourInfo { int8_t snr; // multiplied by 4, user should divide to get float value }; +// A leaf CLIENT (companion/sensor/room-server) directly attached to this repeater +// (M is its first hop). Tracked so suppression does not starve attached clients of +// floods they need. Match key is the 1-byte path hash (prefix[0]); `prefix` carries +// up to 4 identity bytes for display (4 from an advert, 1 from a message src_hash). +struct AttachedClient { + uint8_t prefix[4]; // identity prefix learned (match key = prefix[0]) + uint8_t prefix_len; // bytes actually known: 4 (advert) or 1 (msg src_hash) + uint32_t last_seen; // RTC seconds + bool active; +}; + #ifndef FIRMWARE_BUILD_DATE #define FIRMWARE_BUILD_DATE "6 Jun 2026" #endif @@ -101,6 +120,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { TransportKey default_scope; RateLimiter discover_limiter, anon_limiter; FloodSuppressionTable _flood_supp; // redundancy-aware FLOOD suppression state + NeighbourLinkTable _nbr_links; // inter-near-neighbour reach edges (coverage inference) + AttachedClient _attached[MAX_ATTACHED_CLIENTS] = {}; // directly-attached leaf clients (client-aware suppression) // Near-neighbour freshness window for the coverage test and the adaptive-density // count, 60 min static const uint32_t NEIGHBOUR_FRESH_S = 3600; @@ -137,6 +158,13 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { bool isNearNeighbour(int i, uint32_t now) const; // fresh (<=NEIGHBOUR_FRESH_S) and SNR>=snr_lo int8_t findNearNeighbour(const uint8_t* h, uint8_t hs, uint32_t now) const; // index of near neighbour matching hash, else -1 bool allNearNeighboursCovered(const FloodSuppressionEntry& e, uint32_t now) const; // >=1 near && every near neighbour in e.covered + bool nearReaches(int from_i, int to_j, uint8_t hs, uint32_t now) const; // fresh DIRECTED reach edge: neighbours[from_i] reaches neighbours[to_j] (to_j heard from_i) + bool clientProtectionAllowsSuppress(const mesh::Packet* pkt, uint32_t now) const; // 3-tier client-aware gate (always active) + void addOrRefreshAttachedClient(const uint8_t* prefix, uint8_t plen, uint32_t now); // seed/refresh attached leaf client (prefix[0] is the match key) + bool attachedClientMatches(uint8_t hash1, uint32_t now) const; // is hash1 a fresh attached client? (hash1 vs prefix[0]) + void removeAttachedClient(uint8_t hash1); // reconcile: node turned out to be a repeater (hash1 vs prefix[0]) + void purgeAttachedClients(uint32_t now); // evict stale clients (~24h) + bool isKnownRepeaterHash1(uint8_t hash1) const; // does this 1-byte hash match a known repeater neighbour? 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 @@ -234,6 +262,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void dumpLogFile() override; void setTxPower(int8_t power_dbm) override; void formatNeighborsReply(char *reply) override; + void formatClientsReply(char *reply) override; // list attached leaf clients + void formatReachReply(char *reply, const uint8_t* hash, uint8_t hash_len) override; // reach edges of a near repeater void removeNeighbor(const uint8_t* pubkey, int key_len) override; void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 08ba26afc8..d1203c24bd 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -289,6 +289,23 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "ERR: bad pubkey"); } + } else if (memcmp(command, "clients", 7) == 0) { + _callbacks->formatClientsReply(reply); + } else if (memcmp(command, "reach", 5) == 0) { + const char* hex = &command[5]; + while (*hex == ' ') hex++; // skip spaces after the verb + if (*hex == 0) { + strcpy(reply, "reach HASH"); + } else { + int hex_len = min((int)strlen(hex), MAX_HASH_SIZE * 2); + int hash_len = hex_len / 2; + uint8_t hash[MAX_HASH_SIZE]; + if (hash_len > 0 && mesh::Utils::fromHex(hash, hash_len, hex)) { + _callbacks->formatReachReply(reply, hash, hash_len); + } else { + strcpy(reply, "ERR: bad hash"); + } + } } else if (memcmp(command, "tempradio ", 10) == 0) { strcpy(tmp, &command[10]); const char *parts[5]; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 37cff5aae5..8463e8402a 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -98,6 +98,11 @@ class CommonCLICallbacks { // Appends the suppression-ratio suffix (", suppressed N/M (P%)") to the flood.suppress get-reply. // Default is a no-op so non-repeater roles keep the plain "> on/off" reply. virtual void formatFloodSuppressRatioReply(char *reply) { } + // List directly-attached leaf clients (client-aware suppression). Default no-op. + virtual void formatClientsReply(char *reply) { } + // Reach edges of one near repeater (directed inter-neighbour graph), looked up by + // a hash prefix. Default no-op. hash_len is the number of prefix bytes parsed. + virtual void formatReachReply(char *reply, const uint8_t* hash, uint8_t hash_len) { } 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 index 4c6f968509..214e74add2 100644 --- a/src/helpers/FloodSuppression.h +++ b/src/helpers/FloodSuppression.h @@ -11,17 +11,20 @@ // and our own scheduled outbound re-broadcast all share ONE hash identity. // // M suppresses its rebroadcast of flood F if every NEAR neighbour is already -// known to have F. "Known to have F" = the neighbour appears on the path of -// some forward of F that M overheard -- every hop on a decoded path forwarded F, -// so it has it (certain evidence, no SNR/inference). +// known to have F. A neighbour is "known to have F" if either (a) it forwarded +// F (it appears on the path of an overheard forward -- certain), or (b) it was +// REACHED by a forwarder: some near forwarder fi has a fresh DIRECTED reach edge +// fi->N (see NeighbourLinkTable), so N very likely heard fi's forward (inferred). +// Edges are directed because RF links can be asymmetric. Coverage accumulates +// across multiple overheard forwards, so the combined reach of several forwarders +// can cover all of M's neighbours. // This is sound for directional/co-located antennas -- a downstream neighbour -// that has not received F is on no path M decodes, so it stays uncovered and M -// forwards. +// that no forwarder reaches stays uncovered, so M forwards (never deafens). // -// Per entry we keep a small dedup set of the near-neighbour indices seen on -// overheard forwards' paths (indices into MyMesh::neighbours[]). Suppression -// fires when that set spans all CURRENT near neighbours (checked from MyMesh, -// which owns the neighbour table and the "near" definition: fresh + SNR>=snr_lo). +// Per entry we keep a small dedup set of the near-neighbour indices known to be +// covered (indices into MyMesh::neighbours[]). Suppression fires when that set +// spans all CURRENT near neighbours (checked from MyMesh, which owns the +// neighbour table, the reach graph and the "near" definition: fresh + SNR>=snr_lo). // // 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. @@ -47,6 +50,8 @@ struct FloodSuppressionEntry { uint8_t covered_count; uint32_t first_seen_ms; // for TTL eviction bool suppressed; // our rebroadcast already cancelled/suppressed + bool must_cover_self; // an isolated (no near-edges) near neighbour didn't forward F: + // only M's own TX can cover it -> M must forward, no point widening bool active; // Record a near-neighbour index as covered (dedup). Returns true if newly added. @@ -102,6 +107,7 @@ class FloodSuppressionTable { e->covered_count = 0; e->first_seen_ms = now; e->suppressed = false; + e->must_cover_self = false; e->active = true; if (is_new) *is_new = true; return e; diff --git a/src/helpers/NeighbourLinkTable.h b/src/helpers/NeighbourLinkTable.h new file mode 100644 index 0000000000..afe6f4dcb1 --- /dev/null +++ b/src/helpers/NeighbourLinkTable.h @@ -0,0 +1,106 @@ +#pragma once + +#include // MAX_HASH_SIZE +#include + +// --- Inter-neighbour reach graph for coverage-test flood suppression -------- +// +// Records DIRECTED "can hear" edges among the NEAR neighbours of this repeater. +// An edge src->dst means "dst can hear src's transmissions" (src REACHES dst). +// RF links are frequently ASYMMETRIC (A hears B but not vice versa), so direction +// matters: inferring "N heard fi" from an observation that only "fi heard N" +// would mark N falsely covered -> M would suppress and starve N (the deafening +// this feature exists to prevent). Edges are therefore directed and never flipped. +// +// Direction comes for free from the flood path: path hops are in forwarding order +// [R1..Rn], so a consecutive pair (X, Y) means Y forwarded right after X, i.e. +// Y heard X -> X reaches Y -> directed edge X->Y is recorded. +// +// simple_repeater uses these edges to INFER coverage: if a near neighbour fi +// forwarded flood F, then every near neighbour N with a fresh edge fi->N very +// likely also received F (N heard fi's forward). Coverage is 1-hop, NOT +// transitive. +// +// Edges are keyed by PATH HASH (the public-key prefix), NOT by neighbour-table +// index, so they survive LRU reordering of MyMesh::neighbours[]. A stored +// hash_size lets edges coexist across deployments of different hash widths +// (VER_1 -> 1 byte); only that many bytes are ever compared. Small ring with +// TTL eviction (~1 day -- repeater topology is stable); swept from loop(). + +#ifndef NEIGHBOUR_LINK_TABLE_SIZE + #define NEIGHBOUR_LINK_TABLE_SIZE 128 +#endif + +#ifndef NEIGHBOUR_LINK_TTL_MILLIS + #define NEIGHBOUR_LINK_TTL_MILLIS (24UL * 60UL * 60UL * 1000UL) // ~1 day +#endif + +class NeighbourLinkTable { + struct Link { + uint8_t src[MAX_HASH_SIZE]; // reacher (the earlier hop on the recording path) + uint8_t dst[MAX_HASH_SIZE]; // reached (the later hop -- it heard src) + uint8_t hash_size; + uint32_t last_seen_ms; + bool active; + }; + + Link _links[NEIGHBOUR_LINK_TABLE_SIZE]; + int _next_idx; + + static bool _same(const uint8_t* x, const uint8_t* y, uint8_t hs) { + return memcmp(x, y, hs) == 0; + } + +public: + NeighbourLinkTable() { clear(); } + + void clear() { + memset(_links, 0, sizeof(_links)); + _next_idx = 0; + } + + // Record/refresh a DIRECTED edge src->dst (hs-byte path hashes). src reaches dst. + // A bidirectional link occupies two separate entries (src->dst and dst->src), + // each observed and refreshed independently -- this preserves asymmetry. + void addEdge(const uint8_t* src, const uint8_t* dst, uint8_t hs, uint32_t now) { + for (int i = 0; i < NEIGHBOUR_LINK_TABLE_SIZE; i++) { + Link& l = _links[i]; + if (l.active && l.hash_size == hs && _same(l.src, src, hs) && _same(l.dst, dst, hs)) { + l.last_seen_ms = now; // refresh existing directed edge + return; + } + } + Link& l = _links[_next_idx]; // LRU ring overwrite + _next_idx = (_next_idx + 1) % NEIGHBOUR_LINK_TABLE_SIZE; + memcpy(l.src, src, hs); // only hs bytes are meaningful + memcpy(l.dst, dst, hs); + l.hash_size = hs; + l.last_seen_ms = now; + l.active = true; + } + + // Is there a FRESH directed edge src->dst (hs-byte hashes)? (i.e. dst can hear src) + bool hasEdge(const uint8_t* src, const uint8_t* dst, uint8_t hs, uint32_t now) const { + for (int i = 0; i < NEIGHBOUR_LINK_TABLE_SIZE; i++) { + const Link& l = _links[i]; + if (l.active && l.hash_size == hs && !_expired(l, now) && + _same(l.src, src, hs) && _same(l.dst, dst, hs)) { + return true; + } + } + return false; + } + + // Evict expired edges. Call from loop(). + void purge(uint32_t now) { + for (int i = 0; i < NEIGHBOUR_LINK_TABLE_SIZE; i++) { + if (_links[i].active && _expired(_links[i], now)) _links[i].active = false; + } + } + +private: + static bool _expired(const Link& l, uint32_t now) { + // uint32 subtraction is wrap-safe for any ttl well below the wrap period. + return (uint32_t)(now - l.last_seen_ms) > NEIGHBOUR_LINK_TTL_MILLIS; + } +}; From aa30b3b78ae8f04da14afd4a43249cfeac08ce81 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 31 Jul 2026 08:04:38 +0200 Subject: [PATCH 06/15] Add active TRACE coverage measurement and related enhancements - Introduced active TRACE coverage measurement in MyMesh to improve neighbour coverage accuracy. - Added configuration options for TRACE transmission power and coverage measurement parameters. - Enhanced packet handling to support new TRACE flags, allowing results to be returned to the initiator. - Updated NeighbourLinkTable to support width-tolerant edge lookups and improved TTL for neighbour links. - Added new CLI commands for managing TRACE settings and retrieving near neighbour information. - Improved documentation and comments for clarity on the new features and their intended use. --- examples/simple_repeater/MyMesh.cpp | 377 +++++++++++++++++++++++----- examples/simple_repeater/MyMesh.h | 36 ++- src/Mesh.cpp | 14 +- src/Packet.h | 8 + src/helpers/CommonCLI.cpp | 15 +- src/helpers/CommonCLI.h | 4 + src/helpers/NeighbourLinkTable.h | 41 ++- 7 files changed, 420 insertions(+), 75 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 66e936827b..8f6ea16386 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -141,36 +141,73 @@ int8_t MyMesh::findNearNeighbour(const uint8_t* h, uint8_t hs, uint32_t now) con return -1; } -// True iff at least one near neighbour exists AND every CURRENT near neighbour -// is recorded as covered in e. Iterating current near neighbours makes this -// robust to a neighbour going stale mid-flood (a stale one simply isn't checked). -bool MyMesh::allNearNeighboursCovered(const FloodSuppressionEntry& e, uint32_t now) const { +// Fill out[] with up to max_n near-neighbour INDICES, strongest SNR first (stable on +// ties by index). Near = isNearNeighbour (fresh + SNR>=snr_lo). Coverage is only +// guaranteed for this capped strongest set (see NEAR_NEIGHBOUR_COVERAGE_CAP): the +// adaptive C threshold still counts ALL fresh neighbours for density, so it is +// unaffected. O(MAX_NEIGHBOURS * max_n). +uint8_t MyMesh::topNearNeighbours(int8_t out[], uint8_t max_n, uint32_t now) const { #if MAX_NEIGHBOURS - bool any = false; + uint8_t n = 0; for (int i = 0; i < MAX_NEIGHBOURS; i++) { if (!isNearNeighbour(i, now)) continue; - any = true; - if (!e.covers((uint8_t)i)) return false; + int8_t s_i = neighbours[i].snr; + uint8_t pos = n; + while (pos > 0 && neighbours[out[pos - 1]].snr < s_i) { // insertion sort, desc + if (pos < max_n) out[pos] = out[pos - 1]; + pos--; + } + if (pos < max_n) out[pos] = (int8_t)i; + if (n < max_n) n++; + } + return n; +#else + return 0; +#endif +} + +// Index (into neighbours[]) of a top-N peer whose hash matches, else -1. +int8_t MyMesh::findInTopNear(const uint8_t* h, uint8_t hs, const int8_t* top, uint8_t top_n) const { +#if MAX_NEIGHBOURS + for (uint8_t k = 0; k < top_n; k++) { + if (neighbours[top[k]].id.isHashMatch(h, hs)) return top[k]; } - return any; +#else + (void)h; (void)hs; (void)top; (void)top_n; +#endif + return -1; +} + +// True iff at least one top-N near neighbour exists AND every CURRENT top-N near +// neighbour is recorded as covered in e. Only the capped strongest set is checked: +// a rank-(cap+1) neighbour is not owed coverage (deliberate trade-off). +bool MyMesh::allNearNeighboursCovered(const FloodSuppressionEntry& e, uint32_t now) const { +#if MAX_NEIGHBOURS + int8_t top[NEAR_NEIGHBOUR_COVERAGE_CAP]; + uint8_t n = topNearNeighbours(top, NEAR_NEIGHBOUR_COVERAGE_CAP, now); + for (uint8_t k = 0; k < n; k++) { + if (!e.covers((uint8_t)top[k])) return false; + } + return n > 0; #else return false; #endif } // Is there a FRESH DIRECTED reach edge from neighbours[from_i] to neighbours[to_j]? -// (to_j heard from_i's transmissions.) Edges are recorded from consecutive path -// hops in forwarding order (later heard earlier -> earlier reaches later), so this -// is DIRECTIONAL: RF links can be asymmetric, and we must not infer "to_j heard -// from_i" from a reverse observation. Used to infer coverage: a forwarder fi -// covers its 1-hop graph neighbours (fi reaches N). Keyed by path hash, so robust -// to LRU reordering of neighbours[]. hs is the path-hash width of the current flood. -bool MyMesh::nearReaches(int from_i, int to_j, uint8_t hs, uint32_t now) const { +// (to_j heard from_i's transmissions.) DIRECTIONAL: RF links can be asymmetric, and +// we must not infer "to_j heard from_i" from a reverse observation. Used to infer +// coverage: a forwarder fi covers its 1-hop graph neighbours (fi reaches N). Keyed +// by path hash, so robust to LRU reordering of neighbours[]. hs is the hash width +// (the canonical TRACE_MEAS_HASH_SIZE for measured edges). NOTE: freshness is checked +// in MILLIS (the table's TTL is ms-based and addEdge/purge use millis()), NOT in the +// RTC seconds the callers use for near-neighbour freshness. +bool MyMesh::nearReaches(int from_i, int to_j, uint8_t hs) const { #if MAX_NEIGHBOURS uint8_t hfrom[MAX_HASH_SIZE], hto[MAX_HASH_SIZE]; neighbours[from_i].id.copyHashTo(hfrom, hs); neighbours[to_j].id.copyHashTo(hto, hs); - return _nbr_links.hasEdge(hfrom, hto, hs, now); + return _nbr_links.hasEdge(hfrom, hto, hs, millis()); #else return false; #endif @@ -195,6 +232,149 @@ bool MyMesh::clientProtectionAllowsSuppress(const mesh::Packet* pkt, uint32_t no return false; // Tier B } +// --- Active TRACE coverage measurement ---------------------------------------- +// Send one round-trip coverage TRACE: visit-list [a, b, self] with 2-byte hashes. +// It walks self->a->b->self; the SNR measured at b of a's forward (path_snrs[1]) +// tells whether b can hear a (a reaches b). TRACE_FLAG_TERMINATE_AT_LAST makes it +// deliver onTraceRecv back HERE (at self) instead of at a bystander. Returns the +// trace tag (0 if the packet pool was full). +uint32_t MyMesh::sendCoverageTrace(const mesh::Identity& a, const mesh::Identity& b) { + const uint8_t psz = TRACE_MEAS_HASH_SIZE; // 2 bytes + uint8_t visit[3 * MAX_HASH_SIZE]; + uint8_t n = 0; + a.copyHashTo(&visit[n], psz); n += psz; // hop 0: a (reacher) + b.copyHashTo(&visit[n], psz); n += psz; // hop 1: b (reached) + self_id.copyHashTo(&visit[n], psz); n += psz; // hop 2: self (terminator -> result returns here) + + uint8_t path_sz_code = 1; // 1<<1 == 2 bytes + uint32_t tag = _trace_tag_next++; + uint8_t flags = path_sz_code | TRACE_FLAG_TERMINATE_AT_LAST; + mesh::Packet* pkt = createTrace(tag, 0, flags); + if (!pkt) return 0; + sendDirect(pkt, visit, n); // appends visit-list to payload, pri 5 + return tag; +} + +// A coverage TRACE we initiated has returned. Record the measured directed edge +// a->b (path_hashes[0..psz)=a, [psz..2psz)=b) iff the link is strong enough (SNR at +// b of a's forward >= snr_lo), then retire the pending entry. Only our own [a,b,self] +// traces reach us here: every node terminates at itself, so onTraceRecv fires at the +// initiator, never at a relay or bystander. +void MyMesh::onTraceRecv(mesh::Packet* /*packet*/, uint32_t tag, uint32_t /*auth_code*/, uint8_t flags, + const uint8_t* path_snrs, const uint8_t* path_hashes, uint8_t path_len) { +#if MAX_NEIGHBOURS + uint8_t path_sz = flags & 0x03; + uint8_t entry_sz = 1 << path_sz; + uint8_t n_hops = path_len >> path_sz; // == number of SNRs collected + if (n_hops == 3 && entry_sz == TRACE_MEAS_HASH_SIZE) { + _meas_returned++; // a coverage TRACE round-trip completed back here + int8_t snr_x4 = (int8_t)path_snrs[1]; // SNR at b of a's forward = a reaches b + if (snr_x4 >= (int8_t)(_prefs.flood_suppress_snr_lo * 4)) { + _nbr_links.addEdge(path_hashes, path_hashes + entry_sz, entry_sz, millis()); + _meas_edge++; // ...and the a->b link was strong enough to record + } + } + for (uint8_t i = 0; i < TRACE_PENDING_MAX; i++) { // retire the matching pending entry (success) + if (_trace_pending[i].active && _trace_pending[i].tag == tag) { _trace_pending[i].active = false; break; } + } +#else + (void)tag; (void)flags; (void)path_snrs; (void)path_hashes; (void)path_len; +#endif +} + +// Cadenced coverage measurement. (1) sweep in-flight traces for timeout + single +// retry; (2) every ~60s (~10-15s in sim) find top-N coverage peers whose directed +// edges are missing/expired and probe them with [a,b,self] traces. Bounded by +// TRACE_PENDING_MAX in flight; jittered so simultaneously-booted nodes don't all +// probe at once. TX power is lowered for the burst window (near links are strong) +// and restored afterwards. +void MyMesh::stepCoverageMeasurement() { +#if MAX_NEIGHBOURS + uint32_t now = millis(); + + // restore normal TX power once the burst window has elapsed + if (_trace_tx_revert_at && millisHasNowPassed(_trace_tx_revert_at)) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + _trace_tx_revert_at = 0; + } + + // (1) timeout / single-retry sweep + for (uint8_t i = 0; i < TRACE_PENDING_MAX; i++) { + if (!_trace_pending[i].active) continue; + if ((uint32_t)(now - _trace_pending[i].sent_ms) <= TRACE_MEAS_TIMEOUT_MS) continue; + if (_trace_pending[i].retries < 1) { + _trace_pending[i].retries = 1; + int8_t ia = findNearNeighbour(_trace_pending[i].a, TRACE_MEAS_HASH_SIZE, getRTCClock()->getCurrentTime()); + int8_t ib = findNearNeighbour(_trace_pending[i].b, TRACE_MEAS_HASH_SIZE, getRTCClock()->getCurrentTime()); + uint32_t tag = (ia >= 0 && ib >= 0) ? sendCoverageTrace(neighbours[ia].id, neighbours[ib].id) : 0; + if (tag) { _trace_pending[i].tag = tag; _trace_pending[i].sent_ms = now; _meas_sent++; } + else _trace_pending[i].active = false; // pair no longer resolvable -> drop + } else { + _trace_pending[i].active = false; // second miss -> link does not exist (no edge) + _meas_timeout++; + } + } + + if (!_prefs.flood_suppress) return; + + // (2) cadenced diff/expiry + send + if (!millisHasNowPassed(_next_meas_check_ms)) return; +#if SIM_BUILD + _next_meas_check_ms = futureMillis((int)getRNG()->nextInt(10000, 15000)); +#else + _next_meas_check_ms = futureMillis(60000); +#endif + if (_meas_jitter_until == 0) { // first ever: spread this node's first burst + _meas_jitter_until = futureMillis((int)getRNG()->nextInt(500, 5000)); // (desyncs simultaneously-booted nodes) + return; + } + if (!millisHasNowPassed(_meas_jitter_until)) return; // inter-burst backoff (de-conflicts simultaneous nodes) + + int8_t top[NEAR_NEIGHBOUR_COVERAGE_CAP]; + uint8_t top_n = topNearNeighbours(top, NEAR_NEIGHBOUR_COVERAGE_CAP, getRTCClock()->getCurrentTime()); + if (top_n < 2) return; + + uint8_t ha[TRACE_MEAS_HASH_SIZE], hb[TRACE_MEAS_HASH_SIZE]; + bool burst_started = false, stop = false; + for (uint8_t x = 0; x < top_n && !stop; x++) { + neighbours[top[x]].id.copyHashTo(ha, TRACE_MEAS_HASH_SIZE); + for (uint8_t y = 0; y < top_n && !stop; y++) { + if (x == y) continue; + neighbours[top[y]].id.copyHashTo(hb, TRACE_MEAS_HASH_SIZE); + if (_nbr_links.hasEdge(ha, hb, TRACE_MEAS_HASH_SIZE, now)) continue; // measured & fresh + bool inflight = false; // already probing this direction? + for (uint8_t i = 0; i < TRACE_PENDING_MAX && !inflight; i++) + if (_trace_pending[i].active && memcmp(_trace_pending[i].a, ha, 2) == 0 && memcmp(_trace_pending[i].b, hb, 2) == 0) inflight = true; + if (inflight) continue; + int8_t slot = -1; // free pending slot? + for (uint8_t i = 0; i < TRACE_PENDING_MAX; i++) if (!_trace_pending[i].active) { slot = (int8_t)i; break; } + if (slot < 0) { stop = true; break; } + if (!burst_started) { + burst_started = true; + if (_prefs.trace_tx_power_dbm != _prefs.tx_power_dbm) { + radio_driver.setTxPower(_prefs.trace_tx_power_dbm); + _trace_tx_revert_at = futureMillis(TRACE_TX_POWER_RESTORE_MS); + } + } + uint32_t tag = sendCoverageTrace(neighbours[top[x]].id, neighbours[top[y]].id); + if (!tag) { stop = true; break; } // pool full -> wait + _meas_sent++; + _trace_pending[slot].active = true; + _trace_pending[slot].retries = 0; + _trace_pending[slot].tag = tag; + _trace_pending[slot].sent_ms = now; + memcpy(_trace_pending[slot].a, ha, 2); + memcpy(_trace_pending[slot].b, hb, 2); + stop = true; break; // ONE trace per cadence tick + // (a pair's two directions are ~180ms*3hops round-trips; sending them + // back-to-back makes the 2nd collide with the 1st's return relay. Spacing + // to one-per-tick lets each round trip complete cleanly.) + } + } + if (burst_started) _meas_jitter_until = futureMillis((int)getRNG()->nextInt(500, 3000)); +#endif +} + // Seed/refresh a directly-attached leaf client (M is its first hop). Small LRU ring. // `prefix[0]` is the 1-byte match key; `plen` is how many identity bytes are known // (4 from an advert, 1 from a message src_hash). On refresh, upgrade the stored @@ -783,63 +963,57 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { if (is_new) _fs_seen++; // distinct flood heard -> candidate for our rebroadcast #if MAX_NEIGHBOURS uint32_t now = getRTCClock()->getCurrentTime(); // seconds (RTC), for near-neighbour freshness - uint32_t now_ms = millis(); // milliseconds, for edge TTL uint8_t hs = pkt->getPathHashSize(); uint8_t count = pkt->getPathHashCount(); const uint8_t* p = pkt->path; - // (a) REACH-GRAPH: path hops are in forwarding order, so a consecutive near - // pair (prev_near, idx) means idx heard prev_near -> prev_near REACHES idx - // (directed; RF links can be asymmetric). Record that directed edge. Also - // flag which near neighbours are on THIS path (they forwarded F => have it). + // The reach graph (_nbr_links) is now populated by ACTIVE TRACE measurement + // (stepCoverageMeasurement), NOT inferred from this flood's path. So here we + // only record which COVERAGE peers (M's top-N strongest near neighbours) + // forwarded F, then read the measured graph to infer who else was reached. + int8_t top[NEAR_NEIGHBOUR_COVERAGE_CAP]; + uint8_t top_n = topNearNeighbours(top, NEAR_NEIGHBOUR_COVERAGE_CAP, now); + bool on_path[MAX_NEIGHBOURS] = { false }; - int8_t prev_near = -1; for (uint8_t k = 0; k < count; k++) { - int8_t idx = findNearNeighbour(p, hs, now); - if (idx >= 0) { - on_path[idx] = true; - if (prev_near >= 0) { - // prev_near (earlier) reaches idx (later): idx heard prev_near's forward. - _nbr_links.addEdge(neighbours[prev_near].id.pub_key, neighbours[idx].id.pub_key, hs, now_ms); - } - prev_near = idx; - } else { - prev_near = -1; // a non-near hop breaks adjacency - } + int8_t idx = findInTopNear(p, hs, top, top_n); + if (idx >= 0) on_path[idx] = true; // this coverage peer forwarded F => has F p += hs; } - // (b) COVERAGE: each near forwarder covers itself + the near neighbours it - // REACHES (directed: N heard fi's forward => N has F, inferred). - for (int i = 0; i < MAX_NEIGHBOURS; i++) { - if (!on_path[i]) continue; // i must be a near forwarder on this path - e->addCovered((uint8_t)i); // i forwarded F => i has F (certain) - for (int j = 0; j < MAX_NEIGHBOURS; j++) { // i reaches its fresh graph neighbours - if (j == i || on_path[j] || !isNearNeighbour(j, now)) continue; - if (nearReaches(i, j, hs, now)) e->addCovered((uint8_t)j); // i reaches j (j heard i) + // (b) COVERAGE: each coverage-peer forwarder covers itself + the coverage peers + // it REACHES via a fresh measured edge (N heard fi's forward => N has F). + // Graph edges are measured at the canonical TRACE hash width. + for (uint8_t a = 0; a < top_n; a++) { + int i = top[a]; + if (!on_path[i]) continue; // i must be a forwarder of F + e->addCovered((uint8_t)i); // i forwarded F => i has F (certain) + for (uint8_t b = 0; b < top_n; b++) { + int j = top[b]; + if (j == i || on_path[j]) continue; // j already has F + if (nearReaches(i, j, TRACE_MEAS_HASH_SIZE)) e->addCovered((uint8_t)j); } } - // (c) ISOLATED-NEIGHBOUR FAST-FORWARD: a near neighbour that NO near - // forwarder reaches (in-degree 0) can ONLY be covered by M's own TX. If - // one exists that did NOT forward F, M must forward (it is uncovered) -> - // suppression is impossible and waiting cannot change that, so skip the - // window widening (getRetransmitDelay reads this flag). Also the natural - // cold-start behaviour (sparse graph). + // (c) ISOLATED-PEER FAST-FORWARD: a coverage peer that NO other coverage peer + // reaches (in-degree 0) and did NOT forward F can ONLY be covered by M's + // own TX -> M must forward. Also the cold-start behaviour (graph empty + // before any TRACE completes). getRetransmitDelay reads must_cover_self. e->must_cover_self = false; - for (int i = 0; i < MAX_NEIGHBOURS && !e->must_cover_self; i++) { - if (!isNearNeighbour(i, now) || on_path[i]) continue; + for (uint8_t a = 0; a < top_n && !e->must_cover_self; a++) { + int i = top[a]; + if (on_path[i]) continue; // i forwarded F -> covered bool reachable = false; - for (int j = 0; j < MAX_NEIGHBOURS; j++) { // does any near j reach i? - if (j == i || !isNearNeighbour(j, now)) continue; - if (nearReaches(j, i, hs, now)) { reachable = true; break; } + for (uint8_t b = 0; b < top_n; b++) { // does any coverage peer j reach i? + int j = top[b]; + if (j == i) continue; + if (nearReaches(j, i, TRACE_MEAS_HASH_SIZE)) { reachable = true; break; } } if (!reachable) e->must_cover_self = true; } - // (d) suppress iff no isolated-uncovered neighbour, everyone covered, and - // client-protection allows it (3-tier, always active -- see - // clientProtectionAllowsSuppress). + // (d) suppress iff no isolated-uncovered peer, every coverage peer covered, and + // client-protection allows it (3-tier, always active). if (!e->must_cover_self && allNearNeighboursCovered(*e, now) && clientProtectionAllowsSuppress(pkt, now)) { e->suppressed = true; @@ -1306,12 +1480,21 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max = 64; _prefs.flood_max_unscoped = 64; _prefs.flood_max_advert = 8; +#if SIM_BUILD + // SIM ONLY: the simulator accelerates adverts to ~20s (see updateAdvertTimer). At the real + // default of 8 hops, every advert floods across the whole grid (9 TX/advert in multi_path), + // saturating the channel so almost no advert survives to seed neighbour tables. Neighbour + // discovery only needs zero-hop adverts (the originator's direct TX), so limiting advert + // propagation to 2 hops preserves discovery while cutting advert airtime ~4x. HW unchanged. + _prefs.flood_max_advert = 2; +#endif _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 + _prefs.trace_tx_power_dbm = 10; // TX power for coverage TRACE probes only (near links are strong; less disturbance) // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -1445,11 +1628,32 @@ void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) { } void MyMesh::updateAdvertTimer() { +#if SIM_BUILD + // SIMULATOR ONLY (hardware builds take the #else path unchanged). + // + // Two sim-specific reasons the real 2-minute, advert_interval-gated timer does not populate + // neighbour tables in the simulator: + // 1. The simulator boots every node at (near) the same instant and drives an ABSOLUTE + // firmware clock, so all nodes' adverts fire in the same ~1-2s window and collide at + // dense nodes (equal-SNR neighbours, no capture winner) -> ALL discarded. + // 2. The sim zeros _prefs.advert_interval via prefs-save validation (the 2-minute default + // fails the "manually configured" < 60-minute check) after the first advert cycle, which + // would halt adverts entirely under the real advert_interval-gated path. + // Fix: schedule UNCONDITIONALLY at an accelerated (~20s), per-node-randomised cadence. Real + // hardware desyncs naturally via independent clocks; this emulates that for observable + // coverage dynamics. Independent of advert_interval so the zeroing cannot stop it. + // ~60s cadence (avg): enough rounds to populate within ~420s while keeping advert airtime + // low in a dense grid. (Local adverts use sendZeroHop = 1 TX each, no forwarding; still, in a + // 9-node all-hears-all grid the sim's any-overlap/<6dB collision model is harsh, so a 20s + // cadence saturated the channel. ~60s is the sweet spot for multi_path.) + next_local_advert = futureMillis(getRNG()->nextInt(30000, 90000)); +#else if (_prefs.advert_interval > 0) { // schedule local advert timer next_local_advert = futureMillis(((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); } else { next_local_advert = 0; // stop the timer } +#endif } void MyMesh::updateFloodAdvertTimer() { @@ -1616,12 +1820,12 @@ void MyMesh::formatReachReply(char *reply, const uint8_t* hash, uint8_t hash_len if (near_matches == 0) { strcpy(reply, known_matches == 0 ? "unknown" : "notnear"); return; } if (near_matches > 1) { strcpy(reply, "ambig"); return; } - uint8_t hs = PATH_HASH_SIZE; + uint8_t hs = TRACE_MEAS_HASH_SIZE; // reach edges are measured at the TRACE hash width char *dp = reply; *dp++ = '<'; // line 1: reached-by (j -> me) int n = 0; for (int j = 0; j < MAX_NEIGHBOURS; j++) { - if (j == me || !isNearNeighbour(j, now) || !nearReaches(j, me, hs, now)) continue; + if (j == me || !isNearNeighbour(j, now) || !nearReaches(j, me, hs)) continue; if (dp - reply > 138) { strcpy(dp, "..."); dp += 3; break; } // overflow guard if (n > 0) *dp++ = ','; char hex[9]; mesh::Utils::toHex(hex, neighbours[j].id.pub_key, 4); @@ -1633,7 +1837,7 @@ void MyMesh::formatReachReply(char *reply, const uint8_t* hash, uint8_t hash_len *dp++ = '>'; // line 2: reaches (me -> j) n = 0; for (int j = 0; j < MAX_NEIGHBOURS; j++) { - if (j == me || !isNearNeighbour(j, now) || !nearReaches(me, j, hs, now)) continue; + if (j == me || !isNearNeighbour(j, now) || !nearReaches(me, j, hs)) continue; if (dp - reply > 150) { strcpy(dp, "..."); dp += 3; break; } if (n > 0) *dp++ = ','; char hex[9]; mesh::Utils::toHex(hex, neighbours[j].id.pub_key, 4); @@ -1647,6 +1851,59 @@ void MyMesh::formatReachReply(char *reply, const uint8_t* hash, uint8_t hash_len #endif } +// `near` reply: the near coverage peers (fresh + SNR>=snr_lo), strongest first -- the +// exact set the coverage test / TRACE measurement acts on. The header carries the active +// snr_lo threshold and the coverage cap, so the cutoff is visible. Entries beyond +// NEAR_NEIGHBOUR_COVERAGE_CAP are marked '~' (near but NOT owed coverage -- only the +// capped strongest set is guaranteed/TRACE-measured). HASH:secs_ago:snr mirrors +// formatNeighborsReply (snr is x4). Byte budget like formatNeighborsReply (~150 ceiling). +void MyMesh::formatNearReply(char *reply) { +#if MAX_NEIGHBOURS + char *dp = reply; + uint32_t now = getRTCClock()->getCurrentTime(); + + // collect near-neighbour indices, then insertion-sort by SNR desc (stable on ties) + int8_t idx[MAX_NEIGHBOURS]; + uint8_t n = 0; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (isNearNeighbour(i, now)) idx[n++] = (int8_t)i; + } + for (uint8_t a = 1; a < n; a++) { + int8_t v = idx[a]; int8_t vs = neighbours[v].snr; uint8_t b = a; + while (b > 0 && neighbours[idx[b - 1]].snr < vs) { idx[b] = idx[b - 1]; b--; } + idx[b] = v; + } + + sprintf(dp, "near snr_lo=%d cap=%d n=%u", (int)_prefs.flood_suppress_snr_lo, + (int)NEAR_NEIGHBOUR_COVERAGE_CAP, (unsigned)n); + while (*dp) dp++; + + // coverage-TRACE health: sent=attempts, ret=round-trips that came back, edge=links + // recorded (ret with SNR>=snr_lo), tmo=pairs that timed out twice (no link). If sent>0 + // but ret==0 the round trips never complete (loss/collisions); if ret>0 but edge==0 the + // measured inter-neighbour links are below snr_lo; if sent==0 no top-N>=2 window yet. + sprintf(dp, "\nmeas sent=%lu ret=%lu edge=%lu tmo=%lu", + (unsigned long)_meas_sent, (unsigned long)_meas_returned, + (unsigned long)_meas_edge, (unsigned long)_meas_timeout); + while (*dp) dp++; + + // 150-byte ceiling minus a worst-case entry (~26B: \n + ~ + 8hex + :secs:snr) + for (uint8_t k = 0; k < n && dp - reply < 150 - 26; k++) { + *dp++ = '\n'; + if (k >= NEAR_NEIGHBOUR_COVERAGE_CAP) *dp++ = '~'; // near but beyond the coverage cap + char hex[10]; + mesh::Utils::toHex(hex, neighbours[idx[k]].id.pub_key, 4); + uint32_t secs_ago = now - neighbours[idx[k]].heard_timestamp; + sprintf(dp, "%s:%d:%d", hex, (int)secs_ago, (int)neighbours[idx[k]].snr); + while (*dp) dp++; + } + if (n == 0) { *dp++ = '\n'; strcpy(dp, "-none-"); while (*dp) dp++; } + *dp = 0; +#else + strcpy(reply, "near: disabled"); +#endif +} + void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); @@ -1767,9 +2024,11 @@ void MyMesh::loop() { mesh::Mesh::loop(); _flood_supp.purge(millis()); // evict stale flood-suppression entries - _nbr_links.purge(millis()); // evict stale inter-neighbour reach edges (~1 day TTL) + _nbr_links.purge(millis()); // evict stale inter-neighbour reach edges (~36h TTL) purgeAttachedClients(getRTCClock()->getCurrentTime()); // evict stale attached-client entries (~24h) + stepCoverageMeasurement(); // actively probe (TRACE) coverage among top-N near neighbours + 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) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index a35667fcec..b493599546 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -70,6 +70,17 @@ struct RepeaterStats { #define ATTACHED_CLIENT_FRESH_S (24UL * 3600UL) // ~24h -- attached leaf clients are stable #endif +// --- Active TRACE coverage measurement (populates _nbr_links) --- +// Coverage among M's near neighbours is MEASURED by round-trip TRACEs, not inferred +// from overheard flood paths. Capped to the strongest few neighbours to bound airtime. +#ifndef NEAR_NEIGHBOUR_COVERAGE_CAP + #define NEAR_NEIGHBOUR_COVERAGE_CAP 5 // max near neighbours M guarantees coverage for +#endif +#define TRACE_MEAS_HASH_SIZE 2 // bytes/hash in a coverage TRACE visit-list (2 avoids prefix collisions) +#define TRACE_MEAS_TIMEOUT_MS 3000 // retry once, then give up, if a coverage TRACE does not return in time +#define TRACE_TX_POWER_RESTORE_MS 2000 // restore normal TX power this long after a measurement burst +#define TRACE_PENDING_MAX 8 // in-flight coverage traces (<=4 pairs x 2 directions) + struct NeighbourInfo { mesh::Identity id; uint32_t advert_timestamp; @@ -133,6 +144,21 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { 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) + // --- Active TRACE coverage measurement state (populates _nbr_links) --- + struct PendingTrace { + uint32_t tag; + uint8_t a[TRACE_MEAS_HASH_SIZE]; // reacher hash prefix (a reaches b) + uint8_t b[TRACE_MEAS_HASH_SIZE]; // reached hash prefix + uint32_t sent_ms; + uint8_t retries; // 0 or 1 (single retry on timeout) + bool active; + }; + PendingTrace _trace_pending[TRACE_PENDING_MAX] = {}; + uint32_t _trace_tag_next = 1; // 0 reserved as sendCoverageTrace() failure sentinel + unsigned long _next_meas_check_ms = 0; // cadenced diff/expiry check + unsigned long _meas_jitter_until = 0; // inter-burst jitter backoff + unsigned long _trace_tx_revert_at = 0; // restore TX power after a burst + uint32_t _meas_sent = 0, _meas_returned = 0, _meas_edge = 0, _meas_timeout = 0; // coverage-TRACE observability (surfaced in `near`) uint32_t pending_discover_tag; unsigned long pending_discover_until; bool region_load_active; @@ -157,8 +183,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void touchNeighbourByHash(const mesh::Packet* packet); // refresh a KNOWN neighbour's liveness/SNR from an overheard forward bool isNearNeighbour(int i, uint32_t now) const; // fresh (<=NEIGHBOUR_FRESH_S) and SNR>=snr_lo int8_t findNearNeighbour(const uint8_t* h, uint8_t hs, uint32_t now) const; // index of near neighbour matching hash, else -1 - bool allNearNeighboursCovered(const FloodSuppressionEntry& e, uint32_t now) const; // >=1 near && every near neighbour in e.covered - bool nearReaches(int from_i, int to_j, uint8_t hs, uint32_t now) const; // fresh DIRECTED reach edge: neighbours[from_i] reaches neighbours[to_j] (to_j heard from_i) + uint8_t topNearNeighbours(int8_t out[], uint8_t max_n, uint32_t now) const; // fill out[] with up to max_n near-neighbour INDICES, strongest SNR first + int8_t findInTopNear(const uint8_t* h, uint8_t hs, const int8_t* top, uint8_t top_n) const; // index (into neighbours[]) of a top-N peer matching hash, else -1 + bool allNearNeighboursCovered(const FloodSuppressionEntry& e, uint32_t now) const; // >=1 top-N near && every one in e.covered + bool nearReaches(int from_i, int to_j, uint8_t hs) const; // fresh DIRECTED reach edge: neighbours[from_i] reaches neighbours[to_j] (to_j heard from_i). Freshness is millis-based (TTL is in ms). + uint32_t sendCoverageTrace(const mesh::Identity& a, const mesh::Identity& b); // round-trip [a,b,self] TRACE measuring a->b; returns tag (0 on pool-full) + void stepCoverageMeasurement(); // cadenced: timeout/retry sweep + top-N diff/expiry + send bool clientProtectionAllowsSuppress(const mesh::Packet* pkt, uint32_t now) const; // 3-tier client-aware gate (always active) void addOrRefreshAttachedClient(const uint8_t* prefix, uint8_t plen, uint32_t now); // seed/refresh attached leaf client (prefix[0] is the match key) bool attachedClientMatches(uint8_t hash1, uint32_t now) const; // is hash1 a fresh attached client? (hash1 vs prefix[0]) @@ -189,6 +219,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; void logRx(mesh::Packet* pkt, int len, float score) override; + void onTraceRecv(mesh::Packet* packet, uint32_t tag, uint32_t auth_code, uint8_t flags, const uint8_t* path_snrs, const uint8_t* path_hashes, uint8_t path_len) override; void logTx(mesh::Packet* pkt, int len) override; void logTxFail(mesh::Packet* pkt, int len) override; int calcRxDelay(float score, uint32_t air_time) const override; @@ -264,6 +295,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void formatNeighborsReply(char *reply) override; void formatClientsReply(char *reply) override; // list attached leaf clients void formatReachReply(char *reply, const uint8_t* hash, uint8_t hash_len) override; // reach edges of a near repeater + void formatNearReply(char *reply) override; // near coverage peers + snr_lo threshold void removeNeighbor(const uint8_t* pubkey, int key_len) override; void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index c11f37cacf..ff7af10654 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -52,14 +52,26 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t len = pkt->payload_len - i; // path_len*entry_size can exceed 255 (path_len up to 63, entry_size up to 8); // a uint8_t offset would wrap and steer the isHashMatch() read to the wrong place. + uint8_t entry_sz = 1 << path_sz; uint16_t offset = (uint16_t)pkt->path_len << path_sz; + // is the current entry the FINAL visit-list entry? (used by terminate-at-last below) + bool last_entry = ((uint16_t)offset + entry_sz) >= len; if (offset >= len) { // TRACE has reached end of given path onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); - } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->wasSeen(pkt)) { + } else if (self_id.isHashMatch(&pkt->payload[i + offset], entry_sz) && allowPacketForward(pkt) && !_tables->wasSeen(pkt)) { _tables->markSeen(pkt); // append SNR (Not hash!) pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4); + // TRACE_FLAG_TERMINATE_AT_LAST: deliver the result HERE when this self match + // is the final visit-list entry, and do NOT retransmit past it. Lets a + // coverage trace whose visit-list ends at the initiator (e.g. [a,b,self]) + // return its SNR vector to the initiator instead of to a bystander node. + if ((flags & TRACE_FLAG_TERMINATE_AT_LAST) && last_entry) { + onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); + return ACTION_RELEASE; + } + uint32_t d = getDirectRetransmitDelay(pkt); return ACTION_RETRANSMIT_DELAYED(5, d); // schedule with priority 5 (for now), maybe make configurable? } diff --git a/src/Packet.h b/src/Packet.h index c19d9e9d8f..143bc5ee31 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -31,6 +31,14 @@ namespace mesh { //... #define PAYLOAD_TYPE_RAW_CUSTOM 0x0F // custom packet as raw bytes, for applications with custom encryption, payloads, etc +// TRACE 'flags' byte (payload[8]) bit masks. The lower 2 bits encode the path hash +// size code (1 << code bytes per hash: 0->1, 1->2, 2->4, 3->8). Upper bits are flags: +#define TRACE_FLAG_TERMINATE_AT_LAST 0x04 // deliver onTraceRecv AT the final visit-list entry + // (return-to-initiator), instead of at the bystander + // node that hears the final retransmit. Used by the + // coverage TRACE ([a,b,self]) so the initiator gets its + // own SNR vector back. Additive: legacy callers leave it 0. + #define PAYLOAD_VER_1 0x00 // 1-byte src/dest hashes, 2-byte MAC #define PAYLOAD_VER_2 0x01 // FUTURE (eg. 2-byte hashes, 4-byte MAC ??) #define PAYLOAD_VER_3 0x02 // FUTURE diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index d1203c24bd..051d4e2f1d 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -97,7 +97,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { 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 + file.read((uint8_t *)&_prefs->trace_tx_power_dbm, sizeof(_prefs->trace_tx_power_dbm)); // 299 + // next: 300 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -133,6 +134,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _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); + _prefs->trace_tx_power_dbm = constrain(_prefs->trace_tx_power_dbm, -9, 30); file.close(); } @@ -202,7 +204,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { 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.write((uint8_t *)&_prefs->trace_tx_power_dbm, sizeof(_prefs->trace_tx_power_dbm)); // 299 + // next: 300 file.close(); } @@ -306,6 +309,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re strcpy(reply, "ERR: bad hash"); } } + } else if (memcmp(command, "near", 4) == 0) { + _callbacks->formatNearReply(reply); } else if (memcmp(command, "tempradio ", 10) == 0) { strcpy(tmp, &command[10]); const char *parts[5]; @@ -555,6 +560,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep 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, "trace.tx.power ", 15) == 0) { + int db = atoi(&config[15]); + if (db >= -9 && db <= 30) { _prefs->trace_tx_power_dbm = db; savePrefs(); strcpy(reply, "OK"); } + else strcpy(reply, "Error, must be -9..30 dB"); } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { _prefs->agc_reset_interval = atoi(&config[19]) / 4; savePrefs(); @@ -940,6 +949,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } } else if (memcmp(config, "tx", 2) == 0 && (config[2] == 0 || config[2] == ' ')) { sprintf(reply, "> %d", (int32_t) _prefs->tx_power_dbm); + } else if (memcmp(config, "trace.tx.power", 14) == 0) { + sprintf(reply, "> %d dB", (int) _prefs->trace_tx_power_dbm); } else if (memcmp(config, "freq", 4) == 0) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->freq)); } else if (memcmp(config, "public.key", 10) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 8463e8402a..fd70ad9603 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -72,6 +72,7 @@ struct NodePrefs { // persisted to file 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=snr_lo), strongest first, with the active + // snr_lo threshold and the coverage cap. Default no-op. + virtual void formatNearReply(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/NeighbourLinkTable.h b/src/helpers/NeighbourLinkTable.h index afe6f4dcb1..5ffeef42be 100644 --- a/src/helpers/NeighbourLinkTable.h +++ b/src/helpers/NeighbourLinkTable.h @@ -12,9 +12,12 @@ // would mark N falsely covered -> M would suppress and starve N (the deafening // this feature exists to prevent). Edges are therefore directed and never flipped. // -// Direction comes for free from the flood path: path hops are in forwarding order -// [R1..Rn], so a consecutive pair (X, Y) means Y forwarded right after X, i.e. -// Y heard X -> X reaches Y -> directed edge X->Y is recorded. +// Direction is established by ACTIVE TRACE measurement (simple_repeater): the +// repeater sends a coverage TRACE [a,b,self] that returns to it; the SNR measured +// at b of a's forward tells whether b can hear a -> a reaches b -> directed edge +// a->b is recorded. (Earlier revisions inferred this passively from consecutive +// flood-path hops; that built up too slowly in sparse/mast topologies, so the +// graph is now measured.) // // simple_repeater uses these edges to INFER coverage: if a near neighbour fi // forwarded flood F, then every near neighbour N with a fresh edge fi->N very @@ -23,16 +26,18 @@ // // Edges are keyed by PATH HASH (the public-key prefix), NOT by neighbour-table // index, so they survive LRU reordering of MyMesh::neighbours[]. A stored -// hash_size lets edges coexist across deployments of different hash widths -// (VER_1 -> 1 byte); only that many bytes are ever compared. Small ring with -// TTL eviction (~1 day -- repeater topology is stable); swept from loop(). +// hash_size records the width at which the edge was measured; LOOKUPS (hasEdge) +// are width-tolerant and match on the COMMON PREFIX (min of the query and stored +// widths), so a 2-byte measured edge is found by a query of ANY width -- it is +// never missed solely because the caller used a different hash width. Small ring +// with TTL eviction (~36 h -- repeater topology is stable); swept from loop(). #ifndef NEIGHBOUR_LINK_TABLE_SIZE #define NEIGHBOUR_LINK_TABLE_SIZE 128 #endif #ifndef NEIGHBOUR_LINK_TTL_MILLIS - #define NEIGHBOUR_LINK_TTL_MILLIS (24UL * 60UL * 60UL * 1000UL) // ~1 day + #define NEIGHBOUR_LINK_TTL_MILLIS (36UL * 60UL * 60UL * 1000UL) // ~36h -- coverage is re-measured on expiry #endif class NeighbourLinkTable { @@ -61,7 +66,11 @@ class NeighbourLinkTable { // Record/refresh a DIRECTED edge src->dst (hs-byte path hashes). src reaches dst. // A bidirectional link occupies two separate entries (src->dst and dst->src), - // each observed and refreshed independently -- this preserves asymmetry. + // each observed and refreshed independently -- this preserves asymmetry. Dedup + // here is EXACT-width (only an identical-width entry is refreshed): unlike the + // prefix-tolerant hasEdge() lookup, the WRITE side must NOT merge two distinct + // neighbours that merely share a short common prefix. (simple_repeater records + // only at TRACE_MEAS_HASH_SIZE, so distinct measurements are never coalesced.) void addEdge(const uint8_t* src, const uint8_t* dst, uint8_t hs, uint32_t now) { for (int i = 0; i < NEIGHBOUR_LINK_TABLE_SIZE; i++) { Link& l = _links[i]; @@ -79,12 +88,22 @@ class NeighbourLinkTable { l.active = true; } - // Is there a FRESH directed edge src->dst (hs-byte hashes)? (i.e. dst can hear src) + // Is there a FRESH directed edge src->dst? (i.e. dst can hear src). WIDTH-TOLERANT + // PREFIX match: the edge may have been recorded at a hash width that differs from + // this query's `hs`, so we compare the COMMON PREFIX -- min(hs, l.hash_size) bytes + // -- instead of requiring an exact width. Edges are measured at TRACE_MEAS_HASH_SIZE + // (2 bytes); thus a WIDER query (hs>2) matches on the 2 measured bytes (no loss + // beyond measurement resolution), and a NARROWER query (hs=1) matches on 1 byte (a + // small collision approximation -- two neighbours sharing that byte cannot be told + // apart at that width). simple_repeater always queries at the measurement width (2), + // so this is primarily a robustness safety net: a measured edge is never missed + // solely because a caller happened to use a different hash width. bool hasEdge(const uint8_t* src, const uint8_t* dst, uint8_t hs, uint32_t now) const { for (int i = 0; i < NEIGHBOUR_LINK_TABLE_SIZE; i++) { const Link& l = _links[i]; - if (l.active && l.hash_size == hs && !_expired(l, now) && - _same(l.src, src, hs) && _same(l.dst, dst, hs)) { + if (!l.active || _expired(l, now)) continue; + uint8_t m = (hs < l.hash_size) ? hs : l.hash_size; // common-prefix width + if (_same(l.src, src, m) && _same(l.dst, dst, m)) { return true; } } From 87e124e4135e5434cd64879bea5df9787e88fa25 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 31 Jul 2026 08:13:13 +0200 Subject: [PATCH 07/15] feat: add flood suppression coverage documentation and CLI commands --- docs/README-flood-suppression.md | 185 +++++++++++++++++++++++++++++++ docs/cli_commands.md | 118 +++++++++++++++++--- 2 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 docs/README-flood-suppression.md diff --git a/docs/README-flood-suppression.md b/docs/README-flood-suppression.md new file mode 100644 index 0000000000..40d55aa469 --- /dev/null +++ b/docs/README-flood-suppression.md @@ -0,0 +1,185 @@ +# Flood Suppression + +> **Status:** experimental · repeater-only (`simple_repeater`) · branch `feature/flood-suppression-coverage`. +> Not yet merged; still being tuned and measured on hardware. + +In a dense mesh every repeater rebroadcasts every flood, so most nodes receive many +redundant copies and the air fills with collisions. **Flood suppression** lets a repeater +cancel its *own* scheduled rebroadcast of a flood when its near neighbours are already +covered — cutting redundant on-air traffic while preserving reach. + +See [`cli_commands.md`](cli_commands.md) for the exact command syntax; this document +explains the mechanism and how to tune it. + +--- + +## The decision, in one paragraph + +Repeater **M** suppresses its rebroadcast of flood **F** **iff** + +1. every **near** neighbour is **covered** (already has F), +2. no **isolated uncovered** near neighbour exists (`must_cover_self`), and +3. the **client-aware** protection gate allows it. + +Otherwise M forwards F as normal. Reach is never sacrificed for a neighbour the feature +can see — it only removes rebroadcasts that would be redundant. + +--- + +## Concepts + +### Near neighbours — the coverage set +- **Near** = heard recently (`<= 1 h`) **and** link SNR `>= flood.suppress.snr.lo`. +- Coverage is guaranteed only for the **strongest few** near neighbours + (`NEAR_NEIGHBOUR_COVERAGE_CAP = 5`). A rank-6+ near peer is *not* owed coverage — the + strongest forwarders cover the most nodes, so guaranteeing only the top few bounds + airtime while preserving reach. (The adaptive density estimate still counts *all* fresh + neighbours, so this cap does not weaken the threshold.) +- Inspect live with [`near`](cli_commands.md#near). + +### Coverage — how M knows a neighbour already has F +Two sources, combined across every overheard copy of F: + +1. **Direct** — the neighbour itself forwarded F (M saw its hash on an overheard path). Certain. +2. **Reach-graph** — the neighbour was *reached* by a near forwarder **fi** via a fresh + **directed** measured edge `fi -> N` (N heard fi's forward of F). Inferred from the + reach graph below. + +### The reach graph — actively measured, not inferred +- An edge `fi -> N` means *"N can hear fi's transmissions"* (fi reaches N). +- **Directed.** RF links are asymmetric — A hearing B does **not** mean B hears A. The + graph must not infer forward reach from a reverse observation, or M could suppress a + rebroadcast and starve a neighbour that is actually deaf toward the forwarder. +- Edges are established by **active TRACE coverage probes** (the earlier design inferred + them from overheard flood paths, but that stayed empty in sparse/mast topologies where + two of M's near neighbours never appear consecutively in one flood path). +- **Probe:** a round-trip TRACE with visit-list `[a, b, self]` (2-byte hashes) walks + `self -> a -> b -> self`. The SNR measured at **b** of **a**'s forward is exactly + *"does b hear a"*. A new core flag `TRACE_FLAG_TERMINATE_AT_LAST` delivers the result + back at the initiator instead of a bystander. +- Measured only on demand: when the top-5 set changes (new member / displacement) or after + a **36 h** refresh. One probe per cadence tick (HW ~60 s) to avoid round-trip collisions. +- An edge is recorded iff the measured SNR `>= flood.suppress.snr.lo`; TTL 36 h. +- **1-hop, not transitive** — "reached by fi" means *could hear fi's specific forward*. + +### SNR weighting of overheard forwards +Each overheard neighbour forward counts toward "covered", weighted by the SNR it was +heard at: +- `>= flood.suppress.snr.hi` → counts **double** (a strong/central relay almost certainly reached others too); +- `< flood.suppress.snr.lo` → counts **0** (a marginal relay likely didn't reach the edge — preserve reach). + +### Adaptive threshold C (not user-configurable) +The cancellation threshold **C** — how much "already covered" weight is needed before M +suppresses — is **derived from the neighbour table** (an adaptive density estimate: more +near neighbours ⇒ more redundancy required before cancelling) with a static fallback. So a +dense cluster tolerates more redundancy before suppressing; a sparse one stays +conservative. There is no `set flood.suppress.c`. + +### Cancel-window widening (`delay.factor`) +A flood M would relay centrally (heard at SNR `>= snr.hi`) gets its random TX-delay window +multiplied by `(1 + flood.suppress.delay.factor)`. A wider window gives a redundant +rebroadcast more time to be observed and cancelled before it is transmitted. + +### Client-aware protection (always on) +Suppression must never starve an **attached leaf client** (a companion/sensor/room-server +for which M is the first hop) of a flood it needs. A 3-tier gate is **always active** +(there is no "empty client set ⇒ suppress everything" fallback): + +| Tier | Payload types | Behaviour | +|------|---------------|-----------| +| **A** | TRACE, CONTROL | Pure infrastructure → **suppress OK** | +| **B** | ADVERT, GRP_*, ACK, MULTIPART, … | Broadcast, can't address-check → **never suppress** (always forward) | +| **C** | REQ, RESPONSE, TXT_MSG, PATH, ANON_REQ | Addressed → suppress iff destination is **not** an attached client | + +The attached-client set (16 slots, ~24 h TTL) is seeded from count-0 addressed packets and +non-repeater adverts. Inspect with [`clients`](cli_commands.md#clients). + +### Cold-start safety +Before any TRACE completes, the reach graph is empty ⇒ uncovered peers trigger +`must_cover_self` ⇒ **M forwards everything**. No starvation, no regression vs. firmware +without the feature. Suppression only begins once real reachability has been measured. + +--- + +## When it helps vs. when it is inert +- **Dense omni cluster** (near neighbours mutually in range): edges populate ⇒ redundant + rebroadcasts cancelled ⇒ large airtime/collision reduction. +- **Sparse / linear / hub-spoke / mast** (near neighbours don't hear each other): the + graph is **correctly empty** ⇒ little graph-based suppression, because M must cover each + spoke itself. This is the safe, intended behaviour — the feature never infers + reachability it has not measured. +- Even with an empty graph, the **direct-coverage** path can still suppress: once M has + overheard *every* near neighbour forward F, `allNearNeighboursCovered` fires and the + rebroadcast is cancelled — no reach edge required. + +--- + +## CLI summary + +| Command | Effect | +|---------|--------| +| `get/set flood.suppress ` | Master switch. `get` also prints the suppression ratio (`suppressed a/b (p%)`). | +| `get/set flood.suppress.snr.hi ` | SNR `>=` this counts double (`-30..30`, default `9`). | +| `get/set flood.suppress.snr.lo ` | SNR `<` this counts 0; also the near-set floor and reach-edge floor (`-30..30`, default `0`). | +| `get/set flood.suppress.delay.factor ` | Cancel-window multiplier for central relays (`0..8`, default `2`). | +| `get/set trace.tx.power ` | TX power for coverage TRACE probes only (`-9..30`, default `10`). | +| `near` | Near coverage peers (strongest first) + TRACE probe health (`meas sent/ret/edge/tmo`). | +| `reach ` | Directed reach edges of one near repeater. | +| `clients` | Attached leaf clients. | + +Full syntax and output formats: [`cli_commands.md`](cli_commands.md#flood-suppression-coverage-repeater-only). + +--- + +## Tuning & troubleshooting + +### Read the state first +- `near` → the near set and the `meas sent=… ret=… edge=… tmo=…` probe-health line. +- `reach ` → whether measured directed edges exist for a given peer. +- `get flood.suppress` → on/off + the live suppression ratio. + +### `reach` is empty on hardware, but `near` shows peers? +The coverage-probe TX power is lowered to `trace.tx.power` (default **10 dBm**) **only on +the initiator**. The probe's first hop (`self -> a`) then runs at reduced power; for a +marginal near neighbour (admitted at a low `snr.lo`) that hop can drop below margin, so the +probe never reaches `a`, no round trip completes, and no edge is recorded. The simulator +ignores TX power, so this only manifests on hardware. + +**Fix:** `set trace.tx.power 20` (match normal TX power) and re-check `near` — the `meas` +line's `ret` should rise and edges appear in `reach`. Reading the `meas` line: + +| `meas` reading | Meaning | +|----------------|---------| +| `sent=0` | No `>= 2` near-neighbour window yet, or `flood.suppress off`. | +| `sent>0 ret=0` | Probes go out but no round trip completes — loss/collisions, or the first hop failing (see `trace.tx.power`). | +| `ret>0 edge=0` | Round trips complete but the measured link is below `snr.lo` — genuinely weak / no inter-neighbour reachability. | +| `ret>0 edge>0` | Edges recorded — `reach` should list them. | + +### Near set churning (peers blink in/out) +At a low `snr.lo` (e.g. `0`), marginal neighbours keep crossing the threshold. Raise +`snr.lo` (e.g. `6`–`10`) to focus coverage on the stable, strong core — fewer, stabler +near peers, faster graph convergence. + +### Suppressing too little / too much +- Too little: lower `snr.hi`, or raise `delay.factor` so more redundant rebroadcasts are + observed in time. +- Too much / worried about reach: raise `snr.lo` (stricter near set), or `set flood.suppress off`. + +--- + +## How it is wired (files) +- `src/helpers/NeighbourLinkTable.h` — the directed reach-graph (`addEdge`/`hasEdge`/`purge`, ring 128, 36 h TTL). `hasEdge` is width-tolerant (prefix match); writes are exact-width. +- `src/helpers/FloodSuppression.h` — per-flood entry with `covered` set, `must_cover_self`, cancel + wait-window. +- `src/Mesh.cpp` + `src/Packet.h` — `TRACE_FLAG_TERMINATE_AT_LAST`: delivers a coverage TRACE back at its initiator (the one core change). +- `examples/simple_repeater/MyMesh.{h,cpp}` — the coverage test (`logRx`), `stepCoverageMeasurement()` (active TRACE scheduling, from `loop()`), `onTraceRecv` (records edges), the always-on 3-tier `clientProtectionAllowsSuppress`, the `near`/`reach`/`clients` reply formatters, and the `trace_tx_power_dbm` burst handling. +- `src/helpers/CommonCLI.{h,cpp}` + `NodePrefs` — the CLI commands and persisted prefs above. + +--- + +## Honest limits +- Coverage is guaranteed only for the **top-5** near neighbours. +- **Invisible neighbours** (asymmetric, absent from M's table) cannot be protected by any + table-based method. `set flood.suppress off` (or manual per-neighbour exclusion) remains + the safety net. +- Reach is inferred from a *historically measured* edge ⇒ a small false-positive risk if + an edge has since gone stale, mitigated by fresh-edge-only use + 36 h TTL + cold-start safety. diff --git a/docs/cli_commands.md b/docs/cli_commands.md index c833a778e9..c85f772b81 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -6,6 +6,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore - [Operational](#operational) - [Neighbors](#neighbors-repeater-only) +- [Flood Suppression Coverage](#flood-suppression-coverage-repeater-only) - [Statistics](#statistics) - [Logging](#logging) - [Information](#info) @@ -129,6 +130,62 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +## Flood Suppression Coverage (Repeater Only) + +Inspection commands for the flood-suppression coverage state — the near-neighbour set, the measured inter-neighbour reach graph, and the attached-client set used by the client-aware protection gate. Output is **byte-minimal** (it travels over LoRa as a REQ→RESPONSE payload), so hashes use the same 4-byte/8-hex prefix as `neighbors`. See [`README-flood-suppression.md`](README-flood-suppression.md) for what these represent and how to tune them. + +### near + +**Usage:** +- `near` + +Lists the **near** coverage peers — fresh (`<= 1 h` since last heard) and link SNR `>= flood.suppress.snr.lo` — strongest SNR first. This is the exact set the coverage test and the active TRACE measurement act on. + +**Output:** +``` +near snr_lo= cap=<5> n= +meas sent= ret= edge= tmo= +:: +... +``` + +- **Header:** the active `snr.lo` cutoff, the coverage cap (only the strongest `cap` peers are owed coverage / actively TRACE-measured), and `n` = current near count. +- **`meas` line:** coverage-TRACE health — `sent` = probe attempts, `ret` = round-trips that returned to this node, `edge` = reach links recorded (returned with SNR `>= snr.lo`), `tmo` = pairs that timed out twice (no link). Reading it: `sent>0 ret=0` ⇒ round trips not completing (loss, collisions, or the first probe hop not reaching a marginal near neighbour — see `trace.tx.power`); `ret>0 edge=0` ⇒ inter-neighbour links exist but are below `snr.lo`; `sent=0` ⇒ no `>= 2` near-neighbour window yet (or `flood.suppress off`). +- **Per-peer lines:** `::` where `snr` is `×4` (divide by 4 for dB), same encoding as `neighbors`. Peers beyond the cap are prefixed `~` (near but **not** owed coverage). `-none-` if empty. + +--- + +### reach \ + +**Usage:** +- `reach ` + +Shows the **directed reach edges** of one near repeater — the measured inter-neighbour reach graph used to infer coverage. + +**Parameters:** +- `hash`: Hex prefix of the neighbour to query, any even length (e.g. the 8-hex prefix printed by `neighbors`/`near`). No argument replies `reach HASH`. + +**Output:** two lines: +- Line 1 `<…` — **reached-by**: near neighbours that reach this node (incoming edges). +- Line 2 `>…` — **reaches**: near neighbours this node reaches (outgoing edges). + +Endpoints are 8-hex prefixes, comma-separated; `-` when empty. Status words: `notnear` (known neighbour, but not currently near), `unknown` (no matching neighbour), `ambig` (matches more than one near neighbour). + +**Note:** Edges are populated by the active TRACE coverage measurement (see [`README-flood-suppression.md`](README-flood-suppression.md)). On a sparse, linear, or hub-spoke mesh where near neighbours don't hear each other, the graph is correctly empty and `reach` shows `<-` / `>-`. + +--- + +### clients + +**Usage:** +- `clients` + +Lists the **attached leaf clients** — companion/sensor/room-server nodes for which this repeater is the first hop — tracked by the always-on client-aware protection gate (so suppression never starves them of a flood they need). + +**Output:** one line per client `:s`, where `hash` is the learned identity prefix (8-hex when seeded from an advert, 2-hex when seeded from a message src_hash) and `age` is seconds since last seen. `-none-` if empty. + +--- + ## Statistics ### Clear Stats @@ -697,26 +754,61 @@ This document provides an overview of CLI commands that can be sent to MeshCore 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:** +on-air flood traffic and collisions while preserving reach. It works alongside a +**coverage test** that tracks which of the repeater's near neighbours have already +received a flood — directly (overheard forwarding) or via a measured inter-neighbour +reach edge — so a rebroadcast is cancelled only when every near neighbour is already +covered. + +The cancellation threshold **C is not user-configurable** — it is derived from the +neighbour table (adaptive density estimate) with a static fallback. The options below +are the master switch, the SNR-weighting of overheard forwards, the cancel-window +widening, and the coverage-probe TX power. See +[`README-flood-suppression.md`](README-flood-suppression.md) for the full mechanism, +and the [`near`](#near) / [`reach`](#reach-hash) / [`clients`](#clients) commands to +inspect the learned coverage state at runtime. + +**Master switch:** - `get flood.suppress` / `set flood.suppress ` + +The `get` reply also reports the suppression ratio, e.g. `> on, suppressed 3/19 (15%)` +(suppressed rebroadcasts / distinct floods heard; `0%` when none heard yet). Returns +plain `> off` when the feature is disabled. + +**Parameters:** `state` = `on`|`off` — disables the feature entirely when `off`. + +**SNR weighting of overheard forwards:** - `get flood.suppress.snr.hi` / `set flood.suppress.snr.hi ` - `get flood.suppress.snr.lo` / `set flood.suppress.snr.lo ` + +Each overheard neighbour forward of a flood contributes to the "already covered" count, +weighted by the SNR it was heard at: +- `dB` (`snr.hi`, `-30..30`): heard at SNR `>=` this counts **double** (a strong/central relay almost certainly also reached others). +- `dB` (`snr.lo`, `-30..30`): heard at SNR `<` this counts **0** (a marginal relay likely didn't reach the edge; preserve reach). + +`snr.lo` is also the SNR floor for the **near** coverage set and for recording a measured reach edge (see [`near`](#near)). + +**Cancel-window widening:** - `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) +**Parameters:** `n` = `0..8` — extra TX-delay multiplier applied to a flood this repeater +would relay centrally (heard at SNR `>= snr.hi`). Widening the random delay window gives a +redundant rebroadcast more time to be observed and cancelled before it goes out. `0` +disables the widening. + +**Coverage-probe TX power:** +- `get trace.tx.power` / `set trace.tx.power ` + +**Parameters:** `dBm` = `-9..30` — TX power used **only** for the coverage TRACE probes +(the reach-graph measurement), restored to normal afterwards. Near links are strong, so +the default lowers power to reduce disturbance. If `reach` stays empty on hardware despite +near neighbours being present, raise this to the normal TX power (e.g. `set trace.tx.power 20`) +so the probe's first hop reaches marginal near neighbours — see the tuning notes in +[`README-flood-suppression.md`](README-flood-suppression.md). -**Defaults:** `flood.suppress` = `on` · `flood.suppress.snr.hi` = `9` · `flood.suppress.snr.lo` = `0` · `flood.suppress.delay.factor` = `2` +**Defaults:** `flood.suppress` = `on` · `flood.suppress.snr.hi` = `9` · `flood.suppress.snr.lo` = `0` · `flood.suppress.delay.factor` = `2` · `trace.tx.power` = `10` -**Note:** _Experimental feature —_ still being tuned and measured on hardware. +**Note:** _Experimental feature_ on branch `feature/flood-suppression-coverage` — still being tuned and measured on hardware. --- From 84337cbf0882870d158cf4d646e0f64b9508bf93 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 31 Jul 2026 12:21:22 +0200 Subject: [PATCH 08/15] feat: implement negative-result caching for coverage probing --- examples/simple_repeater/MyMesh.cpp | 86 +++++++++++++++---------- examples/simple_repeater/MyMesh.h | 10 ++- src/helpers/NeighbourLinkTable.h | 97 ++++++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 38 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 8f6ea16386..b7d8e3086c 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -272,6 +272,11 @@ void MyMesh::onTraceRecv(mesh::Packet* /*packet*/, uint32_t tag, uint32_t /*auth if (snr_x4 >= (int8_t)(_prefs.flood_suppress_snr_lo * 4)) { _nbr_links.addEdge(path_hashes, path_hashes + entry_sz, entry_sz, millis()); _meas_edge++; // ...and the a->b link was strong enough to record + } else { + // Returned but weak: the a->b link exists yet cannot carry coverage. Cache as no-edge so it + // is not re-probed every tick; it retries only after NEIGHBOUR_LINK_NEG_TTL_MILLIS (~10h). + _nbr_links.addNegative(path_hashes, path_hashes + entry_sz, entry_sz, millis()); + _meas_neg++; } } for (uint8_t i = 0; i < TRACE_PENDING_MAX; i++) { // retire the matching pending entry (success) @@ -312,6 +317,8 @@ void MyMesh::stepCoverageMeasurement() { } else { _trace_pending[i].active = false; // second miss -> link does not exist (no edge) _meas_timeout++; + _nbr_links.addNegative(_trace_pending[i].a, _trace_pending[i].b, TRACE_MEAS_HASH_SIZE, now); + _meas_neg++; // cache no-edge so we don't re-probe every tick } } @@ -334,42 +341,51 @@ void MyMesh::stepCoverageMeasurement() { uint8_t top_n = topNearNeighbours(top, NEAR_NEIGHBOUR_COVERAGE_CAP, getRTCClock()->getCurrentTime()); if (top_n < 2) return; + // Enumerate the P = top_n*(top_n-1) directed pairs (x != y) as a flat list and scan from a rotating + // offset (_meas_rr_offset), so we don't fixate on the same first absent pair every tick. Decode pair + // index p -> (x,y) via x = p/(top_n-1); y = p%(top_n-1); if (y >= x) y++; (bijection onto x!=y). + // At most ONE trace is sent per cadence tick. + uint8_t P = top_n * (top_n - 1); + uint8_t base = _meas_rr_offset % P; uint8_t ha[TRACE_MEAS_HASH_SIZE], hb[TRACE_MEAS_HASH_SIZE]; bool burst_started = false, stop = false; - for (uint8_t x = 0; x < top_n && !stop; x++) { + for (uint8_t k = 0; k < P && !stop; k++) { + uint8_t p = (base + k) % P; + uint8_t x = p / (top_n - 1); + uint8_t y = p % (top_n - 1); + if (y >= x) y++; // skip the x==y diagonal neighbours[top[x]].id.copyHashTo(ha, TRACE_MEAS_HASH_SIZE); - for (uint8_t y = 0; y < top_n && !stop; y++) { - if (x == y) continue; - neighbours[top[y]].id.copyHashTo(hb, TRACE_MEAS_HASH_SIZE); - if (_nbr_links.hasEdge(ha, hb, TRACE_MEAS_HASH_SIZE, now)) continue; // measured & fresh - bool inflight = false; // already probing this direction? - for (uint8_t i = 0; i < TRACE_PENDING_MAX && !inflight; i++) - if (_trace_pending[i].active && memcmp(_trace_pending[i].a, ha, 2) == 0 && memcmp(_trace_pending[i].b, hb, 2) == 0) inflight = true; - if (inflight) continue; - int8_t slot = -1; // free pending slot? - for (uint8_t i = 0; i < TRACE_PENDING_MAX; i++) if (!_trace_pending[i].active) { slot = (int8_t)i; break; } - if (slot < 0) { stop = true; break; } - if (!burst_started) { - burst_started = true; - if (_prefs.trace_tx_power_dbm != _prefs.tx_power_dbm) { - radio_driver.setTxPower(_prefs.trace_tx_power_dbm); - _trace_tx_revert_at = futureMillis(TRACE_TX_POWER_RESTORE_MS); - } + neighbours[top[y]].id.copyHashTo(hb, TRACE_MEAS_HASH_SIZE); + if (_nbr_links.hasEdge(ha, hb, TRACE_MEAS_HASH_SIZE, now)) continue; // measured & fresh (positive) + if (_nbr_links.hasNegative(ha, hb, TRACE_MEAS_HASH_SIZE, now)) continue; // probed, no edge -> backoff (~10h) + bool inflight = false; // already probing this direction? + for (uint8_t i = 0; i < TRACE_PENDING_MAX && !inflight; i++) + if (_trace_pending[i].active && memcmp(_trace_pending[i].a, ha, 2) == 0 && memcmp(_trace_pending[i].b, hb, 2) == 0) inflight = true; + if (inflight) continue; + int8_t slot = -1; // free pending slot? + for (uint8_t i = 0; i < TRACE_PENDING_MAX; i++) if (!_trace_pending[i].active) { slot = (int8_t)i; break; } + if (slot < 0) { stop = true; break; } + if (!burst_started) { + burst_started = true; + if (_prefs.trace_tx_power_dbm != _prefs.tx_power_dbm) { + radio_driver.setTxPower(_prefs.trace_tx_power_dbm); + _trace_tx_revert_at = futureMillis(TRACE_TX_POWER_RESTORE_MS); } - uint32_t tag = sendCoverageTrace(neighbours[top[x]].id, neighbours[top[y]].id); - if (!tag) { stop = true; break; } // pool full -> wait - _meas_sent++; - _trace_pending[slot].active = true; - _trace_pending[slot].retries = 0; - _trace_pending[slot].tag = tag; - _trace_pending[slot].sent_ms = now; - memcpy(_trace_pending[slot].a, ha, 2); - memcpy(_trace_pending[slot].b, hb, 2); - stop = true; break; // ONE trace per cadence tick - // (a pair's two directions are ~180ms*3hops round-trips; sending them - // back-to-back makes the 2nd collide with the 1st's return relay. Spacing - // to one-per-tick lets each round trip complete cleanly.) } + uint32_t tag = sendCoverageTrace(neighbours[top[x]].id, neighbours[top[y]].id); + if (!tag) { stop = true; break; } // pool full -> wait + _meas_sent++; + _trace_pending[slot].active = true; + _trace_pending[slot].retries = 0; + _trace_pending[slot].tag = tag; + _trace_pending[slot].sent_ms = now; + memcpy(_trace_pending[slot].a, ha, 2); + memcpy(_trace_pending[slot].b, hb, 2); + _meas_rr_offset = (uint8_t)((p + 1) % P); // next tick starts after the pair just probed + stop = true; break; // ONE trace per cadence tick + // (a pair's two directions are ~180ms*3hops round-trips; sending them + // back-to-back makes the 2nd collide with the 1st's return relay. Spacing + // to one-per-tick lets each round trip complete cleanly.) } if (burst_started) _meas_jitter_until = futureMillis((int)getRNG()->nextInt(500, 3000)); #endif @@ -1879,12 +1895,13 @@ void MyMesh::formatNearReply(char *reply) { while (*dp) dp++; // coverage-TRACE health: sent=attempts, ret=round-trips that came back, edge=links - // recorded (ret with SNR>=snr_lo), tmo=pairs that timed out twice (no link). If sent>0 + // recorded (ret with SNR>=snr_lo), tmo=pairs that timed out twice (no link), neg=pairs cached + // as no-edge (timeout or weak return) and skipped until the ~10h backoff expires. If sent>0 // but ret==0 the round trips never complete (loss/collisions); if ret>0 but edge==0 the // measured inter-neighbour links are below snr_lo; if sent==0 no top-N>=2 window yet. - sprintf(dp, "\nmeas sent=%lu ret=%lu edge=%lu tmo=%lu", + sprintf(dp, "\nmeas sent=%lu ret=%lu edge=%lu tmo=%lu neg=%lu", (unsigned long)_meas_sent, (unsigned long)_meas_returned, - (unsigned long)_meas_edge, (unsigned long)_meas_timeout); + (unsigned long)_meas_edge, (unsigned long)_meas_timeout, (unsigned long)_meas_neg); while (*dp) dp++; // 150-byte ceiling minus a worst-case entry (~26B: \n + ~ + 8hex + :secs:snr) @@ -2025,6 +2042,7 @@ void MyMesh::loop() { _flood_supp.purge(millis()); // evict stale flood-suppression entries _nbr_links.purge(millis()); // evict stale inter-neighbour reach edges (~36h TTL) + _nbr_links.purgeNegative(millis()); // evict expired no-edge cache entries (~10h TTL) purgeAttachedClients(getRTCClock()->getCurrentTime()); // evict stale attached-client entries (~24h) stepCoverageMeasurement(); // actively probe (TRACE) coverage among top-N near neighbours diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index b493599546..94dae8399b 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -134,8 +134,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { NeighbourLinkTable _nbr_links; // inter-near-neighbour reach edges (coverage inference) AttachedClient _attached[MAX_ATTACHED_CLIENTS] = {}; // directly-attached leaf clients (client-aware suppression) // Near-neighbour freshness window for the coverage test and the adaptive-density - // count, 60 min - static const uint32_t NEIGHBOUR_FRESH_S = 3600; + // count. Träge (6 h): a repeater briefly unheard (>1 h) but still reachable via the + // forwarded floods it relays (touchNeighbourByHash) must not drop out of the near + // set -- that churn would re-measure its coverage pairs. Local adverts (2 min) and + // every forwarded flood refresh 1-hop neighbours far more often than this window. + static const uint32_t NEIGHBOUR_FRESH_S = 6UL * 3600UL; // 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 @@ -158,7 +161,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long _next_meas_check_ms = 0; // cadenced diff/expiry check unsigned long _meas_jitter_until = 0; // inter-burst jitter backoff unsigned long _trace_tx_revert_at = 0; // restore TX power after a burst - uint32_t _meas_sent = 0, _meas_returned = 0, _meas_edge = 0, _meas_timeout = 0; // coverage-TRACE observability (surfaced in `near`) + uint8_t _meas_rr_offset = 0; // round-robin start index into the flat directed-pair list (advanced per probe) + uint32_t _meas_sent = 0, _meas_returned = 0, _meas_edge = 0, _meas_timeout = 0, _meas_neg = 0; // coverage-TRACE observability (surfaced in `near`) uint32_t pending_discover_tag; unsigned long pending_discover_until; bool region_load_active; diff --git a/src/helpers/NeighbourLinkTable.h b/src/helpers/NeighbourLinkTable.h index 5ffeef42be..914d640371 100644 --- a/src/helpers/NeighbourLinkTable.h +++ b/src/helpers/NeighbourLinkTable.h @@ -31,6 +31,18 @@ // widths), so a 2-byte measured edge is found by a query of ANY width -- it is // never missed solely because the caller used a different hash width. Small ring // with TTL eviction (~36 h -- repeater topology is stable); swept from loop(). +// +// --- Negative-result cache ------------------------------------------------- +// +// A directed pair that was actively TRACE-probed but produced NO edge -- because +// the trace timed out (after its single retry) or returned below snr_lo -- is +// recorded in a separate ring (see NegLink) so stepCoverageMeasurement() does NOT +// re-probe it every cadence tick. Without this, absent/weak (often asymmetric) +// pairs are re-probed forever (~1/min) since they never yield a positive edge. +// TTL is shorter than a positive edge (10 h vs 36 h): a good link that was merely +// disturbed during the two probe attempts recovers sooner. This cache is +// consulted ONLY to gate re-probing; coverage inference reads POSITIVE edges +// exclusively (absence is never treated as coverage). #ifndef NEIGHBOUR_LINK_TABLE_SIZE #define NEIGHBOUR_LINK_TABLE_SIZE 128 @@ -40,6 +52,16 @@ #define NEIGHBOUR_LINK_TTL_MILLIS (36UL * 60UL * 60UL * 1000UL) // ~36h -- coverage is re-measured on expiry #endif +#ifndef NEIGHBOUR_LINK_NEG_HASH_SIZE + #define NEIGHBOUR_LINK_NEG_HASH_SIZE 2 // TRACE coverage hashes are 2 bytes; negatives are stored exact-width +#endif +#ifndef NEIGHBOUR_LINK_NEG_TABLE_SIZE + #define NEIGHBOUR_LINK_NEG_TABLE_SIZE 32 // ~20 directed pairs among 5 near neighbours + churn headroom +#endif +#ifndef NEIGHBOUR_LINK_NEG_TTL_MILLIS + #define NEIGHBOUR_LINK_NEG_TTL_MILLIS (10UL * 60UL * 60UL * 1000UL) // ~10h before a no-edge pair is re-probed +#endif + class NeighbourLinkTable { struct Link { uint8_t src[MAX_HASH_SIZE]; // reacher (the earlier hop on the recording path) @@ -52,6 +74,17 @@ class NeighbourLinkTable { Link _links[NEIGHBOUR_LINK_TABLE_SIZE]; int _next_idx; + // Compact negative-result ring. Fixed-width hashes (measurement is always at + // NEIGHBOUR_LINK_NEG_HASH_SIZE), so -- unlike Link -- no variable width is stored. + struct NegLink { + uint8_t src[NEIGHBOUR_LINK_NEG_HASH_SIZE]; + uint8_t dst[NEIGHBOUR_LINK_NEG_HASH_SIZE]; + uint32_t last_seen_ms; + bool active; + }; + NegLink _neg[NEIGHBOUR_LINK_NEG_TABLE_SIZE]; + int _neg_next_idx; + static bool _same(const uint8_t* x, const uint8_t* y, uint8_t hs) { return memcmp(x, y, hs) == 0; } @@ -61,7 +94,9 @@ class NeighbourLinkTable { void clear() { memset(_links, 0, sizeof(_links)); + memset(_neg, 0, sizeof(_neg)); _next_idx = 0; + _neg_next_idx = 0; } // Record/refresh a DIRECTED edge src->dst (hs-byte path hashes). src reaches dst. @@ -72,6 +107,7 @@ class NeighbourLinkTable { // neighbours that merely share a short common prefix. (simple_repeater records // only at TRACE_MEAS_HASH_SIZE, so distinct measurements are never coalesced.) void addEdge(const uint8_t* src, const uint8_t* dst, uint8_t hs, uint32_t now) { + _clearNegative(src, dst, hs); // a positive edge supersedes a stale "no edge" record for (int i = 0; i < NEIGHBOUR_LINK_TABLE_SIZE; i++) { Link& l = _links[i]; if (l.active && l.hash_size == hs && _same(l.src, src, hs) && _same(l.dst, dst, hs)) { @@ -113,7 +149,50 @@ class NeighbourLinkTable { // Evict expired edges. Call from loop(). void purge(uint32_t now) { for (int i = 0; i < NEIGHBOUR_LINK_TABLE_SIZE; i++) { - if (_links[i].active && _expired(_links[i], now)) _links[i].active = false; + if (_links[i].active && _expired(_links[i], now)) { + _links[i].active = false; + } + } + } + + // --- Negative-result cache (probed but no edge) -------------------------- + // Record/refresh a directed "probed, no edge" result src->dst. `hs` is expected + // to equal NEIGHBOUR_LINK_NEG_HASH_SIZE (kept for API symmetry with addEdge). + // Mirrors addEdge: dedup + refresh, else LRU ring insert. Called from MyMesh on + // TRACE 2nd-miss timeout and on weak return (SNR < snr_lo). + void addNegative(const uint8_t* src, const uint8_t* dst, uint8_t hs, uint32_t now) { + (void)hs; // fixed-width NEIGHBOUR_LINK_NEG_HASH_SIZE; callers always pass that + for (int i = 0; i < NEIGHBOUR_LINK_NEG_TABLE_SIZE; i++) { + NegLink& n = _neg[i]; + if (n.active && _same(n.src, src, NEIGHBOUR_LINK_NEG_HASH_SIZE) && _same(n.dst, dst, NEIGHBOUR_LINK_NEG_HASH_SIZE)) { + n.last_seen_ms = now; // refresh the backoff window + return; + } + } + NegLink& n = _neg[_neg_next_idx]; // LRU ring overwrite + _neg_next_idx = (_neg_next_idx + 1) % NEIGHBOUR_LINK_NEG_TABLE_SIZE; + memcpy(n.src, src, NEIGHBOUR_LINK_NEG_HASH_SIZE); + memcpy(n.dst, dst, NEIGHBOUR_LINK_NEG_HASH_SIZE); + n.last_seen_ms = now; + n.active = true; + } + + // Fresh "probed, no edge" record for src->dst? (`hs` expected == NEIGHBOUR_LINK_NEG_HASH_SIZE.) + // Consulted ONLY by stepCoverageMeasurement to skip re-probing; NEVER affects coverage inference. + bool hasNegative(const uint8_t* src, const uint8_t* dst, uint8_t hs, uint32_t now) const { + (void)hs; + for (int i = 0; i < NEIGHBOUR_LINK_NEG_TABLE_SIZE; i++) { + const NegLink& n = _neg[i]; + if (!n.active || _neg_expired(n, now)) continue; + if (_same(n.src, src, NEIGHBOUR_LINK_NEG_HASH_SIZE) && _same(n.dst, dst, NEIGHBOUR_LINK_NEG_HASH_SIZE)) return true; + } + return false; + } + + // Evict expired negative entries. Call from loop() alongside purge(). + void purgeNegative(uint32_t now) { + for (int i = 0; i < NEIGHBOUR_LINK_NEG_TABLE_SIZE; i++) { + if (_neg[i].active && _neg_expired(_neg[i], now)) _neg[i].active = false; } } @@ -122,4 +201,20 @@ class NeighbourLinkTable { // uint32 subtraction is wrap-safe for any ttl well below the wrap period. return (uint32_t)(now - l.last_seen_ms) > NEIGHBOUR_LINK_TTL_MILLIS; } + + static bool _neg_expired(const NegLink& n, uint32_t now) { + return (uint32_t)(now - n.last_seen_ms) > NEIGHBOUR_LINK_NEG_TTL_MILLIS; + } + + // A fresh positive edge src->dst supersedes any stale "no edge" record for the same + // directed pair (a link that has improved). Prefix match on the common width -- + // addEdge's hs is always the measurement width (2) == NEIGHBOUR_LINK_NEG_HASH_SIZE, + // but stay tolerant if hs ever differs. + void _clearNegative(const uint8_t* src, const uint8_t* dst, uint8_t hs) { + uint8_t m = (hs < (uint8_t)NEIGHBOUR_LINK_NEG_HASH_SIZE) ? hs : (uint8_t)NEIGHBOUR_LINK_NEG_HASH_SIZE; + for (int i = 0; i < NEIGHBOUR_LINK_NEG_TABLE_SIZE; i++) { + NegLink& n = _neg[i]; + if (n.active && _same(n.src, src, m) && _same(n.dst, dst, m)) n.active = false; + } + } }; From 80f6ff44b29bb57168848fc6f7e1ea14bf3a6920 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Mon, 3 Aug 2026 07:42:51 +0200 Subject: [PATCH 09/15] feat: improve reachability tracking and exclusion logic for flood suppression --- examples/simple_repeater/MyMesh.cpp | 138 ++++++++++++++++++++++------ examples/simple_repeater/MyMesh.h | 12 +++ 2 files changed, 124 insertions(+), 26 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b7d8e3086c..ebfae63850 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -79,6 +79,14 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn } } + // Part 3: a NEW identity in this slot (empty slot, or LRU eviction of a *different* neighbour) + // must start with unknown M-reachability. A refresh of the SAME neighbour (the common case -- + // putNeighbour runs on every ~2-min advert) keeps its reachability state intact. + if (!neighbour->id.matches(id)) { + neighbour->m_reach_confirmed = false; + neighbour->m_reach_timeouts = 0; + neighbour->m_reach_last_ok_ms = 0; + } // update neighbour info neighbour->id = id; neighbour->advert_timestamp = timestamp; @@ -128,6 +136,25 @@ bool MyMesh::isNearNeighbour(int i, uint32_t now) const { #endif } +// Part 3: should neighbours[i] be EXCLUDED from the flood-suppression protection set? True when M +// cannot transmit-reach it -- inferred from coverage-TRACE first-hop outcomes (M->N is never +// measured directly). A confirmed link (a [N,*] trace returned within M_REACH_RECONFIRM_MS) is +// protected: sticky -- never excluded on later transient timeouts, so no starvation regression vs +// today. An unconfirmed (or aged) link with >= M_REACH_UNREACHABLE_TIMEOUTS consecutive first-hop +// timeouts is M-unreachable: M owes it no coverage (M's rebroadcast never reached it anyway), so it +// must not force a futile self-forward or block suppression. +bool MyMesh::isExcludedFromProtection(int i, uint32_t now_ms) const { +#if MAX_NEIGHBOURS + const NeighbourInfo& ni = neighbours[i]; + bool confirmed = ni.m_reach_confirmed && + (uint32_t)(now_ms - ni.m_reach_last_ok_ms) < M_REACH_RECONFIRM_MS; // aging + if (confirmed) return false; // M->i known good -> protect + return ni.m_reach_timeouts >= M_REACH_UNREACHABLE_TIMEOUTS; // never confirmed (or aged) & failing +#else + (void)i; (void)now_ms; return false; +#endif +} + // Return the index of a NEAR neighbour whose path-hash matches (or -1). A // forwarded flood carries only forwarder path hashes, so this matches known // neighbours only (cannot seed new ones -- same limit as touchNeighbourByHash). @@ -185,12 +212,15 @@ bool MyMesh::allNearNeighboursCovered(const FloodSuppressionEntry& e, uint32_t n #if MAX_NEIGHBOURS int8_t top[NEAR_NEIGHBOUR_COVERAGE_CAP]; uint8_t n = topNearNeighbours(top, NEAR_NEIGHBOUR_COVERAGE_CAP, now); + uint8_t prot = 0; // protected (M-reachable) peers M owes coverage for (uint8_t k = 0; k < n; k++) { + if (isExcludedFromProtection(top[k], millis())) continue; // Part 3: M can't reach -> not owed + prot++; if (!e.covers((uint8_t)top[k])) return false; } - return n > 0; + return prot > 0; #else - return false; + (void)e; (void)now; return false; #endif } @@ -278,6 +308,15 @@ void MyMesh::onTraceRecv(mesh::Packet* /*packet*/, uint32_t tag, uint32_t /*auth _nbr_links.addNegative(path_hashes, path_hashes + entry_sz, entry_sz, millis()); _meas_neg++; } + // Part 3: this trace returned, so its FIRST HOP a (= path_hashes) received M's TX -> M->a + // works. Confirm a so it is never excluded from the protection set on later transient + // first-hop timeouts (sticky-confirm with aging -- see isExcludedFromProtection). + int8_t ia = findNearNeighbour(path_hashes, TRACE_MEAS_HASH_SIZE, getRTCClock()->getCurrentTime()); + if (ia >= 0) { + neighbours[ia].m_reach_confirmed = true; + neighbours[ia].m_reach_timeouts = 0; + neighbours[ia].m_reach_last_ok_ms = millis(); + } } for (uint8_t i = 0; i < TRACE_PENDING_MAX; i++) { // retire the matching pending entry (success) if (_trace_pending[i].active && _trace_pending[i].tag == tag) { _trace_pending[i].active = false; break; } @@ -319,6 +358,11 @@ void MyMesh::stepCoverageMeasurement() { _meas_timeout++; _nbr_links.addNegative(_trace_pending[i].a, _trace_pending[i].b, TRACE_MEAS_HASH_SIZE, now); _meas_neg++; // cache no-edge so we don't re-probe every tick + // Part 3: the FIRST HOP a may be M-unreachable (M->a broken -> the trace never left M, so it + // timed out regardless of b). Bump a's consecutive-failure count; once it reaches the + // threshold without ever being confirmed, isExcludedFromProtection drops it from protection. + int8_t ia = findNearNeighbour(_trace_pending[i].a, TRACE_MEAS_HASH_SIZE, getRTCClock()->getCurrentTime()); + if (ia >= 0 && neighbours[ia].m_reach_timeouts < 255) neighbours[ia].m_reach_timeouts++; } } @@ -990,40 +1034,43 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { int8_t top[NEAR_NEIGHBOUR_COVERAGE_CAP]; uint8_t top_n = topNearNeighbours(top, NEAR_NEIGHBOUR_COVERAGE_CAP, now); - bool on_path[MAX_NEIGHBOURS] = { false }; + // Which NEAR neighbours forwarded F. Uses findNearNeighbour (ALL near, not just top-N) so + // a rank-(cap+1) forwarder with a harvested cross-rank edge to a top-N neighbour can still + // mark it covered in (b). forwarded[f] true => f is a near neighbour that forwarded F. + bool forwarded[MAX_NEIGHBOURS] = { false }; for (uint8_t k = 0; k < count; k++) { - int8_t idx = findInTopNear(p, hs, top, top_n); - if (idx >= 0) on_path[idx] = true; // this coverage peer forwarded F => has F + int8_t idx = findNearNeighbour(p, hs, now); + if (idx >= 0) forwarded[idx] = true; // this near neighbour forwarded F => has F p += hs; } - // (b) COVERAGE: each coverage-peer forwarder covers itself + the coverage peers - // it REACHES via a fresh measured edge (N heard fi's forward => N has F). - // Graph edges are measured at the canonical TRACE hash width. + // (b) COVERAGE: a protected peer j has F if it forwarded F (certain), or if any near + // forwarder f REACHES it via a fresh measured/harvested edge f->j (j heard f's forward + // => j has F, inferred). Edges at the canonical TRACE hash width. This is where a + // harvested cross-rank edge f(rank>cap)->j(top-N) first pays off (Part 2). for (uint8_t a = 0; a < top_n; a++) { - int i = top[a]; - if (!on_path[i]) continue; // i must be a forwarder of F - e->addCovered((uint8_t)i); // i forwarded F => i has F (certain) - for (uint8_t b = 0; b < top_n; b++) { - int j = top[b]; - if (j == i || on_path[j]) continue; // j already has F - if (nearReaches(i, j, TRACE_MEAS_HASH_SIZE)) e->addCovered((uint8_t)j); + int j = top[a]; + if (forwarded[j]) { e->addCovered((uint8_t)j); continue; } // j forwarded F => has F (certain) + for (int f = 0; f < MAX_NEIGHBOURS; f++) { // any near forwarder reaches j? + if (f == j || !forwarded[f]) continue; + if (nearReaches(f, j, TRACE_MEAS_HASH_SIZE)) { e->addCovered((uint8_t)j); break; } } } - // (c) ISOLATED-PEER FAST-FORWARD: a coverage peer that NO other coverage peer - // reaches (in-degree 0) and did NOT forward F can ONLY be covered by M's - // own TX -> M must forward. Also the cold-start behaviour (graph empty - // before any TRACE completes). getRetransmitDelay reads must_cover_self. + // (c) MUST-COVER-SELF: a protected non-forwarder i that NO near forwarder reaches can only + // be covered by M's own TX -> M must forward. (Cold-start: graph empty before any TRACE + // completes, so every i is unreachable -> M forwards, as intended.) Part 3: skip i that + // M cannot transmit-reach -- M's TX would never reach it anyway, so it must not force a + // futile self-forward (nor block suppression for the peers M CAN reach). e->must_cover_self = false; for (uint8_t a = 0; a < top_n && !e->must_cover_self; a++) { int i = top[a]; - if (on_path[i]) continue; // i forwarded F -> covered + if (isExcludedFromProtection(i, millis())) continue; // Part 3: M->i broken -> not owed coverage + if (forwarded[i]) continue; // i forwarded F -> covered bool reachable = false; - for (uint8_t b = 0; b < top_n; b++) { // does any coverage peer j reach i? - int j = top[b]; - if (j == i) continue; - if (nearReaches(j, i, TRACE_MEAS_HASH_SIZE)) { reachable = true; break; } + for (int f = 0; f < MAX_NEIGHBOURS; f++) { // does any near forwarder f reach i? + if (f == i || !forwarded[f]) continue; + if (nearReaches(f, i, TRACE_MEAS_HASH_SIZE)) { reachable = true; break; } } if (!reachable) e->must_cover_self = true; } @@ -1039,6 +1086,39 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { #endif } } +#if MAX_NEIGHBOURS + // --- Passive TRACE harvest (Part 2) ----------------------------------------- + // Overhear a coverage TRACE [a,b,initiator] that another repeater emitted for ITS own suppression + // and adopt its measured a->b edge -- a richer reach graph at 0 extra airtime (TRACEs are + // cleartext; logRx fires for every received packet). Only the FINAL leg carries path[1] = the + // a->b SNR (b just appended it), so gate on getPathHashCount()>=2. Same SNR gate / direction / + // width / negative-on-weak as onTraceRecv; skip our own trace and any whose a/b are not both ours. + if (effectiveFloodSuppressC() > 0 && pkt->isRouteDirect() + && pkt->getPayloadType() == PAYLOAD_TYPE_TRACE + && pkt->payload_len >= 9 + 3 * TRACE_MEAS_HASH_SIZE) { + uint8_t flags = pkt->payload[8]; + uint8_t entry_sz = 1 << (flags & 0x03); + if ((flags & TRACE_FLAG_TERMINATE_AT_LAST) && entry_sz == TRACE_MEAS_HASH_SIZE + && (pkt->payload_len - 9) / entry_sz == 3 // exactly [a,b,initiator] + && pkt->getPathHashCount() >= 2) { // final leg: path[1] = a->b SNR + const uint8_t* visit = pkt->payload + 9; // [a(2), b(2), initiator(2)] + if (!self_id.isHashMatch(visit + 2 * entry_sz, entry_sz)) { // not our own trace + uint32_t now = getRTCClock()->getCurrentTime(); + if (findNearNeighbour(visit, entry_sz, now) >= 0 + && findNearNeighbour(visit + entry_sz, entry_sz, now) >= 0) { // both a,b are our near + int8_t snr_ab_x4 = (int8_t)pkt->path[1]; + if (snr_ab_x4 >= (int8_t)(_prefs.flood_suppress_snr_lo * 4)) { + _nbr_links.addEdge(visit, visit + entry_sz, entry_sz, millis()); // a reaches b (clears stale neg) + _meas_harvested++; + } else { + _nbr_links.addNegative(visit, visit + entry_sz, entry_sz, millis()); + _meas_harvest_neg++; + } + } + } + } + } +#endif #ifdef WITH_BRIDGE if (_prefs.bridge_pkt_src == 1) { bridge.sendPacket(pkt); @@ -1899,9 +1979,15 @@ void MyMesh::formatNearReply(char *reply) { // as no-edge (timeout or weak return) and skipped until the ~10h backoff expires. If sent>0 // but ret==0 the round trips never complete (loss/collisions); if ret>0 but edge==0 the // measured inter-neighbour links are below snr_lo; if sent==0 no top-N>=2 window yet. - sprintf(dp, "\nmeas sent=%lu ret=%lu edge=%lu tmo=%lu neg=%lu", + // harv=edges/negatives adopted from overheard neighbours' TRACES (Part 2); unr=near neighbours + // M cannot transmit-reach and so excludes from the protection set (Part 3). + uint8_t unr = 0; + for (int i = 0; i < MAX_NEIGHBOURS; i++) + if (isNearNeighbour(i, now) && isExcludedFromProtection(i, millis())) unr++; + sprintf(dp, "\nmeas sent=%lu ret=%lu edge=%lu tmo=%lu neg=%lu harv=%lu unr=%u", (unsigned long)_meas_sent, (unsigned long)_meas_returned, - (unsigned long)_meas_edge, (unsigned long)_meas_timeout, (unsigned long)_meas_neg); + (unsigned long)_meas_edge, (unsigned long)_meas_timeout, (unsigned long)_meas_neg, + (unsigned long)_meas_harvested, (unsigned)unr); while (*dp) dp++; // 150-byte ceiling minus a worst-case entry (~26B: \n + ~ + 8hex + :secs:snr) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 94dae8399b..1c68fd399d 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -80,12 +80,22 @@ struct RepeaterStats { #define TRACE_MEAS_TIMEOUT_MS 3000 // retry once, then give up, if a coverage TRACE does not return in time #define TRACE_TX_POWER_RESTORE_MS 2000 // restore normal TX power this long after a measurement burst #define TRACE_PENDING_MAX 8 // in-flight coverage traces (<=4 pairs x 2 directions) +// Part 3 -- unidirectional-link handling. M->N is never measured directly; it is inferred +// from coverage-TRACE first-hop outcomes: a [N,*] trace returns iff M's TX reached N. After +// K consecutive first-hop-N 2nd-miss timeouts (and no success), N is treated M-unreachable +// and dropped from the protection set (M owes coverage only to neighbours it can reach). +#define M_REACH_UNREACHABLE_TIMEOUTS 2 // consec first-hop-N timeouts -> M-unreachable +#define M_REACH_RECONFIRM_MS (24UL*3600UL*1000UL) // re-test a confirmed link after this idle (antenna drift) struct NeighbourInfo { mesh::Identity id; uint32_t advert_timestamp; uint32_t heard_timestamp; int8_t snr; // multiplied by 4, user should divide to get float value + // Part 3: M->this-neighbour reachability, inferred from coverage-TRACE first-hop outcomes. + bool m_reach_confirmed; // a [N,*] coverage trace has returned (M->N works) + uint8_t m_reach_timeouts; // consecutive first-hop-N 2nd-miss timeouts since last confirm + uint32_t m_reach_last_ok_ms; // millis() of the last first-hop-N success (aging) }; // A leaf CLIENT (companion/sensor/room-server) directly attached to this repeater @@ -163,6 +173,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { unsigned long _trace_tx_revert_at = 0; // restore TX power after a burst uint8_t _meas_rr_offset = 0; // round-robin start index into the flat directed-pair list (advanced per probe) uint32_t _meas_sent = 0, _meas_returned = 0, _meas_edge = 0, _meas_timeout = 0, _meas_neg = 0; // coverage-TRACE observability (surfaced in `near`) + uint32_t _meas_harvested = 0, _meas_harvest_neg = 0; // Part 2: edges/negatives adopted from overheard neighbours' TRACES (surfaced in `near` as harv) uint32_t pending_discover_tag; unsigned long pending_discover_until; bool region_load_active; @@ -186,6 +197,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { 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 bool isNearNeighbour(int i, uint32_t now) const; // fresh (<=NEIGHBOUR_FRESH_S) and SNR>=snr_lo + bool isExcludedFromProtection(int i, uint32_t now_ms) const; // M cannot transmit-reach neighbours[i] -> not owed coverage int8_t findNearNeighbour(const uint8_t* h, uint8_t hs, uint32_t now) const; // index of near neighbour matching hash, else -1 uint8_t topNearNeighbours(int8_t out[], uint8_t max_n, uint32_t now) const; // fill out[] with up to max_n near-neighbour INDICES, strongest SNR first int8_t findInTopNear(const uint8_t* h, uint8_t hs, const int8_t* top, uint8_t top_n) const; // index (into neighbours[]) of a top-N peer matching hash, else -1 From d940938b4e4f3d25a72bcae4bc334e8a11c249b7 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Mon, 3 Aug 2026 08:08:23 +0200 Subject: [PATCH 10/15] feat: enhance flood suppression documentation with coverage probing details and unidirectional link handling --- docs/README-flood-suppression.md | 105 ++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 35 deletions(-) diff --git a/docs/README-flood-suppression.md b/docs/README-flood-suppression.md index 40d55aa469..7b2f5d2943 100644 --- a/docs/README-flood-suppression.md +++ b/docs/README-flood-suppression.md @@ -29,7 +29,8 @@ can see — it only removes rebroadcasts that would be redundant. ## Concepts ### Near neighbours — the coverage set -- **Near** = heard recently (`<= 1 h`) **and** link SNR `>= flood.suppress.snr.lo`. +- **Near** = heard recently (`<= 6 h` — a deliberately wide window so a briefly-silent but + still-reachable repeater doesn't churn out of the set) **and** link SNR `>= flood.suppress.snr.lo`. - Coverage is guaranteed only for the **strongest few** near neighbours (`NEAR_NEIGHBOUR_COVERAGE_CAP = 5`). A rank-6+ near peer is *not* owed coverage — the strongest forwarders cover the most nodes, so guaranteeing only the top few bounds @@ -42,38 +43,51 @@ Two sources, combined across every overheard copy of F: 1. **Direct** — the neighbour itself forwarded F (M saw its hash on an overheard path). Certain. 2. **Reach-graph** — the neighbour was *reached* by a near forwarder **fi** via a fresh - **directed** measured edge `fi -> N` (N heard fi's forward of F). Inferred from the - reach graph below. + **directed** edge `fi -> N` (N heard fi's forward of F). Inferred from the reach graph + below. **fi** may be *any* near forwarder (rank-6+ too, not only the top-5), so an edge + from a weaker forwarder can still mark a top-5 neighbour covered. -### The reach graph — actively measured, not inferred +### The reach graph — measured (actively + passively), not inferred from flood paths - An edge `fi -> N` means *"N can hear fi's transmissions"* (fi reaches N). - **Directed.** RF links are asymmetric — A hearing B does **not** mean B hears A. The graph must not infer forward reach from a reverse observation, or M could suppress a rebroadcast and starve a neighbour that is actually deaf toward the forwarder. -- Edges are established by **active TRACE coverage probes** (the earlier design inferred - them from overheard flood paths, but that stayed empty in sparse/mast topologies where - two of M's near neighbours never appear consecutively in one flood path). -- **Probe:** a round-trip TRACE with visit-list `[a, b, self]` (2-byte hashes) walks - `self -> a -> b -> self`. The SNR measured at **b** of **a**'s forward is exactly - *"does b hear a"*. A new core flag `TRACE_FLAG_TERMINATE_AT_LAST` delivers the result - back at the initiator instead of a bystander. -- Measured only on demand: when the top-5 set changes (new member / displacement) or after - a **36 h** refresh. One probe per cadence tick (HW ~60 s) to avoid round-trip collisions. -- An edge is recorded iff the measured SNR `>= flood.suppress.snr.lo`; TTL 36 h. +- Edges come from **two sources**: + 1. **Active probe** — a round-trip TRACE with visit-list `[a, b, self]` (2-byte hashes) + walks `self -> a -> b -> self`. The SNR measured at **b** of **a**'s forward is + exactly *"does b hear a"*. The core flag `TRACE_FLAG_TERMINATE_AT_LAST` delivers the + result back at the initiator instead of a bystander. (The earlier design inferred + edges from overheard flood paths, but that stayed empty in sparse/mast topologies.) + 2. **Passive harvest** — TRACEs are cleartext, so when a neighbouring repeater runs its + *own* coverage probe `[a, b, initiator]`, any radio in range can read it. M overhears + the probe's final leg (which carries the measured a→b SNR) and adopts the a→b edge + itself. A cluster thus builds a shared graph without each node re-probing every pair. + M ignores probes it initiated and ones whose `a`/`b` are not both its own near + neighbours. **No extra airtime** — receive-only. +- **Probing cadence:** when the top-5 set changes (new member / displacement) or after + refresh; **one probe per cadence tick** (HW ~60 s), the pair chosen **round-robin** so no + single pair is fixated on. An edge is recorded iff the measured SNR + `>= flood.suppress.snr.lo`; positive edges TTL **36 h**. +- **Negative cache:** a pair that yields *no* edge (probe timed out twice, or returned + below `snr.lo`) is cached for **10 h** and not re-probed meanwhile — otherwise probing + never converges (early hardware showed `sent` climbing forever). Coverage inference reads + **positive** edges only, so a cached absence is never treated as coverage. - **1-hop, not transitive** — "reached by fi" means *could hear fi's specific forward*. -### SNR weighting of overheard forwards -Each overheard neighbour forward counts toward "covered", weighted by the SNR it was -heard at: -- `>= flood.suppress.snr.hi` → counts **double** (a strong/central relay almost certainly reached others too); -- `< flood.suppress.snr.lo` → counts **0** (a marginal relay likely didn't reach the edge — preserve reach). - -### Adaptive threshold C (not user-configurable) -The cancellation threshold **C** — how much "already covered" weight is needed before M -suppresses — is **derived from the neighbour table** (an adaptive density estimate: more -near neighbours ⇒ more redundancy required before cancelling) with a static fallback. So a -dense cluster tolerates more redundancy before suppressing; a sparse one stays -conservative. There is no `set flood.suppress.c`. +### The SNR thresholds — what they actually gate +Coverage itself is **binary** — a neighbour either forwarded F, or was reached by a +forwarder's edge. It is *not* SNR-weighted. The two thresholds gate other things: +- **`snr.lo`** — the **near-set floor** (only neighbours heard at ≥ this are "near" / owed + coverage) and the **reach-edge floor** (an a→b link is recorded only if measured ≥ this). +- **`snr.hi`** — drives **cancel-window widening** for central relays (next section) and + feeds the adaptive density estimate. + +### Adaptive enable C (not user-configurable) +Suppression only runs when the neighbour table is dense enough to be worth it: **C** is +**derived from the neighbour table** (an adaptive density estimate) with a static fallback, +and the feature engages only while `C > 0`. C is **not** a "covered weight" threshold — the +decision itself is the binary coverage test above; C merely gates whether that test runs at +all, so a sparse or isolated node doesn't churn on it. There is no `set flood.suppress.c`. ### Cancel-window widening (`delay.factor`) A flood M would relay centrally (heard at SNR `>= snr.hi`) gets its random TX-delay window @@ -99,6 +113,20 @@ Before any TRACE completes, the reach graph is empty ⇒ uncovered peers trigger `must_cover_self` ⇒ **M forwards everything**. No starvation, no regression vs. firmware without the feature. Suppression only begins once real reachability has been measured. +### Unidirectional links (heard but not reachable) +Near-set membership is based on M *hearing* N (the N→M link). The reverse direction (M→N) +is never measured directly — yet a neighbour M cannot actually *reach* (e.g. M's sector +antenna doesn't cover it) should not block suppression: M's rebroadcast would never reach +it anyway. M infers M→N from coverage-probe outcomes: a probe `[a=N, b, self]` returns iff +M's transmission reached N, and **always times out** when M→N is broken. After a couple of +first-hop-N timeouts with no success, such an N is flagged M-unreachable and **dropped from +the protection set** — it no longer forces `must_cover_self`, so one asymmetric link can't +deadlock M's suppression. An unreachable N may still *contribute* coverage as a forwarder +(its N→j edges stay valid). A single later successful probe re-protects N permanently +(sticky confirm, with a 24 h re-test for antenna drift), and it can never starve N: M's TX +never reached it, and its flood supply comes from forwarders it can hear. The `unr` token +in [`near`](cli_commands.md#near) counts neighbours currently excluded. + --- ## When it helps vs. when it is inert @@ -111,6 +139,9 @@ without the feature. Suppression only begins once real reachability has been mea - Even with an empty graph, the **direct-coverage** path can still suppress: once M has overheard *every* near neighbour forward F, `allNearNeighboursCovered` fires and the rebroadcast is cancelled — no reach edge required. +- **Shared-neighbour clusters** benefit most from the passive harvest: when several + coverage-capable repeaters are mutually in range, each adopts edges from the others' + probes, so the graph fills faster and more cross-forwarder coverage is inferred. --- @@ -119,11 +150,11 @@ without the feature. Suppression only begins once real reachability has been mea | Command | Effect | |---------|--------| | `get/set flood.suppress ` | Master switch. `get` also prints the suppression ratio (`suppressed a/b (p%)`). | -| `get/set flood.suppress.snr.hi ` | SNR `>=` this counts double (`-30..30`, default `9`). | -| `get/set flood.suppress.snr.lo ` | SNR `<` this counts 0; also the near-set floor and reach-edge floor (`-30..30`, default `0`). | +| `get/set flood.suppress.snr.hi ` | Central-relay threshold: a flood heard at `>=` this gets its cancel window widened, and it feeds the adaptive density estimate (`-30..30`, default `9`). | +| `get/set flood.suppress.snr.lo ` | Near-set floor (a neighbour must be heard at `>=` this to be "near") and reach-edge floor (an a→b link is recorded only if measured `>=` this) (`-30..30`, default `0`). | | `get/set flood.suppress.delay.factor ` | Cancel-window multiplier for central relays (`0..8`, default `2`). | | `get/set trace.tx.power ` | TX power for coverage TRACE probes only (`-9..30`, default `10`). | -| `near` | Near coverage peers (strongest first) + TRACE probe health (`meas sent/ret/edge/tmo`). | +| `near` | Near coverage peers (strongest first) + the probe-health line `meas sent=… ret=… edge=… tmo=… neg=… harv=… unr=…` (tokens explained under Tuning). | | `reach ` | Directed reach edges of one near repeater. | | `clients` | Attached leaf clients. | @@ -134,7 +165,7 @@ Full syntax and output formats: [`cli_commands.md`](cli_commands.md#flood-suppre ## Tuning & troubleshooting ### Read the state first -- `near` → the near set and the `meas sent=… ret=… edge=… tmo=…` probe-health line. +- `near` → the near set and the `meas sent=… ret=… edge=… tmo=… neg=… harv=… unr=…` probe-health line. - `reach ` → whether measured directed edges exist for a given peer. - `get flood.suppress` → on/off + the live suppression ratio. @@ -154,6 +185,9 @@ line's `ret` should rise and edges appear in `reach`. Reading the `meas` line: | `sent>0 ret=0` | Probes go out but no round trip completes — loss/collisions, or the first hop failing (see `trace.tx.power`). | | `ret>0 edge=0` | Round trips complete but the measured link is below `snr.lo` — genuinely weak / no inter-neighbour reachability. | | `ret>0 edge>0` | Edges recorded — `reach` should list them. | +| `neg>0` | Pairs cached as no-edge (timed out / weak); not re-probed for ~10 h. Expected to grow as the graph converges, after which `sent` levels off. | +| `harv>0` | Edges adopted from *overheard* neighbours' probes (passive harvest). `0` is normal when no other coverage-capable repeater is in range. | +| `unr>0` | Near neighbours M cannot transmit-reach (asymmetric link), excluded from the protection set. | ### Near set churning (peers blink in/out) At a low `snr.lo` (e.g. `0`), marginal neighbours keep crossing the threshold. Raise @@ -168,18 +202,19 @@ near peers, faster graph convergence. --- ## How it is wired (files) -- `src/helpers/NeighbourLinkTable.h` — the directed reach-graph (`addEdge`/`hasEdge`/`purge`, ring 128, 36 h TTL). `hasEdge` is width-tolerant (prefix match); writes are exact-width. +- `src/helpers/NeighbourLinkTable.h` — the directed reach-graph (`addEdge`/`hasEdge`/`purge`, ring 128, 36 h positive TTL) **plus a negative-result ring** (`addNegative`/`hasNegative`/`purgeNegative`, 10 h TTL) so no-edge pairs aren't re-probed to death. `hasEdge` is width-tolerant (prefix match); writes are exact-width. - `src/helpers/FloodSuppression.h` — per-flood entry with `covered` set, `must_cover_self`, cancel + wait-window. - `src/Mesh.cpp` + `src/Packet.h` — `TRACE_FLAG_TERMINATE_AT_LAST`: delivers a coverage TRACE back at its initiator (the one core change). -- `examples/simple_repeater/MyMesh.{h,cpp}` — the coverage test (`logRx`), `stepCoverageMeasurement()` (active TRACE scheduling, from `loop()`), `onTraceRecv` (records edges), the always-on 3-tier `clientProtectionAllowsSuppress`, the `near`/`reach`/`clients` reply formatters, and the `trace_tx_power_dbm` burst handling. +- `examples/simple_repeater/MyMesh.{h,cpp}` — the coverage test (`logRx`: suppression decision + passive TRACE harvest), `stepCoverageMeasurement()` (active TRACE scheduling with round-robin pair selection, from `loop()`), `onTraceRecv` (records edges + confirms M-reachability), `isExcludedFromProtection` (unidirectional-link handling), the always-on 3-tier `clientProtectionAllowsSuppress`, the `near`/`reach`/`clients` reply formatters, and the `trace_tx_power_dbm` burst handling. - `src/helpers/CommonCLI.{h,cpp}` + `NodePrefs` — the CLI commands and persisted prefs above. --- ## Honest limits - Coverage is guaranteed only for the **top-5** near neighbours. -- **Invisible neighbours** (asymmetric, absent from M's table) cannot be protected by any - table-based method. `set flood.suppress off` (or manual per-neighbour exclusion) remains - the safety net. +- **Unidirectional links** (M hears N but cannot reach N) are detected and excluded from + the protection set (above) — but **truly invisible** neighbours (asymmetric, absent from + M's table entirely) cannot be protected by any table-based method. `set flood.suppress + off` (or manual per-neighbour exclusion) remains the safety net there. - Reach is inferred from a *historically measured* edge ⇒ a small false-positive risk if an edge has since gone stale, mitigated by fresh-edge-only use + 36 h TTL + cold-start safety. From 877792817dc4b3fad3009837ab69378e78aeb92d Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Mon, 3 Aug 2026 20:30:48 +0200 Subject: [PATCH 11/15] feat: implement per-pair exponential backoff for negative-result caching in coverage probing --- examples/simple_repeater/MyMesh.cpp | 5 ++-- src/helpers/NeighbourLinkTable.h | 43 +++++++++++++++++++++-------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index ebfae63850..e904c23629 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -304,7 +304,7 @@ void MyMesh::onTraceRecv(mesh::Packet* /*packet*/, uint32_t tag, uint32_t /*auth _meas_edge++; // ...and the a->b link was strong enough to record } else { // Returned but weak: the a->b link exists yet cannot carry coverage. Cache as no-edge so it - // is not re-probed every tick; it retries only after NEIGHBOUR_LINK_NEG_TTL_MILLIS (~10h). + // is not re-probed every tick; it retries on a per-pair exponential backoff (capped ~10h). _nbr_links.addNegative(path_hashes, path_hashes + entry_sz, entry_sz, millis()); _meas_neg++; } @@ -1976,7 +1976,8 @@ void MyMesh::formatNearReply(char *reply) { // coverage-TRACE health: sent=attempts, ret=round-trips that came back, edge=links // recorded (ret with SNR>=snr_lo), tmo=pairs that timed out twice (no link), neg=pairs cached - // as no-edge (timeout or weak return) and skipped until the ~10h backoff expires. If sent>0 + // as no-edge (timeout or weak return) and skipped on a per-pair exponential backoff (capped + // ~10h; a transient failure retries within ~2 min, a permanent one ramps to ~10h). If sent>0 // but ret==0 the round trips never complete (loss/collisions); if ret>0 but edge==0 the // measured inter-neighbour links are below snr_lo; if sent==0 no top-N>=2 window yet. // harv=edges/negatives adopted from overheard neighbours' TRACES (Part 2); unr=near neighbours diff --git a/src/helpers/NeighbourLinkTable.h b/src/helpers/NeighbourLinkTable.h index 914d640371..afd5d9c2c4 100644 --- a/src/helpers/NeighbourLinkTable.h +++ b/src/helpers/NeighbourLinkTable.h @@ -32,16 +32,19 @@ // never missed solely because the caller used a different hash width. Small ring // with TTL eviction (~36 h -- repeater topology is stable); swept from loop(). // -// --- Negative-result cache ------------------------------------------------- +// --- Negative-result cache (per-pair exponential backoff) ------------------- // // A directed pair that was actively TRACE-probed but produced NO edge -- because // the trace timed out (after its single retry) or returned below snr_lo -- is // recorded in a separate ring (see NegLink) so stepCoverageMeasurement() does NOT -// re-probe it every cadence tick. Without this, absent/weak (often asymmetric) -// pairs are re-probed forever (~1/min) since they never yield a positive edge. -// TTL is shorter than a positive edge (10 h vs 36 h): a good link that was merely -// disturbed during the two probe attempts recovers sooner. This cache is -// consulted ONLY to gate re-probing; coverage inference reads POSITIVE edges +// re-probe it every cadence tick. The re-probe backoff is PER PAIR and EXPONENTIAL: +// the first failure waits BASE (~2 min) -- so a transient cause (a momentarily-silent +// forwarder, a brief collision) heals on the next probe -- and each CONSECUTIVE failure +// doubles the wait, capped at MAX (~10 h). A permanently-absent pair therefore ramps +// 2,4,8,... min up to one re-probe per ~10 h (the same steady state as a flat 10 h TTL, +// but without the 10 h blind spot for transients), while a good link that recovers is +// cleared immediately by addEdge() (a positive edge supersedes the record). This cache +// is consulted ONLY to gate re-probing; coverage inference reads POSITIVE edges // exclusively (absence is never treated as coverage). #ifndef NEIGHBOUR_LINK_TABLE_SIZE @@ -58,8 +61,11 @@ #ifndef NEIGHBOUR_LINK_NEG_TABLE_SIZE #define NEIGHBOUR_LINK_NEG_TABLE_SIZE 32 // ~20 directed pairs among 5 near neighbours + churn headroom #endif -#ifndef NEIGHBOUR_LINK_NEG_TTL_MILLIS - #define NEIGHBOUR_LINK_NEG_TTL_MILLIS (10UL * 60UL * 60UL * 1000UL) // ~10h before a no-edge pair is re-probed +#ifndef NEIGHBOUR_LINK_NEG_BACKOFF_BASE_MILLIS + #define NEIGHBOUR_LINK_NEG_BACKOFF_BASE_MILLIS (2UL * 60UL * 1000UL) // first backoff after a fresh "no edge" probe (~2 min); a transient cause (a momentarily-silent forwarder, a brief collision) heals on the next probe +#endif +#ifndef NEIGHBOUR_LINK_NEG_BACKOFF_MAX_MILLIS + #define NEIGHBOUR_LINK_NEG_BACKOFF_MAX_MILLIS (10UL * 60UL * 60UL * 1000UL) // cap: each consecutive failure doubles the wait up to ~10 h, so a permanently-absent pair settles to one re-probe per ~10 h (same steady state as a flat 10 h TTL) while a transient one recovers in minutes #endif class NeighbourLinkTable { @@ -80,6 +86,7 @@ class NeighbourLinkTable { uint8_t src[NEIGHBOUR_LINK_NEG_HASH_SIZE]; uint8_t dst[NEIGHBOUR_LINK_NEG_HASH_SIZE]; uint32_t last_seen_ms; + uint32_t backoff_ms; // per-pair re-probe backoff; doubles on each consecutive failure, capped at NEIGHBOUR_LINK_NEG_BACKOFF_MAX_MILLIS bool active; }; NegLink _neg[NEIGHBOUR_LINK_NEG_TABLE_SIZE]; @@ -165,7 +172,13 @@ class NeighbourLinkTable { for (int i = 0; i < NEIGHBOUR_LINK_NEG_TABLE_SIZE; i++) { NegLink& n = _neg[i]; if (n.active && _same(n.src, src, NEIGHBOUR_LINK_NEG_HASH_SIZE) && _same(n.dst, dst, NEIGHBOUR_LINK_NEG_HASH_SIZE)) { - n.last_seen_ms = now; // refresh the backoff window + // Re-probed and failed AGAIN -> looks permanent: double the backoff (capped at + // MAX), so a persistently-absent pair is retried ever more rarely. A transient + // failure never reaches this branch twice -- it is cleared by addEdge() on success. + if (n.backoff_ms == 0) n.backoff_ms = NEIGHBOUR_LINK_NEG_BACKOFF_BASE_MILLIS; + n.backoff_ms *= 2; + if (n.backoff_ms > NEIGHBOUR_LINK_NEG_BACKOFF_MAX_MILLIS) n.backoff_ms = NEIGHBOUR_LINK_NEG_BACKOFF_MAX_MILLIS; + n.last_seen_ms = now; // restart the (now longer) backoff window return; } } @@ -173,6 +186,7 @@ class NeighbourLinkTable { _neg_next_idx = (_neg_next_idx + 1) % NEIGHBOUR_LINK_NEG_TABLE_SIZE; memcpy(n.src, src, NEIGHBOUR_LINK_NEG_HASH_SIZE); memcpy(n.dst, dst, NEIGHBOUR_LINK_NEG_HASH_SIZE); + n.backoff_ms = NEIGHBOUR_LINK_NEG_BACKOFF_BASE_MILLIS; // first failure -> short backoff (fast retry) n.last_seen_ms = now; n.active = true; } @@ -189,10 +203,13 @@ class NeighbourLinkTable { return false; } - // Evict expired negative entries. Call from loop() alongside purge(). + // Reclaim ring slots for pairs long unre-probed. The threshold is well beyond the max + // backoff (2 x MAX), so a pair that is capped at MAX -- which re-probes and refreshes + // itself every MAX -- is NOT evicted mid-ramp (that would reset it to BASE and re-probe + // it far too often). The LRU ring would reclaim the slot anyway; this just defers churn. void purgeNegative(uint32_t now) { for (int i = 0; i < NEIGHBOUR_LINK_NEG_TABLE_SIZE; i++) { - if (_neg[i].active && _neg_expired(_neg[i], now)) _neg[i].active = false; + if (_neg[i].active && (uint32_t)(now - _neg[i].last_seen_ms) > (2UL * NEIGHBOUR_LINK_NEG_BACKOFF_MAX_MILLIS)) _neg[i].active = false; } } @@ -202,8 +219,10 @@ class NeighbourLinkTable { return (uint32_t)(now - l.last_seen_ms) > NEIGHBOUR_LINK_TTL_MILLIS; } + // Has this pair's per-pair re-probe backoff elapsed? (i.e. it is eligible to be + // probed again -- hasNegative returns false for it). uint32 subtraction is wrap-safe. static bool _neg_expired(const NegLink& n, uint32_t now) { - return (uint32_t)(now - n.last_seen_ms) > NEIGHBOUR_LINK_NEG_TTL_MILLIS; + return (uint32_t)(now - n.last_seen_ms) > n.backoff_ms; } // A fresh positive edge src->dst supersedes any stale "no edge" record for the same From e63da2a30fffca9f0cb554e1f895c82c38002d6c Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Wed, 5 Aug 2026 18:02:13 +0200 Subject: [PATCH 12/15] Implement flood suppression feature in simple_repeater - Introduced a redundancy-aware rebroadcast cancellation mechanism to reduce on-air flood traffic and collisions. - Enhanced the putNeighbour function to smooth link-quality estimates for known neighbours during refresh. - Updated touchNeighbourByHash to apply exponential moving average for link quality smoothing. - Modified stepCoverageMeasurement to ensure correct handling of trace measurements with dynamic hash sizes. - Added new metrics for flood suppression statistics in clearStats function. --- README-flood-suppression.md | 273 ----------------- docs/README-flood-suppression.md | 441 ++++++++++++++++------------ examples/simple_repeater/MyMesh.cpp | 20 +- 3 files changed, 261 insertions(+), 473 deletions(-) delete mode 100644 README-flood-suppression.md diff --git a/README-flood-suppression.md b/README-flood-suppression.md deleted file mode 100644 index 0baf3b748d..0000000000 --- a/README-flood-suppression.md +++ /dev/null @@ -1,273 +0,0 @@ -# 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/README-flood-suppression.md b/docs/README-flood-suppression.md index 7b2f5d2943..0baf3b748d 100644 --- a/docs/README-flood-suppression.md +++ b/docs/README-flood-suppression.md @@ -1,220 +1,273 @@ -# Flood Suppression +# Flood Suppression — Redundancy-Aware Rebroadcast Cancellation -> **Status:** experimental · repeater-only (`simple_repeater`) · branch `feature/flood-suppression-coverage`. -> Not yet merged; still being tuned and measured on hardware. +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. -In a dense mesh every repeater rebroadcasts every flood, so most nodes receive many -redundant copies and the air fills with collisions. **Flood suppression** lets a repeater -cancel its *own* scheduled rebroadcast of a flood when its near neighbours are already -covered — cutting redundant on-air traffic while preserving reach. - -See [`cli_commands.md`](cli_commands.md) for the exact command syntax; this document -explains the mechanism and how to tune it. +It is implemented entirely at the **application layer** (`simple_repeater`); the +core library (`Mesh`, `Dispatcher`, `Packet`) is not modified. --- -## The decision, in one paragraph +## Mechanism -Repeater **M** suppresses its rebroadcast of flood **F** **iff** +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. -1. every **near** neighbour is **covered** (already has F), -2. no **isolated uncovered** near neighbour exists (`must_cover_self`), and -3. the **client-aware** protection gate allows it. +This feature turns each repeater into a *listener before it transmits*: -Otherwise M forwards F as normal. Reach is never sacrificed for a neighbour the feature -can see — it only removes rebroadcasts that would be redundant. +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. -## Concepts - -### Near neighbours — the coverage set -- **Near** = heard recently (`<= 6 h` — a deliberately wide window so a briefly-silent but - still-reachable repeater doesn't churn out of the set) **and** link SNR `>= flood.suppress.snr.lo`. -- Coverage is guaranteed only for the **strongest few** near neighbours - (`NEAR_NEIGHBOUR_COVERAGE_CAP = 5`). A rank-6+ near peer is *not* owed coverage — the - strongest forwarders cover the most nodes, so guaranteeing only the top few bounds - airtime while preserving reach. (The adaptive density estimate still counts *all* fresh - neighbours, so this cap does not weaken the threshold.) -- Inspect live with [`near`](cli_commands.md#near). - -### Coverage — how M knows a neighbour already has F -Two sources, combined across every overheard copy of F: - -1. **Direct** — the neighbour itself forwarded F (M saw its hash on an overheard path). Certain. -2. **Reach-graph** — the neighbour was *reached* by a near forwarder **fi** via a fresh - **directed** edge `fi -> N` (N heard fi's forward of F). Inferred from the reach graph - below. **fi** may be *any* near forwarder (rank-6+ too, not only the top-5), so an edge - from a weaker forwarder can still mark a top-5 neighbour covered. - -### The reach graph — measured (actively + passively), not inferred from flood paths -- An edge `fi -> N` means *"N can hear fi's transmissions"* (fi reaches N). -- **Directed.** RF links are asymmetric — A hearing B does **not** mean B hears A. The - graph must not infer forward reach from a reverse observation, or M could suppress a - rebroadcast and starve a neighbour that is actually deaf toward the forwarder. -- Edges come from **two sources**: - 1. **Active probe** — a round-trip TRACE with visit-list `[a, b, self]` (2-byte hashes) - walks `self -> a -> b -> self`. The SNR measured at **b** of **a**'s forward is - exactly *"does b hear a"*. The core flag `TRACE_FLAG_TERMINATE_AT_LAST` delivers the - result back at the initiator instead of a bystander. (The earlier design inferred - edges from overheard flood paths, but that stayed empty in sparse/mast topologies.) - 2. **Passive harvest** — TRACEs are cleartext, so when a neighbouring repeater runs its - *own* coverage probe `[a, b, initiator]`, any radio in range can read it. M overhears - the probe's final leg (which carries the measured a→b SNR) and adopts the a→b edge - itself. A cluster thus builds a shared graph without each node re-probing every pair. - M ignores probes it initiated and ones whose `a`/`b` are not both its own near - neighbours. **No extra airtime** — receive-only. -- **Probing cadence:** when the top-5 set changes (new member / displacement) or after - refresh; **one probe per cadence tick** (HW ~60 s), the pair chosen **round-robin** so no - single pair is fixated on. An edge is recorded iff the measured SNR - `>= flood.suppress.snr.lo`; positive edges TTL **36 h**. -- **Negative cache:** a pair that yields *no* edge (probe timed out twice, or returned - below `snr.lo`) is cached for **10 h** and not re-probed meanwhile — otherwise probing - never converges (early hardware showed `sent` climbing forever). Coverage inference reads - **positive** edges only, so a cached absence is never treated as coverage. -- **1-hop, not transitive** — "reached by fi" means *could hear fi's specific forward*. - -### The SNR thresholds — what they actually gate -Coverage itself is **binary** — a neighbour either forwarded F, or was reached by a -forwarder's edge. It is *not* SNR-weighted. The two thresholds gate other things: -- **`snr.lo`** — the **near-set floor** (only neighbours heard at ≥ this are "near" / owed - coverage) and the **reach-edge floor** (an a→b link is recorded only if measured ≥ this). -- **`snr.hi`** — drives **cancel-window widening** for central relays (next section) and - feeds the adaptive density estimate. - -### Adaptive enable C (not user-configurable) -Suppression only runs when the neighbour table is dense enough to be worth it: **C** is -**derived from the neighbour table** (an adaptive density estimate) with a static fallback, -and the feature engages only while `C > 0`. C is **not** a "covered weight" threshold — the -decision itself is the binary coverage test above; C merely gates whether that test runs at -all, so a sparse or isolated node doesn't churn on it. There is no `set flood.suppress.c`. - -### Cancel-window widening (`delay.factor`) -A flood M would relay centrally (heard at SNR `>= snr.hi`) gets its random TX-delay window -multiplied by `(1 + flood.suppress.delay.factor)`. A wider window gives a redundant -rebroadcast more time to be observed and cancelled before it is transmitted. - -### Client-aware protection (always on) -Suppression must never starve an **attached leaf client** (a companion/sensor/room-server -for which M is the first hop) of a flood it needs. A 3-tier gate is **always active** -(there is no "empty client set ⇒ suppress everything" fallback): - -| Tier | Payload types | Behaviour | -|------|---------------|-----------| -| **A** | TRACE, CONTROL | Pure infrastructure → **suppress OK** | -| **B** | ADVERT, GRP_*, ACK, MULTIPART, … | Broadcast, can't address-check → **never suppress** (always forward) | -| **C** | REQ, RESPONSE, TXT_MSG, PATH, ANON_REQ | Addressed → suppress iff destination is **not** an attached client | - -The attached-client set (16 slots, ~24 h TTL) is seeded from count-0 addressed packets and -non-repeater adverts. Inspect with [`clients`](cli_commands.md#clients). - -### Cold-start safety -Before any TRACE completes, the reach graph is empty ⇒ uncovered peers trigger -`must_cover_self` ⇒ **M forwards everything**. No starvation, no regression vs. firmware -without the feature. Suppression only begins once real reachability has been measured. - -### Unidirectional links (heard but not reachable) -Near-set membership is based on M *hearing* N (the N→M link). The reverse direction (M→N) -is never measured directly — yet a neighbour M cannot actually *reach* (e.g. M's sector -antenna doesn't cover it) should not block suppression: M's rebroadcast would never reach -it anyway. M infers M→N from coverage-probe outcomes: a probe `[a=N, b, self]` returns iff -M's transmission reached N, and **always times out** when M→N is broken. After a couple of -first-hop-N timeouts with no success, such an N is flagged M-unreachable and **dropped from -the protection set** — it no longer forces `must_cover_self`, so one asymmetric link can't -deadlock M's suppression. An unreachable N may still *contribute* coverage as a forwarder -(its N→j edges stay valid). A single later successful probe re-protects N permanently -(sticky confirm, with a 24 h re-test for antenna drift), and it can never starve N: M's TX -never reached it, and its flood supply comes from forwarders it can hear. The `unr` token -in [`near`](cli_commands.md#near) counts neighbours currently excluded. +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. -## When it helps vs. when it is inert -- **Dense omni cluster** (near neighbours mutually in range): edges populate ⇒ redundant - rebroadcasts cancelled ⇒ large airtime/collision reduction. -- **Sparse / linear / hub-spoke / mast** (near neighbours don't hear each other): the - graph is **correctly empty** ⇒ little graph-based suppression, because M must cover each - spoke itself. This is the safe, intended behaviour — the feature never infers - reachability it has not measured. -- Even with an empty graph, the **direct-coverage** path can still suppress: once M has - overheard *every* near neighbour forward F, `allNearNeighboursCovered` fires and the - rebroadcast is cancelled — no reach edge required. -- **Shared-neighbour clusters** benefit most from the passive harvest: when several - coverage-capable repeaters are mutually in range, each adopts edges from the others' - probes, so the graph fills faster and more cross-forwarder coverage is inferred. +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. --- -## CLI summary +## 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 | -|---------|--------| -| `get/set flood.suppress ` | Master switch. `get` also prints the suppression ratio (`suppressed a/b (p%)`). | -| `get/set flood.suppress.snr.hi ` | Central-relay threshold: a flood heard at `>=` this gets its cancel window widened, and it feeds the adaptive density estimate (`-30..30`, default `9`). | -| `get/set flood.suppress.snr.lo ` | Near-set floor (a neighbour must be heard at `>=` this to be "near") and reach-edge floor (an a→b link is recorded only if measured `>=` this) (`-30..30`, default `0`). | -| `get/set flood.suppress.delay.factor ` | Cancel-window multiplier for central relays (`0..8`, default `2`). | -| `get/set trace.tx.power ` | TX power for coverage TRACE probes only (`-9..30`, default `10`). | -| `near` | Near coverage peers (strongest first) + the probe-health line `meas sent=… ret=… edge=… tmo=… neg=… harv=… unr=…` (tokens explained under Tuning). | -| `reach ` | Directed reach edges of one near repeater. | -| `clients` | Attached leaf clients. | - -Full syntax and output formats: [`cli_commands.md`](cli_commands.md#flood-suppression-coverage-repeater-only). +|---|---| +| `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`) | --- -## Tuning & troubleshooting - -### Read the state first -- `near` → the near set and the `meas sent=… ret=… edge=… tmo=… neg=… harv=… unr=…` probe-health line. -- `reach ` → whether measured directed edges exist for a given peer. -- `get flood.suppress` → on/off + the live suppression ratio. - -### `reach` is empty on hardware, but `near` shows peers? -The coverage-probe TX power is lowered to `trace.tx.power` (default **10 dBm**) **only on -the initiator**. The probe's first hop (`self -> a`) then runs at reduced power; for a -marginal near neighbour (admitted at a low `snr.lo`) that hop can drop below margin, so the -probe never reaches `a`, no round trip completes, and no edge is recorded. The simulator -ignores TX power, so this only manifests on hardware. - -**Fix:** `set trace.tx.power 20` (match normal TX power) and re-check `near` — the `meas` -line's `ret` should rise and edges appear in `reach`. Reading the `meas` line: - -| `meas` reading | Meaning | -|----------------|---------| -| `sent=0` | No `>= 2` near-neighbour window yet, or `flood.suppress off`. | -| `sent>0 ret=0` | Probes go out but no round trip completes — loss/collisions, or the first hop failing (see `trace.tx.power`). | -| `ret>0 edge=0` | Round trips complete but the measured link is below `snr.lo` — genuinely weak / no inter-neighbour reachability. | -| `ret>0 edge>0` | Edges recorded — `reach` should list them. | -| `neg>0` | Pairs cached as no-edge (timed out / weak); not re-probed for ~10 h. Expected to grow as the graph converges, after which `sent` levels off. | -| `harv>0` | Edges adopted from *overheard* neighbours' probes (passive harvest). `0` is normal when no other coverage-capable repeater is in range. | -| `unr>0` | Near neighbours M cannot transmit-reach (asymmetric link), excluded from the protection set. | - -### Near set churning (peers blink in/out) -At a low `snr.lo` (e.g. `0`), marginal neighbours keep crossing the threshold. Raise -`snr.lo` (e.g. `6`–`10`) to focus coverage on the stable, strong core — fewer, stabler -near peers, faster graph convergence. - -### Suppressing too little / too much -- Too little: lower `snr.hi`, or raise `delay.factor` so more redundant rebroadcasts are - observed in time. -- Too much / worried about reach: raise `snr.lo` (stricter near set), or `set flood.suppress off`. +## 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. --- -## How it is wired (files) -- `src/helpers/NeighbourLinkTable.h` — the directed reach-graph (`addEdge`/`hasEdge`/`purge`, ring 128, 36 h positive TTL) **plus a negative-result ring** (`addNegative`/`hasNegative`/`purgeNegative`, 10 h TTL) so no-edge pairs aren't re-probed to death. `hasEdge` is width-tolerant (prefix match); writes are exact-width. -- `src/helpers/FloodSuppression.h` — per-flood entry with `covered` set, `must_cover_self`, cancel + wait-window. -- `src/Mesh.cpp` + `src/Packet.h` — `TRACE_FLAG_TERMINATE_AT_LAST`: delivers a coverage TRACE back at its initiator (the one core change). -- `examples/simple_repeater/MyMesh.{h,cpp}` — the coverage test (`logRx`: suppression decision + passive TRACE harvest), `stepCoverageMeasurement()` (active TRACE scheduling with round-robin pair selection, from `loop()`), `onTraceRecv` (records edges + confirms M-reachability), `isExcludedFromProtection` (unidirectional-link handling), the always-on 3-tier `clientProtectionAllowsSuppress`, the `near`/`reach`/`clients` reply formatters, and the `trace_tx_power_dbm` burst handling. -- `src/helpers/CommonCLI.{h,cpp}` + `NodePrefs` — the CLI commands and persisted prefs above. +## 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. --- -## Honest limits -- Coverage is guaranteed only for the **top-5** near neighbours. -- **Unidirectional links** (M hears N but cannot reach N) are detected and excluded from - the protection set (above) — but **truly invisible** neighbours (asymmetric, absent from - M's table entirely) cannot be protected by any table-based method. `set flood.suppress - off` (or manual per-neighbour exclusion) remains the safety net there. -- Reach is inferred from a *historically measured* edge ⇒ a small false-positive risk if - an edge has since gone stale, mitigated by fresh-edge-only use + 36 h TTL + cold-start safety. +## 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/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e904c23629..7831698946 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -79,10 +79,11 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn } } + bool is_refresh = neighbour->id.matches(id); // same neighbour already in this slot? (computed before the id overwrite below) // Part 3: a NEW identity in this slot (empty slot, or LRU eviction of a *different* neighbour) // must start with unknown M-reachability. A refresh of the SAME neighbour (the common case -- // putNeighbour runs on every ~2-min advert) keeps its reachability state intact. - if (!neighbour->id.matches(id)) { + if (!is_refresh) { neighbour->m_reach_confirmed = false; neighbour->m_reach_timeouts = 0; neighbour->m_reach_last_ok_ms = 0; @@ -91,7 +92,12 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn neighbour->id = id; neighbour->advert_timestamp = timestamp; neighbour->heard_timestamp = getRTCClock()->getCurrentTime(); - neighbour->snr = (int8_t)(snr * 4); + // Smooth the link-quality estimate on a refresh of a KNOWN neighbour (the common ~2-min advert + // path), so a single weak/strong advert does not jump the value the adaptive p75 / near test read. + // A NEW slot (different id) is seeded from the single advert sample. EMA α≈0.25 (x4), matching + // touchNeighbourByHash -- without this the advert hard-replace would reset that smoothing ~2 min. + int8_t adv = (int8_t)(snr * 4); + neighbour->snr = is_refresh ? (3 * neighbour->snr + adv) / 4 : adv; #endif } @@ -115,7 +121,7 @@ void MyMesh::touchNeighbourByHash(const mesh::Packet* packet) { 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) + neighbours[i].snr = (3 * neighbours[i].snr + new_snr) / 4; // EMA α≈0.25 (x4): new sample 25%, outlier shifts ≤3 dB not halfway return; // at most one slot matches a given hash } } @@ -404,7 +410,7 @@ void MyMesh::stepCoverageMeasurement() { if (_nbr_links.hasNegative(ha, hb, TRACE_MEAS_HASH_SIZE, now)) continue; // probed, no edge -> backoff (~10h) bool inflight = false; // already probing this direction? for (uint8_t i = 0; i < TRACE_PENDING_MAX && !inflight; i++) - if (_trace_pending[i].active && memcmp(_trace_pending[i].a, ha, 2) == 0 && memcmp(_trace_pending[i].b, hb, 2) == 0) inflight = true; + if (_trace_pending[i].active && memcmp(_trace_pending[i].a, ha, TRACE_MEAS_HASH_SIZE) == 0 && memcmp(_trace_pending[i].b, hb, TRACE_MEAS_HASH_SIZE) == 0) inflight = true; if (inflight) continue; int8_t slot = -1; // free pending slot? for (uint8_t i = 0; i < TRACE_PENDING_MAX; i++) if (!_trace_pending[i].active) { slot = (int8_t)i; break; } @@ -423,8 +429,8 @@ void MyMesh::stepCoverageMeasurement() { _trace_pending[slot].retries = 0; _trace_pending[slot].tag = tag; _trace_pending[slot].sent_ms = now; - memcpy(_trace_pending[slot].a, ha, 2); - memcpy(_trace_pending[slot].b, hb, 2); + memcpy(_trace_pending[slot].a, ha, TRACE_MEAS_HASH_SIZE); + memcpy(_trace_pending[slot].b, hb, TRACE_MEAS_HASH_SIZE); _meas_rr_offset = (uint8_t)((p + 1) % P); // next tick starts after the pair just probed stop = true; break; // ONE trace per cadence tick // (a pair's two directions are ~180ms*3hops round-trips; sending them @@ -2027,6 +2033,8 @@ void MyMesh::clearStats() { ((SimpleMeshTables *)getTables())->resetStats(); _fs_seen = 0; _fs_suppressed = 0; + _meas_sent = _meas_returned = _meas_edge = _meas_timeout = _meas_neg = 0; + _meas_harvested = _meas_harvest_neg = 0; } void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { From 91760382f83f6598bd80bcbd50f4e22b4d890cc8 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Wed, 12 Aug 2026 16:01:04 +0000 Subject: [PATCH 13/15] feat: enhance flood suppression with noise-aware payload-class policy and SNR fallback --- docs/README-flood-suppression.md | 44 +++++++++++- examples/simple_repeater/MyMesh.cpp | 100 ++++++++++++++++++++++++++-- examples/simple_repeater/MyMesh.h | 6 ++ src/helpers/CommonCLI.cpp | 10 ++- src/helpers/CommonCLI.h | 3 + src/helpers/FloodSuppression.h | 14 ++++ 6 files changed, 168 insertions(+), 9 deletions(-) diff --git a/docs/README-flood-suppression.md b/docs/README-flood-suppression.md index 0baf3b748d..83451b2836 100644 --- a/docs/README-flood-suppression.md +++ b/docs/README-flood-suppression.md @@ -67,11 +67,11 @@ cancels reliably land before the redundant TX goes out. ## Configuration -There is **one master switch** and three tuning parameters. The threshold **C is +There is **one master switch** and five 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 +`NodePrefs` fields (`src/helpers/CommonCLI.h`), persisted at file bytes 295–302 (`src/helpers/CommonCLI.cpp`): | Field | Type | Default | Meaning | @@ -79,7 +79,9 @@ static fallback (see *Adaptive mode*). | `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. | +| `flood_suppress_delay_x` | `uint8_t` | `3` | Extra TX-delay multiplier for central flood relays. | +| `trace_tx_power_dbm` | `int8_t` (dBm) | `10` | TX power for coverage TRACE probes only (lower = less disturbance). | +| `flood_suppress_noise_floor` | `int8_t` (dBm) | `-95` | Noise floor `>=` this marks the channel as noisy; on a noisy channel only cheap (self-healing) payloads are suppressed. | The feature is **on by default**; `set flood.suppress off` (or YAML `flood_suppress: 0`) disables it completely. @@ -96,6 +98,42 @@ The feature is **on by default**; `set flood.suppress off` (or YAML | `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`) | +| `set flood.suppress.noise.floor ` | `-120..0` (`get flood.suppress.noise.floor`) | +| `set trace.tx.power ` | `-9..30` (`get trace.tx.power`) | + +### Payload-class noise gate + +A repeater's retransmit **adds** airtime; on a congested channel every extra TX +worsens the collision problem. The noise gate therefore applies a payload-class +policy whenever the channel is noisy (`noise_floor >= flood_suppress_noise_floor`): + +| Class | Payload types | On a noisy channel | +|---|---|---| +| **Cheap / self-healing** | `ADVERT` | may be suppressed (silence > repeat) | +| **Confidence-only** | `TRACE`, `CONTROL`, `GRP_TXT`, `GRP_DATA`, `PATH`, `ANON_REQ` | forwarded (only suppressed on a quiet channel) | +| **Payload-critical** | `REQ`, `RESPONSE`, `TXT_MSG`, `ACK`, `MULTIPART` | never suppressed by the gate | + +The gate is fixed ON and not configurable; only the noise-floor threshold above +is adjustable. + +`TRACE` and `CONTROL` are deliberately **not** in the cheap class: `TRACE` feeds the +coverage graph this suppressor depends on, and `CONTROL` drives neighbour discovery, so +dropping them on a noisy channel would starve the very data the gate relies on and risk +a self-reinforcing collapse of the reach graph. They are still suppressible on a *quiet* +channel (redundant copies cost airtime for no benefit); only the noisy-channel drop is +withheld. + +### SNR-repeat fallback + +The coverage-graph test is intentionally conservative: it only suppresses when it +can **prove** every near neighbour already has the flood. When the graph cannot +prove coverage (e.g. the forwarders are beyond the top-N coverage cap, so no +measured TRACE edge exists), a secondary **legacy SNR-repeat counter** still +applies: each overheard forward of the same hash increments a per-flood weighted +counter (`SNR >= snr.hi` → +2, `< snr.lo` → 0, else +1). Once the weighted count +reaches the effective **C**, the rebroadcast is cancelled even without graph +proof. The graph result always wins; the fallback only widens the suppression +set. It is fixed ON and not configurable. --- diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 82a0c6b5a2..c631bf04c1 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -268,6 +268,49 @@ bool MyMesh::clientProtectionAllowsSuppress(const mesh::Packet* pkt, uint32_t no return false; // Tier B } +// --- Payload-class suppression policy (noise-aware) ----------------------------- +// A repeater's retransmit ADDS airtime. On a congested/noisy channel every extra TX +// worsens the collision problem, so "stay silent rather than repeat" applies to the +// payload classes whose loss is cheap/self-healing. The class decides whether a +// redundant rebroadcast may be cancelled on a busy channel: +// 0 = NEVER (payload-critical): REQ, RESPONSE, TXT_MSG, ACK, MULTIPART -- always +// re-forward (subject to the normal dedup/coverage logic); noise never vetoes. +// 1 = CONFIDENCE-ONLY (best-effort but not disposable): TRACE, CONTROL, GRP_TXT, +// GRP_DATA, PATH, ANON_REQ -- suppressible on a quiet channel, forwarded on a +// noisy one. TRACE/CONTROL live here (NOT in tier 2): they are measurement and +// discovery plumbing -- TRACE feeds the coverage graph this suppressor relies +// on, CONTROL drives neighbour discovery -- so dropping them on a noisy channel +// would starve that data and risk a self-reinforcing collapse of the reach graph. +// 2 = CHEAP (self-healing): ADVERT (periodic re-send) -- suppressible even on a noisy +// channel (silence > repeat); adverts are rate-limited and re-sent periodically. +uint8_t MyMesh::floodSuppressTier(const mesh::Packet* pkt) const { + switch (pkt->getPayloadType()) { + case PAYLOAD_TYPE_ADVERT: + return 2; // cheap: self-healing (periodic re-send) -- suppressible even when noisy + case PAYLOAD_TYPE_TRACE: + case PAYLOAD_TYPE_CONTROL: + case PAYLOAD_TYPE_GRP_TXT: + case PAYLOAD_TYPE_GRP_DATA: + case PAYLOAD_TYPE_PATH: + case PAYLOAD_TYPE_ANON_REQ: + return 1; // confidence-only: suppressible only on a quiet channel (forwarded when noisy) + default: + return 0; // never: REQ/RESPONSE/TXT_MSG/ACK/MULTIPART/RAW_CUSTOM + } +} + +// Channel-state gate for suppression. On a busy/noisy channel an extra TX amplifies the +// collision problem, so when the channel IS busy we suppress only the cheap (self-healing) +// class (silence > repeat) and keep the confidence-only classes forwarding. A high noise +// floor = busy/interfered channel. `_radio->getNoiseFloor()` is maintained by the core's +// calibration loop (RadioLibWrappers). +bool MyMesh::noiseGateAllowsSuppress(const mesh::Packet* pkt) const { + uint8_t tier = floodSuppressTier(pkt); + if (tier == 0) return false; // payload-critical: never suppress + bool noisy = ((int)_radio->getNoiseFloor() >= (int)_prefs.flood_suppress_noise_floor); + return noisy ? (tier == 2) : true; // noisy: only cheap; quiet: tier 1 or 2 +} + // --- Active TRACE coverage measurement ---------------------------------------- // Send one round-trip coverage TRACE: visit-list [a, b, self] with 2-byte hashes. // It walks self->a->b->self; the SNR measured at b of a's forward (path_snrs[1]) @@ -1079,15 +1122,51 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { } // (d) suppress iff no isolated-uncovered peer, every coverage peer covered, and - // client-protection allows it (3-tier, always active). + // client-protection allows it (3-tier, always active). The noise gate then + // decides by payload class: on a busy channel only cheap (self-healing) + // payloads are suppressed (silence > repeat); payload-critical ones forward. if (!e->must_cover_self && allNearNeighboursCovered(*e, now) && clientProtectionAllowsSuppress(pkt, now)) { - e->suppressed = true; - _fs_suppressed++; // our rebroadcast was made redundant - cancelPendingFloodOutbound(hash); + if (noiseGateAllowsSuppress(pkt)) { + e->suppressed = true; + _fs_suppressed++; // our rebroadcast was made redundant + _fs_supp_graph++; + cancelPendingFloodOutbound(hash); + } else { + _fs_noise_blocked++; // graph said redundant, noise gate vetoed + } } #endif } + + // --- SNR-repeat fallback (soundness-preserving) ----------------------------- + // Runs for every overheard copy EXCEPT the first (entry-creating) one, when the + // graph test did NOT suppress. Revives the original weighted counter: weight by + // this copy's RX SNR (>=snr_hi -> +2, 0, else +1). When the weighted + // count reaches the effective C, the rebroadcast is redundant even without graph + // proof (e.g. the forwarders are rank >cap, so no TRACE edge covers them). The + // graph result always wins: this only fires when the graph could not prove + // coverage, and never overrides must_cover_self (an uncovered top-N neighbour M + // definitively owes coverage to -- only M's own TX can reach it). Same noise gate + // + client protection as the graph path. + if (e && !e->suppressed && !is_new) { + uint8_t c = effectiveFloodSuppressC(); + if (c > 0 && e->snr_fallback_wcount < 255) { + float snr = pkt->getSNR(); + int8_t hi = effectiveFloodSuppressSnrHi(); + int8_t lo = _prefs.flood_suppress_snr_lo; + e->snr_fallback_wcount += (snr >= hi) ? 2 : (snr < lo) ? 0 : 1; + if (e->snr_fallback_wcount >= c && !e->snr_fallback_suppressed && !e->must_cover_self && + clientProtectionAllowsSuppress(pkt, getRTCClock()->getCurrentTime()) && + noiseGateAllowsSuppress(pkt)) { + e->snr_fallback_suppressed = true; + e->suppressed = true; + _fs_suppressed++; + _fs_supp_snr_fallback++; + cancelPendingFloodOutbound(hash); + } + } + } } #if MAX_NEIGHBOURS // --- Passive TRACE harvest (Part 2) ----------------------------------------- @@ -1547,6 +1626,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _fs_next_recompute_ms = 0; _fs_seen = 0; _fs_suppressed = 0; + _fs_supp_graph = _fs_supp_snr_fallback = _fs_noise_blocked = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; set_radio_at = revert_radio_at = 0; @@ -1590,8 +1670,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _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 + _prefs.flood_suppress_delay_x = 3; // extra TX-delay multiplier for central flood relays (wider cancel window) _prefs.trace_tx_power_dbm = 10; // TX power for coverage TRACE probes only (near links are strong; less disturbance) + // SNR-repeat fallback and noise gate are fixed ON (not configurable). + _prefs.flood_suppress_noise_floor = -95; // dBm: noise floor >= this => channel considered noisy // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -1881,6 +1963,13 @@ void MyMesh::formatPacketStatsReply(char *reply) { 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); + // Append the suppression-path breakdown: graph=coverage-graph suppressions, + // snr_fallback=SNR-repeat fallback suppressions, nblk=graph-suppressions vetoed + // by the noise gate. Lets the operator see WHICH mechanism is doing the work. + char extra[64]; + sprintf(extra, " (graph=%lu snr_fallback=%lu nblk=%lu)", (unsigned long)_fs_supp_graph, + (unsigned long)_fs_supp_snr_fallback, (unsigned long)_fs_noise_blocked); + strcat(reply, extra); } // `clients` reply: one line per attached leaf client ":s" -- the hash is @@ -2034,6 +2123,7 @@ void MyMesh::clearStats() { ((SimpleMeshTables *)getTables())->resetStats(); _fs_seen = 0; _fs_suppressed = 0; + _fs_supp_graph = _fs_supp_snr_fallback = _fs_noise_blocked = 0; _meas_sent = _meas_returned = _meas_edge = _meas_timeout = _meas_neg = 0; _meas_harvested = _meas_harvest_neg = 0; } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 3e0f9f9e12..a6d9d7d6f8 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -157,6 +157,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { 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) + // Observability breakdown of the suppression numerator (surfaced in `get flood.suppress`). + uint32_t _fs_supp_graph; // suppressed by the coverage-graph test + uint32_t _fs_supp_snr_fallback; // suppressed by the SNR-repeat fallback + uint32_t _fs_noise_blocked; // graph+client said suppress, but the noise gate vetoed // --- Active TRACE coverage measurement state (populates _nbr_links) --- struct PendingTrace { uint32_t tag; @@ -212,6 +216,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void purgeAttachedClients(uint32_t now); // evict stale clients (~24h) bool isKnownRepeaterHash1(uint8_t hash1) const; // does this 1-byte hash match a known repeater neighbour? void cancelPendingFloodOutbound(const uint8_t* hash); // cancel our scheduled flood rebroadcast (if any) + uint8_t floodSuppressTier(const mesh::Packet* pkt) const; // payload-class policy: 0=never, 1=confidence-only, 2=cheap-to-suppress + bool noiseGateAllowsSuppress(const mesh::Packet* pkt) const; // channel-state gate (busy/noisy channel -> restrict to cheap classes) 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 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 9f9833a351..2d0361312f 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -107,7 +107,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy 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 file.read((uint8_t *)&_prefs->trace_tx_power_dbm, sizeof(_prefs->trace_tx_power_dbm)); // 299 - // next: 300 + file.read((uint8_t *)&_prefs->flood_suppress_noise_floor, sizeof(_prefs->flood_suppress_noise_floor)); // 300 + // next: 301 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -144,6 +145,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy _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); _prefs->trace_tx_power_dbm = constrain(_prefs->trace_tx_power_dbm, -9, 30); + _prefs->flood_suppress_noise_floor = constrain(_prefs->flood_suppress_noise_floor, -120, 0); file.close(); } @@ -515,6 +517,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep 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, "flood.suppress.noise.floor ", 27) == 0) { + int dbm = atoi(&config[27]); + if (dbm >= -120 && dbm <= 0) { _prefs->flood_suppress_noise_floor = dbm; savePrefs(); strcpy(reply, "OK"); } + else strcpy(reply, "Error, must be -120..0 dBm"); } else if (memcmp(config, "trace.tx.power ", 15) == 0) { int db = atoi(&config[15]); if (db >= -9 && db <= 30) { _prefs->trace_tx_power_dbm = db; savePrefs(); strcpy(reply, "OK"); } @@ -849,6 +855,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep 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.noise.floor", 26) == 0) { + sprintf(reply, "> %d dBm", (int) _prefs->flood_suppress_noise_floor); } else if (memcmp(config, "flood.suppress", 14) == 0) { sprintf(reply, "> %s", _prefs->flood_suppress ? "on" : "off"); _callbacks->formatFloodSuppressRatioReply(reply + strlen(reply)); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 32348318e7..91779a3739 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -77,6 +77,8 @@ class NodePrefs : public ConfigSerializer { int8_t flood_suppress_snr_lo = 0; // dB: overheard forward with SNR= this => channel considered noisy private: class RadioPrefs : public ConfigSerializer { @@ -160,6 +162,7 @@ class NodePrefs : public ConfigSerializer { def("fs_lo", _parent->flood_suppress_snr_lo); def("fs_dx", _parent->flood_suppress_delay_x); def("fs_tx", _parent->trace_tx_power_dbm); + def("fs_nf", _parent->flood_suppress_noise_floor); } public: RepeatPrefs(NodePrefs* parent) : _parent(parent) { } diff --git a/src/helpers/FloodSuppression.h b/src/helpers/FloodSuppression.h index 214e74add2..7bf3f26c1a 100644 --- a/src/helpers/FloodSuppression.h +++ b/src/helpers/FloodSuppression.h @@ -54,6 +54,18 @@ struct FloodSuppressionEntry { // only M's own TX can cover it -> M must forward, no point widening bool active; + // --- SNR fallback (per-flood weighted overheard-forward counter) --- + // Revived from the original redundancy-aware design (commit 02043366): count each + // overheard forward of THIS flood, weighted by its RX SNR at arrival time + // (SNR >= snr_hi -> +2, SNR < snr_lo -> 0, else +1). When the weighted count + // reaches the effective threshold C the flood's rebroadcast is redundant even + // if the coverage-graph could not prove it (e.g. forwarders are rank >cap, so + // no measured TRACE edges exist). Checked AFTER the graph test fails, so the + // sound graph path always wins; the fallback only widens the suppression set. + // Saturating at 255 is fine (threshold C is a small integer). + uint8_t snr_fallback_wcount; + bool snr_fallback_suppressed; + // Record a near-neighbour index as covered (dedup). Returns true if newly added. bool addCovered(uint8_t idx) { for (uint8_t i = 0; i < covered_count; i++) @@ -108,6 +120,8 @@ class FloodSuppressionTable { e->first_seen_ms = now; e->suppressed = false; e->must_cover_self = false; + e->snr_fallback_wcount = 0; + e->snr_fallback_suppressed = false; e->active = true; if (is_new) *is_new = true; return e; From c5b8ea70f5e29ede2fe8a961b9ed8a3925b7458a Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Wed, 12 Aug 2026 16:47:09 +0000 Subject: [PATCH 14/15] feat: update flood suppression parameters to include noise margin and adaptivity improvements --- docs/README-flood-suppression.md | 54 +++++++++++-------- examples/simple_repeater/MyMesh.cpp | 80 +++++++++++++++++++++-------- examples/simple_repeater/MyMesh.h | 3 ++ src/helpers/CommonCLI.cpp | 23 ++++++--- src/helpers/CommonCLI.h | 16 ++++-- 5 files changed, 123 insertions(+), 53 deletions(-) diff --git a/docs/README-flood-suppression.md b/docs/README-flood-suppression.md index 83451b2836..8446832bf3 100644 --- a/docs/README-flood-suppression.md +++ b/docs/README-flood-suppression.md @@ -67,9 +67,11 @@ cancels reliably land before the redundant TX goes out. ## Configuration -There is **one master switch** and five tuning parameters. The threshold **C is -not user-configurable** — it is derived from the neighbour table (adaptive) with a -static fallback (see *Adaptive mode*). +There is **one master switch** and five tuning parameters. The threshold **C**, +`snr.hi` and `snr.lo` are **not user-configurable** — they are derived from the +neighbour table (adaptive) with static fallbacks (see *Adaptive mode*). The noise +gate's threshold is also **not an absolute value**: it is measured relative to each +site's own quiet baseline. `NodePrefs` fields (`src/helpers/CommonCLI.h`), persisted at file bytes 295–302 (`src/helpers/CommonCLI.cpp`): @@ -77,11 +79,11 @@ static fallback (see *Adaptive mode*). | 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_snr_hi` | `int8_t` (dB) | `9` | Overheard forward with SNR `>=` this counts **double** (adaptive p75; configured value is the fallback). | +| `flood_suppress_snr_lo` | `int8_t` (dB) | `0` | Near-membership threshold; overheard forward with SNR `<` this counts **0** (adaptive p25; configured value is the fallback). | | `flood_suppress_delay_x` | `uint8_t` | `3` | Extra TX-delay multiplier for central flood relays. | | `trace_tx_power_dbm` | `int8_t` (dBm) | `10` | TX power for coverage TRACE probes only (lower = less disturbance). | -| `flood_suppress_noise_floor` | `int8_t` (dBm) | `-95` | Noise floor `>=` this marks the channel as noisy; on a noisy channel only cheap (self-healing) payloads are suppressed. | +| `flood_suppress_noise_margin` | `int8_t` (dB) | `AUTO` (10) | Margin above the site's quiet baseline at which the channel counts as noisy; `AUTO` = firmware default. On a noisy channel only cheap (self-healing) payloads are suppressed. | The feature is **on by default**; `set flood.suppress off` (or YAML `flood_suppress: 0`) disables it completely. @@ -96,16 +98,19 @@ The feature is **on by default**; `set flood.suppress off` (or YAML |---|---| | `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.snr.lo ` | `-30..30` (`get flood.suppress.snr.lo`); adaptive p25 fallback | | `set flood.suppress.delay.factor ` | `0..8` (`get flood.suppress.delay.factor`) | -| `set flood.suppress.noise.floor ` | `-120..0` (`get flood.suppress.noise.floor`) | +| `set flood.suppress.noise.margin ` | `auto` or `0..40` (`get flood.suppress.noise.margin`) | | `set trace.tx.power ` | `-9..30` (`get trace.tx.power`) | ### Payload-class noise gate A repeater's retransmit **adds** airtime; on a congested channel every extra TX worsens the collision problem. The noise gate therefore applies a payload-class -policy whenever the channel is noisy (`noise_floor >= flood_suppress_noise_floor`): +policy whenever the channel is noisy. "Noisy" is **relative**: the measured noise +floor is `>=` this site's slowly-tracked quiet **baseline** plus +`flood_suppress_noise_margin` dB (default `AUTO` = 10 dB) — so no absolute-dBm +guess is needed: | Class | Payload types | On a noisy channel | |---|---|---| @@ -113,8 +118,9 @@ policy whenever the channel is noisy (`noise_floor >= flood_suppress_noise_floor | **Confidence-only** | `TRACE`, `CONTROL`, `GRP_TXT`, `GRP_DATA`, `PATH`, `ANON_REQ` | forwarded (only suppressed on a quiet channel) | | **Payload-critical** | `REQ`, `RESPONSE`, `TXT_MSG`, `ACK`, `MULTIPART` | never suppressed by the gate | -The gate is fixed ON and not configurable; only the noise-floor threshold above -is adjustable. +The gate is fixed ON. The margin defaults to `AUTO` (the firmware derives the +threshold from the baseline); an explicit `0..40` dB override only makes it more +or less sensitive. `TRACE` and `CONTROL` are deliberately **not** in the cheap class: `TRACE` feeds the coverage graph this suppressor depends on, and `CONTROL` drives neighbour discovery, so @@ -139,16 +145,18 @@ set. It is fixed ON and not configurable. ## 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. +With the master switch **on**, the threshold **C**, `snr.hi` **and `snr.lo`** 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 safe **static fallbacks** when no neighbour data is available. No +per-topology tuning is required. The noise-floor **baseline** (for the relative +noise gate) is learned here too. `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). +`effectiveFloodSuppressC()` / `effectiveFloodSuppressSnrHi()` / +`effectiveFloodSuppressSnrLo()`. C/hi/lo are derived under `#if MAX_NEIGHBOURS` +(the table is a build flag); the baseline tracker runs every cycle regardless. **Derivation** (only **fresh** neighbours counted — `heard_timestamp` age ≤ 600 s, i.e. heard within the last 10 min): @@ -167,7 +175,13 @@ 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` | +| `effective_snr_lo` | link-SNR **p25** of fresh neighbours | near-membership threshold; `clamp(p25, -5, 15)`; needs ≥ 4 samples, else the configured `snr.lo` | +| `effective_snr_hi` | link-SNR **p75** of fresh neighbours | `clamp(p75, eff.lo+4, eff.lo+12)`; needs ≥ 4 samples, else the configured `snr.hi` | + +`snr.lo` does **not** feed back into the density count `n` (which is by timestamp +only), so widening/narrowing the near set cannot oscillate `c`. The **noise-floor +baseline** (fast follow down, slow 1/16 approach up so a permanent rise eventually +re-baselines) makes the noise gate deployment-independent. 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 @@ -219,7 +233,7 @@ static fallback. The refresh is a hardware-only improvement. | 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.h` | Helper include; `_flood_supp` + adaptive state (`_fs_eff_c`, `_fs_eff_hi`, `_fs_eff_lo`, `_fs_adaptive_active`, `_fs_floor_baseline`, …); `cancelPendingFloodOutbound`, `updateAdaptiveFloodParams`, `effectiveFloodSuppressC/Hi/Lo`, `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. | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index c631bf04c1..cbf0468e47 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -129,13 +129,13 @@ void MyMesh::touchNeighbourByHash(const mesh::Packet* packet) { } // Is neighbours[i] a "near" coverage peer? fresh (<= NEIGHBOUR_FRESH_S) and link -// SNR >= flood_suppress_snr_lo. Distant/weak neighbours are edge nodes, excluded -// (same intent as the old SNR-weighting weight-0). +// SNR >= effective snr_lo (adaptive p25). Distant/weak neighbours are edge nodes, +// excluded (same intent as the old SNR-weighting weight-0). bool MyMesh::isNearNeighbour(int i, uint32_t now) const { #if MAX_NEIGHBOURS if (neighbours[i].heard_timestamp == 0) return false; // empty slot if ((uint32_t)(now - neighbours[i].heard_timestamp) > NEIGHBOUR_FRESH_S) return false; // stale - int8_t lo_x4 = (int8_t)(_prefs.flood_suppress_snr_lo * 4); + int8_t lo_x4 = (int8_t)(effectiveFloodSuppressSnrLo() * 4); return neighbours[i].snr >= lo_x4; #else return false; @@ -300,14 +300,19 @@ uint8_t MyMesh::floodSuppressTier(const mesh::Packet* pkt) const { } // Channel-state gate for suppression. On a busy/noisy channel an extra TX amplifies the -// collision problem, so when the channel IS busy we suppress only the cheap (self-healing) -// class (silence > repeat) and keep the confidence-only classes forwarding. A high noise -// floor = busy/interfered channel. `_radio->getNoiseFloor()` is maintained by the core's -// calibration loop (RadioLibWrappers). +// collision problem, so when the channel IS noisy we suppress only the cheap (self-healing) +// class (silence > repeat) and keep the confidence-only classes forwarding. "Noisy" is relative: +// the measured floor is at least `margin` dB above THIS SITE's slowly-tracked quiet baseline +// (see updateAdaptiveFloodParams), so no expert absolute-dBm value is needed. Margin is AUTO +// (firmware default) unless an explicit CLI override is set. Until the baseline is learned +// (first cycle, ~60s) we assume noisy -- the safe direction (keep confidence classes forwarding). bool MyMesh::noiseGateAllowsSuppress(const mesh::Packet* pkt) const { uint8_t tier = floodSuppressTier(pkt); if (tier == 0) return false; // payload-critical: never suppress - bool noisy = ((int)_radio->getNoiseFloor() >= (int)_prefs.flood_suppress_noise_floor); + int8_t margin = (_prefs.flood_suppress_noise_margin == FLOOD_SUPPRESS_NOISE_MARGIN_AUTO) + ? FLOOD_SUPPRESS_NOISE_MARGIN_DEFAULT : _prefs.flood_suppress_noise_margin; + bool noisy = (_fs_floor_baseline <= -999) || + ((int)_radio->getNoiseFloor() >= _fs_floor_baseline + (int)margin); return noisy ? (tier == 2) : true; // noisy: only cheap; quiet: tier 1 or 2 } @@ -348,7 +353,7 @@ void MyMesh::onTraceRecv(mesh::Packet* /*packet*/, uint32_t tag, uint32_t /*auth if (n_hops == 3 && entry_sz == TRACE_MEAS_HASH_SIZE) { _meas_returned++; // a coverage TRACE round-trip completed back here int8_t snr_x4 = (int8_t)path_snrs[1]; // SNR at b of a's forward = a reaches b - if (snr_x4 >= (int8_t)(_prefs.flood_suppress_snr_lo * 4)) { + if (snr_x4 >= (int8_t)(effectiveFloodSuppressSnrLo() * 4)) { _nbr_links.addEdge(path_hashes, path_hashes + entry_sz, entry_sz, millis()); _meas_edge++; // ...and the a->b link was strong enough to record } else { @@ -906,6 +911,11 @@ void MyMesh::cancelPendingFloodOutbound(const uint8_t* hash) { // 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; +// Clamp for the derived snr_lo (near-membership threshold). LoRa decodes below 0 dB SNR, so the +// floor keeps usable weak links "near"; the cap stops membership becoming trivially loose. +static const int8_t FLOOD_SUPPRESS_SNR_LO_MIN = -5; +static const int8_t FLOOD_SUPPRESS_SNR_LO_MAX = 15; + // 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 { @@ -916,11 +926,26 @@ 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; } +int8_t MyMesh::effectiveFloodSuppressSnrLo() const { + if (!_prefs.flood_suppress) return _prefs.flood_suppress_snr_lo; // moot: effective c == 0 + return _fs_adaptive_active ? _fs_eff_lo : _prefs.flood_suppress_snr_lo; +} // 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() { + // --- Noise-floor baseline (relative noise gate) --- + // getNoiseFloor() is the median-estimated ambient (RadioLibWrappers). We track its quiet + // minimum as THIS SITE's baseline: fast follow downward (got quieter), slow 1/16 approach + // upward (a persistent rise -- e.g. a new interferer -- slowly becomes the new baseline, so the + // gate eventually re-opens; mirrors the median estimator's bounded hold-release). "noisy" is + // then floor >= baseline + margin -- deployment-independent, no expert absolute-dBm. Runs every + // cycle (60s) when flood suppression is on, independent of MAX_NEIGHBOURS. + int cur_floor = _radio->getNoiseFloor(); + if (_fs_floor_baseline <= -999) _fs_floor_baseline = cur_floor; // first sample + else if (cur_floor < _fs_floor_baseline) _fs_floor_baseline = cur_floor; // fast down + else _fs_floor_baseline += (cur_floor - _fs_floor_baseline) / 16; // slow up #if MAX_NEIGHBOURS int n = 0; int8_t snr_x4[MAX_NEIGHBOURS]; @@ -940,18 +965,25 @@ void MyMesh::updateAdaptiveFloodParams() { // 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. + // snr_lo = p25 (near-membership threshold) and snr_hi = p75 of fresh link SNRs (dB). lo anchors + // hi's clamp [lo+4, lo+12]; both need >=4 samples, else keep configured. Adaptive lo means the + // near set self-calibrates to the deployment (strong mesh -> weak links become "edge"); it does + // NOT feed back into n (n counts fresh neighbours by timestamp only), so no oscillation loop. + int8_t derived_lo = _prefs.flood_suppress_snr_lo; // else keep configured 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) + for (int i = 1; i < n; i++) { // insertion sort ascending (<=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 lo_db = (int8_t)(snr_x4[((n - 1) * 1) / 4] / 4); // p25, x4 -> dB + if (lo_db < FLOOD_SUPPRESS_SNR_LO_MIN) lo_db = FLOOD_SUPPRESS_SNR_LO_MIN; + if (lo_db > FLOOD_SUPPRESS_SNR_LO_MAX) lo_db = FLOOD_SUPPRESS_SNR_LO_MAX; + derived_lo = lo_db; 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; + if (hi_db < lo_db + 4) hi_db = lo_db + 4; + if (hi_db > lo_db + 12) hi_db = lo_db + 12; derived_hi = hi_db; } @@ -959,11 +991,12 @@ void MyMesh::updateAdaptiveFloodParams() { 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); + if (new_c != _fs_eff_c || derived_hi != _fs_eff_hi || derived_lo != _fs_eff_lo) { + MESH_DEBUG_PRINTLN("%s flood-suppress adaptive: neighbours=%d -> c=%d (was %d), snr_lo=%d (was %d), snr_hi=%d (was %d)", + getLogDateTime(), n, new_c, _fs_eff_c, (int)derived_lo, (int)_fs_eff_lo, (int)derived_hi, (int)_fs_eff_hi); } _fs_eff_c = new_c; + _fs_eff_lo = derived_lo; _fs_eff_hi = derived_hi; #else _fs_adaptive_active = false; // no neighbour table compiled in -> static fallback @@ -1154,7 +1187,7 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { if (c > 0 && e->snr_fallback_wcount < 255) { float snr = pkt->getSNR(); int8_t hi = effectiveFloodSuppressSnrHi(); - int8_t lo = _prefs.flood_suppress_snr_lo; + int8_t lo = effectiveFloodSuppressSnrLo(); e->snr_fallback_wcount += (snr >= hi) ? 2 : (snr < lo) ? 0 : 1; if (e->snr_fallback_wcount >= c && !e->snr_fallback_suppressed && !e->must_cover_self && clientProtectionAllowsSuppress(pkt, getRTCClock()->getCurrentTime()) && @@ -1189,7 +1222,7 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { if (findNearNeighbour(visit, entry_sz, now) >= 0 && findNearNeighbour(visit + entry_sz, entry_sz, now) >= 0) { // both a,b are our near int8_t snr_ab_x4 = (int8_t)pkt->path[1]; - if (snr_ab_x4 >= (int8_t)(_prefs.flood_suppress_snr_lo * 4)) { + if (snr_ab_x4 >= (int8_t)(effectiveFloodSuppressSnrLo() * 4)) { _nbr_links.addEdge(visit, visit + entry_sz, entry_sz, millis()); // a reaches b (clears stale neg) _meas_harvested++; } else { @@ -1621,9 +1654,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc uptime_millis = 0; _fs_eff_c = 0; // adaptive: off until neighbour table fills _fs_eff_hi = 9; + _fs_eff_lo = 0; _fs_pending_c = 0; _fs_adaptive_active = false; // until neighbour data is available -> static fallback _fs_next_recompute_ms = 0; + _fs_floor_baseline = -999; // noise-floor baseline: not yet learned -> gate assumes noisy _fs_seen = 0; _fs_suppressed = 0; _fs_supp_graph = _fs_supp_snr_fallback = _fs_noise_blocked = 0; @@ -1672,8 +1707,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_suppress_snr_lo = 0; // dB: weak overheard forward => ignored (preserve edge) _prefs.flood_suppress_delay_x = 3; // extra TX-delay multiplier for central flood relays (wider cancel window) _prefs.trace_tx_power_dbm = 10; // TX power for coverage TRACE probes only (near links are strong; less disturbance) - // SNR-repeat fallback and noise gate are fixed ON (not configurable). - _prefs.flood_suppress_noise_floor = -95; // dBm: noise floor >= this => channel considered noisy + // SNR-repeat fallback is fixed ON (not configurable). The noise gate's margin defaults to AUTO + // (firmware derives "noisy" relative to this site's baseline); an explicit override is optional. + _prefs.flood_suppress_noise_margin = FLOOD_SUPPRESS_NOISE_MARGIN_AUTO; // relative gate (auto margin) // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -2066,7 +2102,7 @@ void MyMesh::formatNearReply(char *reply) { idx[b] = v; } - sprintf(dp, "near snr_lo=%d cap=%d n=%u", (int)_prefs.flood_suppress_snr_lo, + sprintf(dp, "near snr_lo=%d cap=%d n=%u", (int)effectiveFloodSuppressSnrLo(), (int)NEAR_NEIGHBOUR_COVERAGE_CAP, (unsigned)n); while (*dp) dp++; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index a6d9d7d6f8..db7a6540ab 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -152,9 +152,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { // 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 + int8_t _fs_eff_lo; // derived snr_lo (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; + int _fs_floor_baseline; // slow-tracked quiet noise floor (this site's baseline); <= -999 = not yet learned uint32_t _fs_seen; // distinct floods heard (denominator of suppression ratio) uint32_t _fs_suppressed; // floods whose rebroadcast was made redundant (numerator) // Observability breakdown of the suppression numerator (surfaced in `get flood.suppress`). @@ -221,6 +223,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { 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 + int8_t effectiveFloodSuppressSnrLo() const; // adaptive? _fs_eff_lo : flood_suppress_snr_lo 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); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 2d0361312f..d255983992 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -107,7 +107,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy 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 file.read((uint8_t *)&_prefs->trace_tx_power_dbm, sizeof(_prefs->trace_tx_power_dbm)); // 299 - file.read((uint8_t *)&_prefs->flood_suppress_noise_floor, sizeof(_prefs->flood_suppress_noise_floor)); // 300 + file.read((uint8_t *)&_prefs->flood_suppress_noise_margin, sizeof(_prefs->flood_suppress_noise_margin)); // 300 // next: 301 // sanitise bad pref values @@ -145,7 +145,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy _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); _prefs->trace_tx_power_dbm = constrain(_prefs->trace_tx_power_dbm, -9, 30); - _prefs->flood_suppress_noise_floor = constrain(_prefs->flood_suppress_noise_floor, -120, 0); + if (_prefs->flood_suppress_noise_margin != FLOOD_SUPPRESS_NOISE_MARGIN_AUTO) + _prefs->flood_suppress_noise_margin = constrain(_prefs->flood_suppress_noise_margin, FLOOD_SUPPRESS_NOISE_MARGIN_MIN, FLOOD_SUPPRESS_NOISE_MARGIN_MAX); file.close(); } @@ -517,10 +518,13 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep 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, "flood.suppress.noise.floor ", 27) == 0) { - int dbm = atoi(&config[27]); - if (dbm >= -120 && dbm <= 0) { _prefs->flood_suppress_noise_floor = dbm; savePrefs(); strcpy(reply, "OK"); } - else strcpy(reply, "Error, must be -120..0 dBm"); + } else if (memcmp(config, "flood.suppress.noise.margin ", 28) == 0) { + if (memcmp(&config[28], "auto", 4) == 0) { _prefs->flood_suppress_noise_margin = FLOOD_SUPPRESS_NOISE_MARGIN_AUTO; savePrefs(); strcpy(reply, "OK"); } + else { + int db = atoi(&config[28]); + if (db >= FLOOD_SUPPRESS_NOISE_MARGIN_MIN && db <= FLOOD_SUPPRESS_NOISE_MARGIN_MAX) { _prefs->flood_suppress_noise_margin = db; savePrefs(); strcpy(reply, "OK"); } + else strcpy(reply, "Error, must be auto or 0..40 dB"); + } } else if (memcmp(config, "trace.tx.power ", 15) == 0) { int db = atoi(&config[15]); if (db >= -9 && db <= 30) { _prefs->trace_tx_power_dbm = db; savePrefs(); strcpy(reply, "OK"); } @@ -855,8 +859,11 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep 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.noise.floor", 26) == 0) { - sprintf(reply, "> %d dBm", (int) _prefs->flood_suppress_noise_floor); + } else if (memcmp(config, "flood.suppress.noise.margin", 27) == 0) { + if (_prefs->flood_suppress_noise_margin == FLOOD_SUPPRESS_NOISE_MARGIN_AUTO) + sprintf(reply, "> auto (default %d dB)", FLOOD_SUPPRESS_NOISE_MARGIN_DEFAULT); + else + sprintf(reply, "> %d dB", (int) _prefs->flood_suppress_noise_margin); } else if (memcmp(config, "flood.suppress", 14) == 0) { sprintf(reply, "> %s", _prefs->flood_suppress ? "on" : "off"); _callbacks->formatFloodSuppressRatioReply(reply + strlen(reply)); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 91779a3739..d174258013 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -20,6 +20,15 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 +// Noise-gate margin (flood suppression). The channel is "noisy" when the measured noise floor is +// at least this many dB above THIS SITE's slowly-tracked quiet baseline (see MyMesh's baseline +// tracker). Relative, so no expert absolute-dBm guess is needed. AUTO => firmware default; an +// explicit 0..40 dB value overrides it (smaller = more sensitive / suppresses less). +#define FLOOD_SUPPRESS_NOISE_MARGIN_AUTO 127 // int8_t sentinel: use FLOOD_SUPPRESS_NOISE_MARGIN_DEFAULT +#define FLOOD_SUPPRESS_NOISE_MARGIN_DEFAULT 10 // dB above baseline (the auto margin) +#define FLOOD_SUPPRESS_NOISE_MARGIN_MIN 0 +#define FLOOD_SUPPRESS_NOISE_MARGIN_MAX 40 + class NodePrefs : public ConfigSerializer { public: // in-memory backing data @@ -77,8 +86,9 @@ class NodePrefs : public ConfigSerializer { int8_t flood_suppress_snr_lo = 0; // dB: overheard forward with SNR= this => channel considered noisy + // SNR-repeat fallback is fixed ON (not configurable). The noise gate's margin is configurable + // (AUTO = firmware default); see FLOOD_SUPPRESS_NOISE_MARGIN_* above. + int8_t flood_suppress_noise_margin = FLOOD_SUPPRESS_NOISE_MARGIN_AUTO; // dB above baseline (AUTO=default) private: class RadioPrefs : public ConfigSerializer { @@ -162,7 +172,7 @@ class NodePrefs : public ConfigSerializer { def("fs_lo", _parent->flood_suppress_snr_lo); def("fs_dx", _parent->flood_suppress_delay_x); def("fs_tx", _parent->trace_tx_power_dbm); - def("fs_nf", _parent->flood_suppress_noise_floor); + def("fs_nm", _parent->flood_suppress_noise_margin); } public: RepeatPrefs(NodePrefs* parent) : _parent(parent) { } From 9f2f936aad63cdb2d5e8406b818aaa339b59a555 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 14 Aug 2026 10:17:46 +0000 Subject: [PATCH 15/15] flood-suppression: remove the noise gate and payload-class policy entirely The HW deployment (4x Wio-S3 clique + rooftop, 18h) showed the gate vetoing 93-95% of graph-proven redundancies (nblk 1325-2819 vs 66-212 suppressed): the measured floor on an active mesh mostly reflects the mesh's own redundant traffic, so the gate closed exactly when suppression was most valuable -- a self-reinforcing loop. Channel state no longer enters the suppression decision: under load a redundant rebroadcast is itself the load, and cancelling it is correct even at residual delivery risk (user decision). Removed: noiseGateAllowsSuppress/floodSuppressTier, the site noise-floor baseline tracker in updateAdaptiveFloodParams, the flood_suppress_noise_margin pref (macros, NodePrefs field, fs_nm serializer key, legacy byte 300 read, set/get flood.suppress.noise.margin) and the nblk counter -- suppression now gates on the coverage graph / SNR-repeat fallback plus the always-on 3-tier client protection only. `get flood.suppress` reports (graph= snr_fallback=). Co-Authored-By: Claude --- docs/README-flood-suppression.md | 64 +++++++----------- examples/simple_repeater/MyMesh.cpp | 101 +++++----------------------- examples/simple_repeater/MyMesh.h | 4 -- src/helpers/CommonCLI.cpp | 17 +---- src/helpers/CommonCLI.h | 14 +--- 5 files changed, 43 insertions(+), 157 deletions(-) diff --git a/docs/README-flood-suppression.md b/docs/README-flood-suppression.md index 8446832bf3..de907ba75f 100644 --- a/docs/README-flood-suppression.md +++ b/docs/README-flood-suppression.md @@ -67,13 +67,11 @@ cancels reliably land before the redundant TX goes out. ## Configuration -There is **one master switch** and five tuning parameters. The threshold **C**, +There is **one master switch** and four tuning parameters. The threshold **C**, `snr.hi` and `snr.lo` are **not user-configurable** — they are derived from the -neighbour table (adaptive) with static fallbacks (see *Adaptive mode*). The noise -gate's threshold is also **not an absolute value**: it is measured relative to each -site's own quiet baseline. +neighbour table (adaptive) with static fallbacks (see *Adaptive mode*). -`NodePrefs` fields (`src/helpers/CommonCLI.h`), persisted at file bytes 295–302 +`NodePrefs` fields (`src/helpers/CommonCLI.h`), persisted at file bytes 295–299 (`src/helpers/CommonCLI.cpp`): | Field | Type | Default | Meaning | @@ -83,7 +81,6 @@ site's own quiet baseline. | `flood_suppress_snr_lo` | `int8_t` (dB) | `0` | Near-membership threshold; overheard forward with SNR `<` this counts **0** (adaptive p25; configured value is the fallback). | | `flood_suppress_delay_x` | `uint8_t` | `3` | Extra TX-delay multiplier for central flood relays. | | `trace_tx_power_dbm` | `int8_t` (dBm) | `10` | TX power for coverage TRACE probes only (lower = less disturbance). | -| `flood_suppress_noise_margin` | `int8_t` (dB) | `AUTO` (10) | Margin above the site's quiet baseline at which the channel counts as noisy; `AUTO` = firmware default. On a noisy channel only cheap (self-healing) payloads are suppressed. | The feature is **on by default**; `set flood.suppress off` (or YAML `flood_suppress: 0`) disables it completely. @@ -100,34 +97,22 @@ The feature is **on by default**; `set flood.suppress off` (or YAML | `set flood.suppress.snr.hi ` | `-30..30` (`get flood.suppress.snr.hi`) | | `set flood.suppress.snr.lo ` | `-30..30` (`get flood.suppress.snr.lo`); adaptive p25 fallback | | `set flood.suppress.delay.factor ` | `0..8` (`get flood.suppress.delay.factor`) | -| `set flood.suppress.noise.margin ` | `auto` or `0..40` (`get flood.suppress.noise.margin`) | | `set trace.tx.power ` | `-9..30` (`get trace.tx.power`) | -### Payload-class noise gate - -A repeater's retransmit **adds** airtime; on a congested channel every extra TX -worsens the collision problem. The noise gate therefore applies a payload-class -policy whenever the channel is noisy. "Noisy" is **relative**: the measured noise -floor is `>=` this site's slowly-tracked quiet **baseline** plus -`flood_suppress_noise_margin` dB (default `AUTO` = 10 dB) — so no absolute-dBm -guess is needed: - -| Class | Payload types | On a noisy channel | -|---|---|---| -| **Cheap / self-healing** | `ADVERT` | may be suppressed (silence > repeat) | -| **Confidence-only** | `TRACE`, `CONTROL`, `GRP_TXT`, `GRP_DATA`, `PATH`, `ANON_REQ` | forwarded (only suppressed on a quiet channel) | -| **Payload-critical** | `REQ`, `RESPONSE`, `TXT_MSG`, `ACK`, `MULTIPART` | never suppressed by the gate | - -The gate is fixed ON. The margin defaults to `AUTO` (the firmware derives the -threshold from the baseline); an explicit `0..40` dB override only makes it more -or less sensitive. - -`TRACE` and `CONTROL` are deliberately **not** in the cheap class: `TRACE` feeds the -coverage graph this suppressor depends on, and `CONTROL` drives neighbour discovery, so -dropping them on a noisy channel would starve the very data the gate relies on and risk -a self-reinforcing collapse of the reach graph. They are still suppressible on a *quiet* -channel (redundant copies cost airtime for no benefit); only the noisy-channel drop is -withheld. +### Channel-state policy: deliberately none + +An earlier revision gated suppression on the measured noise floor (a per-site +quiet baseline plus a configurable margin) with a payload-class policy on top. +It was **removed** on purpose. On an active mesh the measured floor mostly +reflects the mesh's **own** redundant traffic, so the gate closed exactly when +suppression was most valuable — a self-reinforcing loop (little suppression → +more forwards → "noisy" → even less suppression). Channel state therefore does +not enter the suppression decision at all: under load a redundant rebroadcast +is itself the load, and cancelling it is the right move even at some residual +delivery risk. The only content-based gate is the always-on 3-tier **client +protection** (`MyMesh::clientProtectionAllowsSuppress`: TRACE/CONTROL free, +addressed types iff the destination is not an attached client, broadcasts that +clients may need are always forwarded). ### SNR-repeat fallback @@ -139,7 +124,9 @@ applies: each overheard forward of the same hash increments a per-flood weighted counter (`SNR >= snr.hi` → +2, `< snr.lo` → 0, else +1). Once the weighted count reaches the effective **C**, the rebroadcast is cancelled even without graph proof. The graph result always wins; the fallback only widens the suppression -set. It is fixed ON and not configurable. +set. The same 3-tier client protection applies as on the graph path; channel +state and payload class gate neither path (see *Channel-state policy: +deliberately none*). The fallback is fixed ON and not configurable. --- @@ -149,14 +136,13 @@ With the master switch **on**, the threshold **C**, `snr.hi` **and `snr.lo`** ar **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 safe **static fallbacks** when no neighbour data is available. No -per-topology tuning is required. The noise-floor **baseline** (for the relative -noise gate) is learned here too. +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()` / `effectiveFloodSuppressSnrLo()`. C/hi/lo are derived under `#if MAX_NEIGHBOURS` -(the table is a build flag); the baseline tracker runs every cycle regardless. +(the table is a build flag). **Derivation** (only **fresh** neighbours counted — `heard_timestamp` age ≤ 600 s, i.e. heard within the last 10 min): @@ -179,9 +165,7 @@ identity, so seeding brand-new neighbours still needs an advert / node-discovery | `effective_snr_hi` | link-SNR **p75** of fresh neighbours | `clamp(p75, eff.lo+4, eff.lo+12)`; needs ≥ 4 samples, else the configured `snr.hi` | `snr.lo` does **not** feed back into the density count `n` (which is by timestamp -only), so widening/narrowing the near set cannot oscillate `c`. The **noise-floor -baseline** (fast follow down, slow 1/16 approach up so a permanent rise eventually -re-baselines) makes the noise gate deployment-independent. +only), so widening/narrowing the near set cannot oscillate `c`. 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 @@ -233,7 +217,7 @@ static fallback. The refresh is a hardware-only improvement. | 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_eff_lo`, `_fs_adaptive_active`, `_fs_floor_baseline`, …); `cancelPendingFloodOutbound`, `updateAdaptiveFloodParams`, `effectiveFloodSuppressC/Hi/Lo`, `touchNeighbourByHash`; `sendNodeDiscoverReq(delay_millis)`. | +| `examples/simple_repeater/MyMesh.h` | Helper include; `_flood_supp` + adaptive state (`_fs_eff_c`, `_fs_eff_hi`, `_fs_eff_lo`, `_fs_adaptive_active`, …); `cancelPendingFloodOutbound`, `updateAdaptiveFloodParams`, `effectiveFloodSuppressC/Hi/Lo`, `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. | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index cbf0468e47..731e315b67 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -268,54 +268,6 @@ bool MyMesh::clientProtectionAllowsSuppress(const mesh::Packet* pkt, uint32_t no return false; // Tier B } -// --- Payload-class suppression policy (noise-aware) ----------------------------- -// A repeater's retransmit ADDS airtime. On a congested/noisy channel every extra TX -// worsens the collision problem, so "stay silent rather than repeat" applies to the -// payload classes whose loss is cheap/self-healing. The class decides whether a -// redundant rebroadcast may be cancelled on a busy channel: -// 0 = NEVER (payload-critical): REQ, RESPONSE, TXT_MSG, ACK, MULTIPART -- always -// re-forward (subject to the normal dedup/coverage logic); noise never vetoes. -// 1 = CONFIDENCE-ONLY (best-effort but not disposable): TRACE, CONTROL, GRP_TXT, -// GRP_DATA, PATH, ANON_REQ -- suppressible on a quiet channel, forwarded on a -// noisy one. TRACE/CONTROL live here (NOT in tier 2): they are measurement and -// discovery plumbing -- TRACE feeds the coverage graph this suppressor relies -// on, CONTROL drives neighbour discovery -- so dropping them on a noisy channel -// would starve that data and risk a self-reinforcing collapse of the reach graph. -// 2 = CHEAP (self-healing): ADVERT (periodic re-send) -- suppressible even on a noisy -// channel (silence > repeat); adverts are rate-limited and re-sent periodically. -uint8_t MyMesh::floodSuppressTier(const mesh::Packet* pkt) const { - switch (pkt->getPayloadType()) { - case PAYLOAD_TYPE_ADVERT: - return 2; // cheap: self-healing (periodic re-send) -- suppressible even when noisy - case PAYLOAD_TYPE_TRACE: - case PAYLOAD_TYPE_CONTROL: - case PAYLOAD_TYPE_GRP_TXT: - case PAYLOAD_TYPE_GRP_DATA: - case PAYLOAD_TYPE_PATH: - case PAYLOAD_TYPE_ANON_REQ: - return 1; // confidence-only: suppressible only on a quiet channel (forwarded when noisy) - default: - return 0; // never: REQ/RESPONSE/TXT_MSG/ACK/MULTIPART/RAW_CUSTOM - } -} - -// Channel-state gate for suppression. On a busy/noisy channel an extra TX amplifies the -// collision problem, so when the channel IS noisy we suppress only the cheap (self-healing) -// class (silence > repeat) and keep the confidence-only classes forwarding. "Noisy" is relative: -// the measured floor is at least `margin` dB above THIS SITE's slowly-tracked quiet baseline -// (see updateAdaptiveFloodParams), so no expert absolute-dBm value is needed. Margin is AUTO -// (firmware default) unless an explicit CLI override is set. Until the baseline is learned -// (first cycle, ~60s) we assume noisy -- the safe direction (keep confidence classes forwarding). -bool MyMesh::noiseGateAllowsSuppress(const mesh::Packet* pkt) const { - uint8_t tier = floodSuppressTier(pkt); - if (tier == 0) return false; // payload-critical: never suppress - int8_t margin = (_prefs.flood_suppress_noise_margin == FLOOD_SUPPRESS_NOISE_MARGIN_AUTO) - ? FLOOD_SUPPRESS_NOISE_MARGIN_DEFAULT : _prefs.flood_suppress_noise_margin; - bool noisy = (_fs_floor_baseline <= -999) || - ((int)_radio->getNoiseFloor() >= _fs_floor_baseline + (int)margin); - return noisy ? (tier == 2) : true; // noisy: only cheap; quiet: tier 1 or 2 -} - // --- Active TRACE coverage measurement ---------------------------------------- // Send one round-trip coverage TRACE: visit-list [a, b, self] with 2-byte hashes. // It walks self->a->b->self; the SNR measured at b of a's forward (path_snrs[1]) @@ -935,17 +887,6 @@ int8_t MyMesh::effectiveFloodSuppressSnrLo() const { // 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() { - // --- Noise-floor baseline (relative noise gate) --- - // getNoiseFloor() is the median-estimated ambient (RadioLibWrappers). We track its quiet - // minimum as THIS SITE's baseline: fast follow downward (got quieter), slow 1/16 approach - // upward (a persistent rise -- e.g. a new interferer -- slowly becomes the new baseline, so the - // gate eventually re-opens; mirrors the median estimator's bounded hold-release). "noisy" is - // then floor >= baseline + margin -- deployment-independent, no expert absolute-dBm. Runs every - // cycle (60s) when flood suppression is on, independent of MAX_NEIGHBOURS. - int cur_floor = _radio->getNoiseFloor(); - if (_fs_floor_baseline <= -999) _fs_floor_baseline = cur_floor; // first sample - else if (cur_floor < _fs_floor_baseline) _fs_floor_baseline = cur_floor; // fast down - else _fs_floor_baseline += (cur_floor - _fs_floor_baseline) / 16; // slow up #if MAX_NEIGHBOURS int n = 0; int8_t snr_x4[MAX_NEIGHBOURS]; @@ -1155,19 +1096,14 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { } // (d) suppress iff no isolated-uncovered peer, every coverage peer covered, and - // client-protection allows it (3-tier, always active). The noise gate then - // decides by payload class: on a busy channel only cheap (self-healing) - // payloads are suppressed (silence > repeat); payload-critical ones forward. + // client-protection allows it (3-tier, always active). Channel state does not + // enter the decision: under load the redundant TX itself IS the load. if (!e->must_cover_self && allNearNeighboursCovered(*e, now) && clientProtectionAllowsSuppress(pkt, now)) { - if (noiseGateAllowsSuppress(pkt)) { - e->suppressed = true; - _fs_suppressed++; // our rebroadcast was made redundant - _fs_supp_graph++; - cancelPendingFloodOutbound(hash); - } else { - _fs_noise_blocked++; // graph said redundant, noise gate vetoed - } + e->suppressed = true; + _fs_suppressed++; // our rebroadcast was made redundant + _fs_supp_graph++; + cancelPendingFloodOutbound(hash); } #endif } @@ -1180,8 +1116,9 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { // proof (e.g. the forwarders are rank >cap, so no TRACE edge covers them). The // graph result always wins: this only fires when the graph could not prove // coverage, and never overrides must_cover_self (an uncovered top-N neighbour M - // definitively owes coverage to -- only M's own TX can reach it). Same noise gate - // + client protection as the graph path. + // definitively owes coverage to -- only M's own TX can reach it). Same client + // protection as the graph path; channel state and payload class do not gate this + // (deliberate -- see README). if (e && !e->suppressed && !is_new) { uint8_t c = effectiveFloodSuppressC(); if (c > 0 && e->snr_fallback_wcount < 255) { @@ -1190,8 +1127,7 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { int8_t lo = effectiveFloodSuppressSnrLo(); e->snr_fallback_wcount += (snr >= hi) ? 2 : (snr < lo) ? 0 : 1; if (e->snr_fallback_wcount >= c && !e->snr_fallback_suppressed && !e->must_cover_self && - clientProtectionAllowsSuppress(pkt, getRTCClock()->getCurrentTime()) && - noiseGateAllowsSuppress(pkt)) { + clientProtectionAllowsSuppress(pkt, getRTCClock()->getCurrentTime())) { e->snr_fallback_suppressed = true; e->suppressed = true; _fs_suppressed++; @@ -1658,10 +1594,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _fs_pending_c = 0; _fs_adaptive_active = false; // until neighbour data is available -> static fallback _fs_next_recompute_ms = 0; - _fs_floor_baseline = -999; // noise-floor baseline: not yet learned -> gate assumes noisy _fs_seen = 0; _fs_suppressed = 0; - _fs_supp_graph = _fs_supp_snr_fallback = _fs_noise_blocked = 0; + _fs_supp_graph = _fs_supp_snr_fallback = 0; next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; set_radio_at = revert_radio_at = 0; @@ -1707,9 +1642,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_suppress_snr_lo = 0; // dB: weak overheard forward => ignored (preserve edge) _prefs.flood_suppress_delay_x = 3; // extra TX-delay multiplier for central flood relays (wider cancel window) _prefs.trace_tx_power_dbm = 10; // TX power for coverage TRACE probes only (near links are strong; less disturbance) - // SNR-repeat fallback is fixed ON (not configurable). The noise gate's margin defaults to AUTO - // (firmware derives "noisy" relative to this site's baseline); an explicit override is optional. - _prefs.flood_suppress_noise_margin = FLOOD_SUPPRESS_NOISE_MARGIN_AUTO; // relative gate (auto margin) + // SNR-repeat fallback is fixed ON (not configurable). // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -2000,11 +1933,11 @@ 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); // Append the suppression-path breakdown: graph=coverage-graph suppressions, - // snr_fallback=SNR-repeat fallback suppressions, nblk=graph-suppressions vetoed - // by the noise gate. Lets the operator see WHICH mechanism is doing the work. + // snr_fallback=SNR-repeat fallback suppressions. Lets the operator see WHICH + // mechanism is doing the work. char extra[64]; - sprintf(extra, " (graph=%lu snr_fallback=%lu nblk=%lu)", (unsigned long)_fs_supp_graph, - (unsigned long)_fs_supp_snr_fallback, (unsigned long)_fs_noise_blocked); + sprintf(extra, " (graph=%lu snr_fallback=%lu)", (unsigned long)_fs_supp_graph, + (unsigned long)_fs_supp_snr_fallback); strcat(reply, extra); } @@ -2159,7 +2092,7 @@ void MyMesh::clearStats() { ((SimpleMeshTables *)getTables())->resetStats(); _fs_seen = 0; _fs_suppressed = 0; - _fs_supp_graph = _fs_supp_snr_fallback = _fs_noise_blocked = 0; + _fs_supp_graph = _fs_supp_snr_fallback = 0; _meas_sent = _meas_returned = _meas_edge = _meas_timeout = _meas_neg = 0; _meas_harvested = _meas_harvest_neg = 0; } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index db7a6540ab..5d544caf01 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -156,13 +156,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { 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; - int _fs_floor_baseline; // slow-tracked quiet noise floor (this site's baseline); <= -999 = not yet learned uint32_t _fs_seen; // distinct floods heard (denominator of suppression ratio) uint32_t _fs_suppressed; // floods whose rebroadcast was made redundant (numerator) // Observability breakdown of the suppression numerator (surfaced in `get flood.suppress`). uint32_t _fs_supp_graph; // suppressed by the coverage-graph test uint32_t _fs_supp_snr_fallback; // suppressed by the SNR-repeat fallback - uint32_t _fs_noise_blocked; // graph+client said suppress, but the noise gate vetoed // --- Active TRACE coverage measurement state (populates _nbr_links) --- struct PendingTrace { uint32_t tag; @@ -218,8 +216,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void purgeAttachedClients(uint32_t now); // evict stale clients (~24h) bool isKnownRepeaterHash1(uint8_t hash1) const; // does this 1-byte hash match a known repeater neighbour? void cancelPendingFloodOutbound(const uint8_t* hash); // cancel our scheduled flood rebroadcast (if any) - uint8_t floodSuppressTier(const mesh::Packet* pkt) const; // payload-class policy: 0=never, 1=confidence-only, 2=cheap-to-suppress - bool noiseGateAllowsSuppress(const mesh::Packet* pkt) const; // channel-state gate (busy/noisy channel -> restrict to cheap classes) 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 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index d255983992..9f9833a351 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -107,8 +107,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy 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 file.read((uint8_t *)&_prefs->trace_tx_power_dbm, sizeof(_prefs->trace_tx_power_dbm)); // 299 - file.read((uint8_t *)&_prefs->flood_suppress_noise_margin, sizeof(_prefs->flood_suppress_noise_margin)); // 300 - // next: 301 + // next: 300 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -145,8 +144,6 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy _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); _prefs->trace_tx_power_dbm = constrain(_prefs->trace_tx_power_dbm, -9, 30); - if (_prefs->flood_suppress_noise_margin != FLOOD_SUPPRESS_NOISE_MARGIN_AUTO) - _prefs->flood_suppress_noise_margin = constrain(_prefs->flood_suppress_noise_margin, FLOOD_SUPPRESS_NOISE_MARGIN_MIN, FLOOD_SUPPRESS_NOISE_MARGIN_MAX); file.close(); } @@ -518,13 +515,6 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep 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, "flood.suppress.noise.margin ", 28) == 0) { - if (memcmp(&config[28], "auto", 4) == 0) { _prefs->flood_suppress_noise_margin = FLOOD_SUPPRESS_NOISE_MARGIN_AUTO; savePrefs(); strcpy(reply, "OK"); } - else { - int db = atoi(&config[28]); - if (db >= FLOOD_SUPPRESS_NOISE_MARGIN_MIN && db <= FLOOD_SUPPRESS_NOISE_MARGIN_MAX) { _prefs->flood_suppress_noise_margin = db; savePrefs(); strcpy(reply, "OK"); } - else strcpy(reply, "Error, must be auto or 0..40 dB"); - } } else if (memcmp(config, "trace.tx.power ", 15) == 0) { int db = atoi(&config[15]); if (db >= -9 && db <= 30) { _prefs->trace_tx_power_dbm = db; savePrefs(); strcpy(reply, "OK"); } @@ -859,11 +849,6 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep 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.noise.margin", 27) == 0) { - if (_prefs->flood_suppress_noise_margin == FLOOD_SUPPRESS_NOISE_MARGIN_AUTO) - sprintf(reply, "> auto (default %d dB)", FLOOD_SUPPRESS_NOISE_MARGIN_DEFAULT); - else - sprintf(reply, "> %d dB", (int) _prefs->flood_suppress_noise_margin); } else if (memcmp(config, "flood.suppress", 14) == 0) { sprintf(reply, "> %s", _prefs->flood_suppress ? "on" : "off"); _callbacks->formatFloodSuppressRatioReply(reply + strlen(reply)); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index d174258013..365fd10b64 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -20,15 +20,6 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 -// Noise-gate margin (flood suppression). The channel is "noisy" when the measured noise floor is -// at least this many dB above THIS SITE's slowly-tracked quiet baseline (see MyMesh's baseline -// tracker). Relative, so no expert absolute-dBm guess is needed. AUTO => firmware default; an -// explicit 0..40 dB value overrides it (smaller = more sensitive / suppresses less). -#define FLOOD_SUPPRESS_NOISE_MARGIN_AUTO 127 // int8_t sentinel: use FLOOD_SUPPRESS_NOISE_MARGIN_DEFAULT -#define FLOOD_SUPPRESS_NOISE_MARGIN_DEFAULT 10 // dB above baseline (the auto margin) -#define FLOOD_SUPPRESS_NOISE_MARGIN_MIN 0 -#define FLOOD_SUPPRESS_NOISE_MARGIN_MAX 40 - class NodePrefs : public ConfigSerializer { public: // in-memory backing data @@ -86,9 +77,7 @@ class NodePrefs : public ConfigSerializer { int8_t flood_suppress_snr_lo = 0; // dB: overheard forward with SNRflood_suppress_snr_lo); def("fs_dx", _parent->flood_suppress_delay_x); def("fs_tx", _parent->trace_tx_power_dbm); - def("fs_nm", _parent->flood_suppress_noise_margin); } public: RepeatPrefs(NodePrefs* parent) : _parent(parent) { }