From 0204336668215356097b095e03f09277436e9236 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Fri, 10 Jul 2026 12:44:56 +0200 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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) { } From 24fb29947fab6c5adf1363625861631c823ab949 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sun, 23 Aug 2026 20:34:07 +0000 Subject: [PATCH 16/17] flood-suppression: split ADVERT tier by originator (repeater adverts = Tier A) clientProtectionAllowsSuppress previously classified every ADVERT as Tier B (never suppress). Repeaters now parse the advert's originator type from the received payload ([pub_key][timestamp 4][signature][app_data], low nibble of app_data[0]): an advert originated by a repeater (ADV_TYPE_REPEATER) counts as Tier A infrastructure, as suppressible as TRACE/CONTROL -- every repeater learns its neighbours from any overheard copy, and suppression only ever cancels M's own rebroadcast, never M's receive path. All other adverts (client/room/sensor) stay Tier B; malformed or too-short adverts fall back to Tier B (forward, safe), mirroring the addressed-type handling. On default HW prefs flood adverts propagate up to flood_max_advert=8 hops every flood_advert_interval (47h), so redundant rebroadcasts of them are real airtime. Verified in mcsim (4-repeater clique, flood adverts enabled via CLI): baseline suppressed 0/2 (0%), with the split suppressed 3/4 (75%, graph path); flood-advert forwards drop accordingly. Co-Authored-By: Claude --- docs/README-flood-suppression.md | 8 ++++++-- examples/simple_repeater/MyMesh.cpp | 25 ++++++++++++++++++++++--- examples/simple_repeater/MyMesh.h | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/README-flood-suppression.md b/docs/README-flood-suppression.md index de907ba75f..d2c5cb1a68 100644 --- a/docs/README-flood-suppression.md +++ b/docs/README-flood-suppression.md @@ -111,8 +111,12 @@ 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). +adverts originated by a **repeater** (`ADV_TYPE_REPEATER`) equally free — every +repeater learns its neighbours from any overheard copy, and suppression only +cancels M's own rebroadcast, never M's receive path; client/room/sensor adverts +are broadcasts clients may need, so they stay protected like the other broadcast +types —, addressed types iff the destination is not an attached client, and all +remaining broadcasts are always forwarded). ### SNR-repeat fallback diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index faf134432b..d32dee3734 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -252,14 +252,33 @@ bool MyMesh::nearReaches(int from_i, int to_j, uint8_t hs) const { // 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 A (TRACE/CONTROL, ADVERT with originator ADV_TYPE_REPEATER): 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). +// Tier B (client/room/sensor ADVERTs, GRP_*/ACK/MULTIPART/...): broadcast, can't +// address-check, clients may need -> NEVER suppress. 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_ADVERT) { + // Adverts are split by originator type. A REPEATER advert is infrastructure: + // every repeater learns its neighbours from any overheard copy (onAdvertRecv + // fires on receipt -- suppression only ever cancels M's OWN rebroadcast, never + // M's receive path), so it is as suppressible as TRACE/CONTROL. Every other + // advert (client/room/sensor) stays Tier B: clients may need it. Malformed or + // too short -> Tier B (forward, safe), like the addressed types. + // Advert payload as parsed by Mesh::onRecvPacket: [pub_key][timestamp 4] + // [signature][app_data]; app_data[0] = flags byte, low nibble = ADV_TYPE_*. + int off = PUB_KEY_SIZE + 4 + SIGNATURE_SIZE; + if (pkt->payload_len > off) { // parser reads app_data[0] unconditionally + int alen = pkt->payload_len - off; + if (alen > MAX_ADVERT_DATA_SIZE) alen = MAX_ADVERT_DATA_SIZE; // name buffer is MAX_ADVERT_DATA_SIZE + AdvertDataParser parser(&pkt->payload[off], (uint8_t)alen); + if (parser.isValid() && parser.getType() == ADV_TYPE_REPEATER) return true; // Tier A + } + return false; // Tier B + } 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) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index f67322897b..b53811da76 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -210,7 +210,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { 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) + bool clientProtectionAllowsSuppress(const mesh::Packet* pkt, uint32_t now) const; // 3-tier client-aware gate (always active; repeater-originated adverts count as Tier A) 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]) From 66840a391600ebe43d9471340fdae6233e47551e Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Wed, 26 Aug 2026 20:51:24 +0000 Subject: [PATCH 17/17] Add pubkey prefix blacklist/whitelist as CLI-configured key filters Blacklist (`blacklist add|del <8-hex-prefix>`, up to 15 entries in /prefs.json): drop ADVERT and ANON_REQ packets of matching senders at receive -- before dedup, Ed25519 verify, forwarding and neighbour/ attached-client learning. The tool against constantly-advertising nodes: each of their adverts is a fresh flood (new timestamp -> new packet hash) that repeaters otherwise always forward. Adding an entry purges already learned neighbour/attached-client state (onBlacklistEntryAdded). Data packets are unaffected: pre-crypto they expose only 1-byte hashes. Whitelist (same grammar, repeater only): the flood-suppression client gate never suppresses traffic of a whitelisted key, even if the node never checked in -- addressed packets matching the dest or src hash are always rebroadcast, and the originator's adverts are never suppressed. Guarantees delivery to pre-configured listen-only clients and pins must-serve backbone peers. Blacklist takes precedence over whitelist. sim A/B (mcsim): blacklist -- GW forwards 3/3 adverts before, 0/5 after the add (baseline forwards all); whitelist -- DM delivery to a never-checked-in client 11/12 -> 12/12, R1/R2 suppressed 4/10+4/12 -> 0. Co-Authored-By: Claude --- docs/README-flood-suppression.md | 8 +++ docs/cli_commands.md | 22 ++++++ examples/simple_repeater/MyMesh.cpp | 30 ++++++++- examples/simple_repeater/MyMesh.h | 1 + examples/simple_room_server/MyMesh.cpp | 11 +++ src/helpers/CommonCLI.cpp | 92 ++++++++++++++++++++++++++ src/helpers/CommonCLI.h | 45 +++++++++++++ 7 files changed, 208 insertions(+), 1 deletion(-) diff --git a/docs/README-flood-suppression.md b/docs/README-flood-suppression.md index d2c5cb1a68..e8a437c25a 100644 --- a/docs/README-flood-suppression.md +++ b/docs/README-flood-suppression.md @@ -118,6 +118,14 @@ are broadcasts clients may need, so they stay protected like the other broadcast types —, addressed types iff the destination is not an attached client, and all remaining broadcasts are always forwarded). +**Whitelist override:** entries of the pubkey **whitelist** (`whitelist add +<8-hex-prefix>`, see `cli_commands.md` § Key Filters) bypass all tiers — a +whitelisted key's traffic (to or from) is never suppressed, even if the node +never checked in and therefore never entered the attached-client table. This is +the explicit operator override for pre-configured listen-only clients and +must-serve backbone peers. The pubkey **blacklist** on the other hand acts +earlier (at receive, on ADVERT/ANON_REQ only) and independently of suppression. + ### SNR-repeat fallback The coverage-graph test is intentionally conservative: it only suppresses when it diff --git a/docs/cli_commands.md b/docs/cli_commands.md index ffa609c5be..2c34da16bb 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -186,6 +186,28 @@ Lists the **attached leaf clients** — companion/sensor/room-server nodes for w --- +### Key Filters (blacklist / whitelist) + +**Usage:** +- `blacklist` — list entries +- `blacklist add <8-hex-prefix>` — add a public-key prefix +- `blacklist del <8-hex-prefix>` (or `remove`) — delete an entry +- `whitelist` / `whitelist add|del <8-hex-prefix>` — same grammar + +Both lists hold up to **15 entries of fixed 4-byte pubkey prefixes** (8 hex chars — the same prefix the `neighbors`/`clients` commands display). Entries persist in `/prefs.json`; adding an entry also purges already-learned neighbour/attached-client state for that prefix. The blacklist takes precedence if a key matches both lists. + +**Blacklist semantics (repeater + room server):** a matching sender's **ADVERT and ANON_REQ** packets are dropped at receive — before dedup, signature verification, forwarding and neighbour/client learning. This is the tool against nodes that constantly send adverts (each advert is a fresh flood that would otherwise always be forwarded). Other packet types (REQ/RESPONSE/TXT/PATH/GRP) carry only 1-byte hashes in clear, so they *cannot* be prefix-matched before decryption and are unaffected — a blacklisted sender's direct messages still flow. Use `setperm 0` to also evict the key from the contacts/ACL. + +**Whitelist semantics (repeater only):** the flood-suppression gate never suppresses traffic associated with a whitelisted key, even if that node never checked in (no advert seen, so not in the attached-client table): + +- an addressed packet (REQ/RESPONSE/TXT/PATH/ANON_REQ) whose destination hash matches the entry's first byte is always rebroadcast (1-byte match: a stray collision with an unrelated key only causes extra forwarding, never loss), and +- a flood *originated* by a whitelisted key (src hash match) is always rebroadcast, and +- an ADVERT whose sender prefix matches exactly is never suppressed. + +This guarantees delivery to pre-configured clients that only listen (e.g. sensors that never advertise), and pins "must-serve" peers such as backbone repeaters. The whitelist is orthogonal to `setperm` (no permissions/reply-routing effect) and only takes effect with flood suppression active. + +--- + ## Statistics ### Clear Stats diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index d32dee3734..a0fda43b97 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -258,10 +258,15 @@ bool MyMesh::nearReaches(int from_i, int to_j, uint8_t hs) const { // attached client, so suppress OK iff dest is NOT one. // Tier B (client/room/sensor ADVERTs, GRP_*/ACK/MULTIPART/...): broadcast, can't // address-check, clients may need -> NEVER suppress. +// The pubkey WHITELIST overrides all tiers: traffic to/from a whitelisted key is +// never suppressed, even if the node never checked in (attached-client table empty). 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_ADVERT) { + // Whitelisted originator (full sender pubkey is in clear at payload[0..31]): + // never suppress their adverts, incl. Tier-A repeater adverts. + if (pkt->payload_len >= 4 && _prefs.keyInWhitelist(pkt->payload)) return false; // Adverts are split by originator type. A REPEATER advert is infrastructure: // every repeater learns its neighbours from any overheard copy (onAdvertRecv // fires on receipt -- suppression only ever cancels M's OWN rebroadcast, never @@ -282,7 +287,10 @@ bool MyMesh::clientProtectionAllowsSuppress(const mesh::Packet* pkt, uint32_t no 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 + if (attachedClientMatches(pkt->payload[0], now)) return false; // dest is an attached client + if (_prefs.whitelistHash1Match(pkt->payload[0])) return false; // dest whitelisted (never checked in) + if (pkt->payload_len >= 2 && _prefs.whitelistHash1Match(pkt->payload[1])) return false; // originated by whitelisted + return true; // Tier C: suppress OK } return false; // Tier B } @@ -1289,6 +1297,18 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { } mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { + // Pubkey blacklist: drop at receive, i.e. before dedup (wasSeen), Ed25519 verify, + // forwarding and neighbour/attached-client learning. ADVERT + ANON_REQ only -- + // the only payload types carrying the full sender pubkey in clear (ADVERT + // payload[0..31], ANON_REQ payload[1..32]); all other types expose just 1-byte + // hashes pre-crypto, and matching those would drop ~1/256 innocent traffic. + if (_prefs.blacklist_count > 0) { + uint8_t pt = pkt->getPayloadType(); + const uint8_t* key4 = NULL; + if (pt == PAYLOAD_TYPE_ADVERT && pkt->payload_len >= 4) key4 = pkt->payload; + else if (pt == PAYLOAD_TYPE_ANON_REQ && pkt->payload_len >= 5) key4 = pkt->payload + 1; + if (key4 != NULL && _prefs.keyInBlacklist(key4)) return ACTION_RELEASE; + } if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { @@ -1938,6 +1958,14 @@ void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) { #endif } +// A blacklist entry was just added: purge already-learned state for that pubkey +// prefix, so a spam node stops being treated as a neighbour / attached client +// until expiry instead of immediately. +void MyMesh::onBlacklistEntryAdded(const uint8_t *key4) { + removeNeighbor(key4, 4); // prefix match over the neighbour table + removeAttachedClient(key4[0]); // attached-client table is keyed on prefix[0] +} + void MyMesh::startRegionsLoad() { temp_map.resetFrom(region_map); // rebuild regions in a temp instance memset(load_stack, 0, sizeof(load_stack)); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index b53811da76..bf57678009 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -319,6 +319,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { 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 onBlacklistEntryAdded(const uint8_t* key4) override; void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 546d094fc8..5b96d0dd3b 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -307,6 +307,17 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { } mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { + // Pubkey blacklist: drop at receive, i.e. before dedup (wasSeen), Ed25519 verify, + // forwarding and contact learning. ADVERT + ANON_REQ only -- the only payload + // types carrying the full sender pubkey in clear (ADVERT payload[0..31], ANON_REQ + // payload[1..32]); all other types expose just 1-byte hashes pre-crypto. + if (_prefs.blacklist_count > 0) { + uint8_t pt = pkt->getPayloadType(); + const uint8_t* key4 = NULL; + if (pt == PAYLOAD_TYPE_ADVERT && pkt->payload_len >= 4) key4 = pkt->payload; + else if (pt == PAYLOAD_TYPE_ANON_REQ && pkt->payload_len >= 5) key4 = pkt->payload + 1; + if (key4 != NULL && _prefs.keyInBlacklist(key4)) return ACTION_RELEASE; + } if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 8e29b895d3..5e2e015f79 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -267,6 +267,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } } else if (memcmp(command, "near", 4) == 0) { _callbacks->formatNearReply(reply); + } else if (memcmp(command, "blacklist", 9) == 0 && (command[9] == 0 || command[9] == ' ')) { + handleKeyFilterCmd(_prefs->blacklist_keys, &_prefs->blacklist_count, command + 9, true, reply); + } else if (memcmp(command, "whitelist", 9) == 0 && (command[9] == 0 || command[9] == ' ')) { + handleKeyFilterCmd(_prefs->whitelist_keys, &_prefs->whitelist_count, command + 9, false, reply); } else if (memcmp(command, "tempradio ", 10) == 0) { strcpy(tmp, &command[10]); const char *parts[5]; @@ -1237,3 +1241,91 @@ void CommonCLI::handleRegionCmd(char* command, char* reply) { strcpy(reply, "Err - ??"); } } + +// `blacklist [add|del|remove] <8-hex-prefix>` / `whitelist ...` (no args = list). +// Entries are fixed 4-byte pubkey prefixes (8 hex chars, same convention as the +// `neighbors`/`clients` output). Adding to the blacklist also lets the role purge +// learned per-node state via onBlacklistEntryAdded. +void CommonCLI::handleKeyFilterCmd(uint8_t keys[][4], uint8_t* count, const char* args, bool is_blacklist, char* reply) { + const char* name = is_blacklist ? "blacklist" : "whitelist"; + while (*args == ' ') args++; + + if (*args == 0) { // list entries + int n = *count > MAX_KEY_FILTERS ? MAX_KEY_FILTERS : *count; + if (n == 0) { + strcpy(reply, "-none-"); + return; + } + char* dp = reply; + dp += sprintf(dp, "n=%d ", n); + for (int i = 0; i < n; i++) { + if (dp - reply > 140) { strcpy(dp, "..."); return; } // stay inside the 160-byte reply buffer + if (i > 0) *dp++ = ','; + mesh::Utils::toHex(dp, keys[i], 4); + dp += 8; + } + *dp = 0; + return; + } + + const char* verb = args; + const char* sp = strchr(args, ' '); + int verb_len = (sp != NULL) ? sp - verb : strlen(verb); + bool is_add = (verb_len == 3 && memcmp(verb, "add", 3) == 0); + bool is_del = (verb_len == 3 && memcmp(verb, "del", 3) == 0) + || (verb_len == 6 && memcmp(verb, "remove", 6) == 0); + if (!is_add && !is_del) { + sprintf(reply, "Err - usage: %s [add|del] <8-hex-prefix>", name); + return; + } + + const char* hex = (sp != NULL) ? sp + 1 : verb + verb_len; + while (*hex == ' ') hex++; + if (strlen(hex) != 8) { + strcpy(reply, "Err - key must be an 8-hex-char prefix"); + return; + } + for (int i = 0; i < 8; i++) { + if (!mesh::Utils::isHexChar(hex[i])) { + strcpy(reply, "Err - bad key"); + return; + } + } + uint8_t key[4]; + if (!mesh::Utils::fromHex(key, 4, hex)) { // length already validated; defensive + strcpy(reply, "Err - bad key"); + return; + } + + int n = *count > MAX_KEY_FILTERS ? MAX_KEY_FILTERS : *count; + int found = -1; + for (int i = 0; i < n; i++) { + if (memcmp(keys[i], key, 4) == 0) { found = i; break; } + } + + if (is_add) { + if (found >= 0) { + strcpy(reply, "Err - already listed"); + } else if (n >= MAX_KEY_FILTERS) { + sprintf(reply, "Err - full (%d)", MAX_KEY_FILTERS); + } else { + memcpy(keys[n], key, 4); + *count = n + 1; + savePrefs(); + if (is_blacklist) _callbacks->onBlacklistEntryAdded(key); + strcpy(reply, "OK"); + } + } else { + if (found < 0) { + strcpy(reply, "Err - not found"); + } else { + // compact the tail down over the removed entry + for (int i = found; i < n - 1; i++) { + memcpy(keys[i], keys[i + 1], 4); + } + *count = n - 1; + savePrefs(); + strcpy(reply, "OK"); + } + } +} diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 16597f5cd5..09eb42bab8 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -20,6 +20,12 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 +// Public-key prefix filters (blacklist / whitelist). Entries are FIXED 4-byte pubkey +// prefixes -- the same 8-hex-char prefix the `neighbors`/`clients` CLI displays. +// MAX_KEY_FILTERS is a hard cap: the persisted hex blob (15*4*2 = 120 chars) must +// stay under ConfigSerializer's quoted-token limit (CONFIG_MAX_TOKEN_LEN-1 = 127). +#define MAX_KEY_FILTERS 15 + class NodePrefs : public ConfigSerializer { public: // in-memory backing data @@ -80,6 +86,35 @@ class NodePrefs : public ConfigSerializer { int8_t trace_tx_power_dbm = 0; // TX power (dBm) used ONLY for coverage TRACE probes (lower = less disturbance) // SNR-repeat fallback is fixed ON (not configurable). + // Public-key prefix filters. Blacklist: drop ADVERT/ANON_REQ from matching senders + // at receive (only those types carry the full sender pubkey in clear; data packets + // expose just 1-byte hashes). Whitelist: repeater never flood-suppresses traffic + // to/from a matching key, even if the node never checked in. Entries are 4-byte + // pubkey prefixes; blacklist takes precedence over whitelist. + uint8_t blacklist_keys[MAX_KEY_FILTERS][4] = {}; + uint8_t blacklist_count = 0; + uint8_t whitelist_keys[MAX_KEY_FILTERS][4] = {}; + uint8_t whitelist_count = 0; + + // Match a 4-byte pubkey prefix (counts clamped defensively against corrupt prefs). + bool keyInBlacklist(const uint8_t* key4) const { + uint8_t n = blacklist_count > MAX_KEY_FILTERS ? MAX_KEY_FILTERS : blacklist_count; + for (int i = 0; i < n; i++) if (memcmp(blacklist_keys[i], key4, 4) == 0) return true; + return false; + } + bool keyInWhitelist(const uint8_t* key4) const { + uint8_t n = whitelist_count > MAX_KEY_FILTERS ? MAX_KEY_FILTERS : whitelist_count; + for (int i = 0; i < n; i++) if (memcmp(whitelist_keys[i], key4, 4) == 0) return true; + return false; + } + // 1-byte hash match (first byte of a whitelisted prefix == dest/src hash of an + // addressed packet). A false positive (1/256) only causes extra forwarding. + bool whitelistHash1Match(uint8_t hash1) const { + uint8_t n = whitelist_count > MAX_KEY_FILTERS ? MAX_KEY_FILTERS : whitelist_count; + for (int i = 0; i < n; i++) if (whitelist_keys[i][0] == hash1) return true; + return false; + } + private: class RadioPrefs : public ConfigSerializer { NodePrefs* _parent; @@ -196,6 +231,10 @@ class NodePrefs : public ConfigSerializer { def("repeat", repeat); def("room", room); def("power", power); + def("blacklist", blacklist_keys, sizeof(blacklist_keys)); // binary blob -> quoted hex + def("blacklist_n", blacklist_count); + def("whitelist", whitelist_keys, sizeof(whitelist_keys)); // binary blob -> quoted hex + def("whitelist_n", whitelist_count); } public: @@ -226,6 +265,11 @@ class CommonCLICallbacks { virtual void removeNeighbor(const uint8_t* pubkey, int key_len) { // no op by default }; + // A blacklist entry was just added (key4 = 4-byte pubkey prefix). Roles with learned + // per-node state (neighbour / attached-client tables) should purge matching entries. + virtual void onBlacklistEntryAdded(const uint8_t* key4) { + // no op by default + }; virtual void formatStatsReply(char *reply) = 0; virtual void formatRadioStatsReply(char *reply) = 0; virtual void formatPacketStatsReply(char *reply) = 0; @@ -289,6 +333,7 @@ class CommonCLI { void loadPrefsInt(FILESYSTEM* _fs, const char* filename); void handleRegionCmd(char* command, char* reply); + void handleKeyFilterCmd(uint8_t keys[][4], uint8_t* count, const char* args, bool is_blacklist, char* reply); void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply); void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply);