diff --git a/docs/README-flood-suppression.md b/docs/README-flood-suppression.md new file mode 100644 index 0000000000..de907ba75f --- /dev/null +++ b/docs/README-flood-suppression.md @@ -0,0 +1,309 @@ +# 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 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*). + +`NodePrefs` fields (`src/helpers/CommonCLI.h`), persisted at file bytes 295–299 +(`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** (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). | + +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`); adaptive p25 fallback | +| `set flood.suppress.delay.factor ` | `0..8` (`get flood.suppress.delay.factor`) | +| `set trace.tx.power ` | `-9..30` (`get trace.tx.power`) | + +### 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 + +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. 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. + +--- + +## Adaptive mode (self-tuning, zero-admin) + +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. + +`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). + +**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_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`. + +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_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. | + +`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 8772b929fe..ffa609c5be 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 @@ -708,6 +765,69 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### [Experimental] Flood suppression — redundancy-aware rebroadcast cancellation +**Repeater Only:** Yes + +Cancels a repeater's own scheduled flood rebroadcast when neighbouring repeaters have +already forwarded the same flood (i.e. its rebroadcast would be redundant), cutting +on-air flood traffic and collisions while preserving reach. 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:** `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` · `trace.tx.power` = `10` + +**Note:** _Experimental feature_ on branch `feature/flood-suppression-coverage` — still being tuned and measured on hardware. + +--- + ### ACL #### Add, update or remove permissions for a companion diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7d0179f3ab..faf134432b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -79,14 +79,430 @@ 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 (!is_refresh) { + 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; 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 +} + +// 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 = (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 + } + } #endif } +// Is neighbours[i] a "near" coverage peer? fresh (<= NEIGHBOUR_FRESH_S) and link +// 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)(effectiveFloodSuppressSnrLo() * 4); + return neighbours[i].snr >= lo_x4; +#else + return false; +#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). +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; +} + +// 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 + uint8_t n = 0; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (!isNearNeighbour(i, now)) continue; + 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]; + } +#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); + 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 prot > 0; +#else + (void)e; (void)now; 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.) 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, millis()); +#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 +} + +// --- 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)(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 { + // 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 on a per-pair exponential backoff (capped ~10h). + _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; } + } +#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++; + _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++; + } + } + + 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; + + // 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 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); + 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, 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; } + 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, 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 + // 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 +// 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 @@ -431,11 +847,126 @@ 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; + +// 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 { + 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; +} +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() { +#if MAX_NEIGHBOURS + int n = 0; + int8_t snr_x4[MAX_NEIGHBOURS]; + uint32_t now = getRTCClock()->getCurrentTime(); // seconds (RTC) + 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_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 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 + 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; + } + + // 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 || 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 +#endif +} + bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood() - && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { - 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 (mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { + return false; + } } if (packet->isRouteFlood() && recv_pkt_region == NULL) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); @@ -477,6 +1008,176 @@ 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); + } + + // --- 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. 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); + bool is_new = false; + 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 + uint8_t hs = pkt->getPathHashSize(); + uint8_t count = pkt->getPathHashCount(); + const uint8_t* p = pkt->path; + + // 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); + + // 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 = findNearNeighbour(p, hs, now); + if (idx >= 0) forwarded[idx] = true; // this near neighbour forwarded F => has F + p += hs; + } + + // (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 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) 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 (isExcludedFromProtection(i, millis())) continue; // Part 3: M->i broken -> not owed coverage + if (forwarded[i]) continue; // i forwarded F -> covered + bool reachable = false; + 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; + } + + // (d) suppress iff no isolated-uncovered peer, every coverage peer covered, and + // 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)) { + e->suppressed = true; + _fs_suppressed++; // our rebroadcast was made redundant + _fs_supp_graph++; + cancelPendingFloodOutbound(hash); + } +#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 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) { + float snr = pkt->getSNR(); + int8_t hi = effectiveFloodSuppressSnrHi(); + 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())) { + e->snr_fallback_suppressed = true; + e->suppressed = true; + _fs_suppressed++; + _fs_supp_snr_fallback++; + cancelPendingFloodOutbound(hash); + } + } + } + } +#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)(effectiveFloodSuppressSnrLo() * 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); @@ -546,7 +1247,22 @@ 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. 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()) { + 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; } uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); @@ -651,11 +1367,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()); + } } } } @@ -842,19 +1565,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); } } @@ -875,6 +1608,15 @@ 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_eff_lo = 0; + _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; + _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; @@ -905,8 +1647,22 @@ 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 = 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). // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -1042,11 +1798,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() { @@ -1174,6 +1951,150 @@ 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); + // Append the suppression-path breakdown: graph=coverage-graph suppressions, + // 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)", (unsigned long)_fs_supp_graph, + (unsigned long)_fs_supp_snr_fallback); + strcat(reply, extra); +} + +// `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 = 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)) 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)) 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 +} + +// `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)effectiveFloodSuppressSnrLo(), + (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), neg=pairs cached + // 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 + // 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_harvested, (unsigned)unr); + 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, ""); @@ -1191,6 +2112,11 @@ void MyMesh::clearStats() { radio_driver.resetStats(); resetStats(); ((SimpleMeshTables *)getTables())->resetStats(); + _fs_seen = 0; + _fs_suppressed = 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; } void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { @@ -1291,6 +2217,18 @@ 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 (~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 + + 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 6d9cf459ce..f67322897b 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -30,6 +30,8 @@ #include #include #include +#include +#include #include #include #include @@ -63,11 +65,50 @@ 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 + +// --- 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) +// 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 +// (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 @@ -100,6 +141,44 @@ 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 + 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. 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 + 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; + 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 + // --- 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 + 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; @@ -121,6 +200,27 @@ 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 + 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 + 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]) + 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 + 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); @@ -141,6 +241,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; @@ -183,7 +284,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; } @@ -214,10 +315,14 @@ 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 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; void formatPacketStatsReply(char *reply) override; + void formatFloodSuppressRatioReply(char *reply) override; void startRegionsLoad() override; bool saveRegions() override; void onDefaultRegionChanged(const RegionEntry* r) override; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index a714db68ec..23604d95d2 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -119,6 +119,14 @@ void setup() { the_mesh.sendSelfAdvertisement(16000, false); #endif + // When flood suppression is enabled, actively discover direct neighbours shortly + // after boot so the neighbour list — which adaptive c/snr_hi derivation relies on — fills + // fast (~30-60s), instead of waiting for periodic adverts. Fired after the boot self-advert; + // jitter inside sendNodeDiscoverReq de-synchronises a fleet reboot. + if (the_mesh.getNodePrefs()->flood_suppress) { + the_mesh.sendNodeDiscoverReq(16000 + 5000); // ~21s + jitter + } + board.onBootComplete(); } diff --git a/src/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 b318bb58e8..8e29b895d3 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -102,7 +102,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy 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 + 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); @@ -135,6 +140,11 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean _prefs->radio_fem_txgain = constrain(_prefs->radio_fem_txgain, 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); + _prefs->trace_tx_power_dbm = constrain(_prefs->trace_tx_power_dbm, -9, 30); file.close(); } @@ -238,6 +248,25 @@ 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, "near", 4) == 0) { + _callbacks->formatNearReply(reply); } else if (memcmp(command, "tempradio ", 10) == 0) { strcpy(tmp, &command[10]); const char *parts[5]; @@ -471,6 +500,26 @@ 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, "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(); @@ -817,6 +866,15 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold); } else if (memcmp(config, "cad", 3) == 0) { sprintf(reply, "> %s", _prefs->cad_enabled ? "on" : "off"); + } else if (memcmp(config, "flood.suppress.delay.factor", 27) == 0) { + sprintf(reply, "> %d", (uint32_t) _prefs->flood_suppress_delay_x); + } else if (memcmp(config, "flood.suppress.snr.hi", 21) == 0) { + sprintf(reply, "> %d dB", (int) _prefs->flood_suppress_snr_hi); + } else if (memcmp(config, "flood.suppress.snr.lo", 21) == 0) { + sprintf(reply, "> %d dB", (int) _prefs->flood_suppress_snr_lo); + } else if (memcmp(config, "flood.suppress", 14) == 0) { + sprintf(reply, "> %s", _prefs->flood_suppress ? "on" : "off"); + _callbacks->formatFloodSuppressRatioReply(reply + strlen(reply)); } else if (memcmp(config, "agc.reset.interval", 18) == 0) { sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4); } else if (memcmp(config, "multi.acks", 10) == 0) { @@ -897,6 +955,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 237c758e9f..16597f5cd5 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -70,6 +70,15 @@ class NodePrefs : public ConfigSerializer { uint8_t loop_detect = 0; uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) uint8_t extra_sf[4]; + // 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 = 0; // master switch (0=off, 1=on); feature default applied in MyMesh ctor + int8_t flood_suppress_snr_hi = 0; // dB: overheard forward with SNR>=this counts double (central/redundant) + int8_t flood_suppress_snr_lo = 0; // dB: overheard forward with SNRflood_max_unscoped); def("f_max_adv", _parent->flood_max_advert); def("loop", _parent->loop_detect); + def("fs", _parent->flood_suppress); + def("fs_hi", _parent->flood_suppress_snr_hi); + def("fs_lo", _parent->flood_suppress_snr_lo); + def("fs_dx", _parent->flood_suppress_delay_x); + def("fs_tx", _parent->trace_tx_power_dbm); } public: RepeatPrefs(NodePrefs* parent) : _parent(parent) { } @@ -215,6 +229,17 @@ 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) { } + // 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) { } + // Near coverage peers (fresh + 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/FloodSuppression.h b/src/helpers/FloodSuppression.h new file mode 100644 index 0000000000..7bf3f26c1a --- /dev/null +++ b/src/helpers/FloodSuppression.h @@ -0,0 +1,144 @@ +#pragma once + +#include // MAX_HASH_SIZE +#include + +// --- 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. +// +// M suppresses its rebroadcast of flood F if every NEAR neighbour is already +// 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 no forwarder reaches stays uncovered, so M forwards (never deafens). +// +// 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. + +#ifndef FLOOD_SUPPRESS_TABLE_SIZE + #define FLOOD_SUPPRESS_TABLE_SIZE 32 +#endif + +#ifndef FLOOD_SUPPRESS_TTL_MILLIS + #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 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 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; + + // --- 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++) + 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 { + 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->covered_count = 0; + 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; + } + + // Evict expired entries. Call from loop(). + void purge(uint32_t now) { + for (int i = 0; i < FLOOD_SUPPRESS_TABLE_SIZE; i++) { + if (_entries[i].active && _expired(_entries[i], now)) { + _entries[i].active = false; + } + } + } + +private: + static bool _expired(const FloodSuppressionEntry& e, uint32_t now) { + // uint32 subtraction is wrap-safe for any ttl well below the wrap period. + return (uint32_t)(now - e.first_seen_ms) > FLOOD_SUPPRESS_TTL_MILLIS; + } +}; diff --git a/src/helpers/NeighbourLinkTable.h b/src/helpers/NeighbourLinkTable.h new file mode 100644 index 0000000000..afd5d9c2c4 --- /dev/null +++ b/src/helpers/NeighbourLinkTable.h @@ -0,0 +1,239 @@ +#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 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 +// 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 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(). +// +// --- 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. 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 + #define NEIGHBOUR_LINK_TABLE_SIZE 128 +#endif + +#ifndef NEIGHBOUR_LINK_TTL_MILLIS + #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_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 { + 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; + + // 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; + 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]; + int _neg_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)); + 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. + // A bidirectional link occupies two separate entries (src->dst and dst->src), + // 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) { + _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)) { + 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? (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 || _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; + } + } + 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; + } + } + } + + // --- 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)) { + // 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; + } + } + 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.backoff_ms = NEIGHBOUR_LINK_NEG_BACKOFF_BASE_MILLIS; // first failure -> short backoff (fast retry) + 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; + } + + // 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 && (uint32_t)(now - _neg[i].last_seen_ms) > (2UL * NEIGHBOUR_LINK_NEG_BACKOFF_MAX_MILLIS)) _neg[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; + } + + // 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) > n.backoff_ms; + } + + // 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; + } + } +}; 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); + } };