Skip to content

Commit df0f576

Browse files
usrfloclaude
andcommitted
Merge branch 'feature/flood-suppression' into ufo
Co-Authored-By: Claude <noreply@anthropic.com>
2 parents 3d7932e + df17304 commit df0f576

7 files changed

Lines changed: 50 additions & 157 deletions

File tree

docs/README-flood-suppression.md

Lines changed: 24 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,11 @@ cancels reliably land before the redundant TX goes out.
6767

6868
## Configuration
6969

70-
There is **one master switch** and five tuning parameters. The threshold **C**,
70+
There is **one master switch** and four tuning parameters. The threshold **C**,
7171
`snr.hi` and `snr.lo` are **not user-configurable** — they are derived from the
72-
neighbour table (adaptive) with static fallbacks (see *Adaptive mode*). The noise
73-
gate's threshold is also **not an absolute value**: it is measured relative to each
74-
site's own quiet baseline.
72+
neighbour table (adaptive) with static fallbacks (see *Adaptive mode*).
7573

76-
`NodePrefs` fields (`src/helpers/CommonCLI.h`), persisted at file bytes 295–302
74+
`NodePrefs` fields (`src/helpers/CommonCLI.h`), persisted at file bytes 295–299
7775
(`src/helpers/CommonCLI.cpp`):
7876

7977
| Field | Type | Default | Meaning |
@@ -83,7 +81,6 @@ site's own quiet baseline.
8381
| `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). |
8482
| `flood_suppress_delay_x` | `uint8_t` | `3` | Extra TX-delay multiplier for central flood relays. |
8583
| `trace_tx_power_dbm` | `int8_t` (dBm) | `10` | TX power for coverage TRACE probes only (lower = less disturbance). |
86-
| `flood_suppress_noise_margin` | `int8_t` (dB) | `AUTO` (10) | Margin above the site's quiet baseline at which the channel counts as noisy; `AUTO` = firmware default. On a noisy channel only cheap (self-healing) payloads are suppressed. |
8784

8885
The feature is **on by default**; `set flood.suppress off` (or YAML
8986
`flood_suppress: 0`) disables it completely.
@@ -100,34 +97,22 @@ The feature is **on by default**; `set flood.suppress off` (or YAML
10097
| `set flood.suppress.snr.hi <dB>` | `-30..30` (`get flood.suppress.snr.hi`) |
10198
| `set flood.suppress.snr.lo <dB>` | `-30..30` (`get flood.suppress.snr.lo`); adaptive p25 fallback |
10299
| `set flood.suppress.delay.factor <n>` | `0..8` (`get flood.suppress.delay.factor`) |
103-
| `set flood.suppress.noise.margin <dB>` | `auto` or `0..40` (`get flood.suppress.noise.margin`) |
104100
| `set trace.tx.power <dBm>` | `-9..30` (`get trace.tx.power`) |
105101

106-
### Payload-class noise gate
107-
108-
A repeater's retransmit **adds** airtime; on a congested channel every extra TX
109-
worsens the collision problem. The noise gate therefore applies a payload-class
110-
policy whenever the channel is noisy. "Noisy" is **relative**: the measured noise
111-
floor is `>=` this site's slowly-tracked quiet **baseline** plus
112-
`flood_suppress_noise_margin` dB (default `AUTO` = 10 dB) — so no absolute-dBm
113-
guess is needed:
114-
115-
| Class | Payload types | On a noisy channel |
116-
|---|---|---|
117-
| **Cheap / self-healing** | `ADVERT` | may be suppressed (silence > repeat) |
118-
| **Confidence-only** | `TRACE`, `CONTROL`, `GRP_TXT`, `GRP_DATA`, `PATH`, `ANON_REQ` | forwarded (only suppressed on a quiet channel) |
119-
| **Payload-critical** | `REQ`, `RESPONSE`, `TXT_MSG`, `ACK`, `MULTIPART` | never suppressed by the gate |
120-
121-
The gate is fixed ON. The margin defaults to `AUTO` (the firmware derives the
122-
threshold from the baseline); an explicit `0..40` dB override only makes it more
123-
or less sensitive.
124-
125-
`TRACE` and `CONTROL` are deliberately **not** in the cheap class: `TRACE` feeds the
126-
coverage graph this suppressor depends on, and `CONTROL` drives neighbour discovery, so
127-
dropping them on a noisy channel would starve the very data the gate relies on and risk
128-
a self-reinforcing collapse of the reach graph. They are still suppressible on a *quiet*
129-
channel (redundant copies cost airtime for no benefit); only the noisy-channel drop is
130-
withheld.
102+
### Channel-state policy: deliberately none
103+
104+
An earlier revision gated suppression on the measured noise floor (a per-site
105+
quiet baseline plus a configurable margin) with a payload-class policy on top.
106+
It was **removed** on purpose. On an active mesh the measured floor mostly
107+
reflects the mesh's **own** redundant traffic, so the gate closed exactly when
108+
suppression was most valuable — a self-reinforcing loop (little suppression →
109+
more forwards → "noisy" → even less suppression). Channel state therefore does
110+
not enter the suppression decision at all: under load a redundant rebroadcast
111+
is itself the load, and cancelling it is the right move even at some residual
112+
delivery risk. The only content-based gate is the always-on 3-tier **client
113+
protection** (`MyMesh::clientProtectionAllowsSuppress`: TRACE/CONTROL free,
114+
addressed types iff the destination is not an attached client, broadcasts that
115+
clients may need are always forwarded).
131116

132117
### SNR-repeat fallback
133118

@@ -139,7 +124,9 @@ applies: each overheard forward of the same hash increments a per-flood weighted
139124
counter (`SNR >= snr.hi` → +2, `< snr.lo` → 0, else +1). Once the weighted count
140125
reaches the effective **C**, the rebroadcast is cancelled even without graph
141126
proof. The graph result always wins; the fallback only widens the suppression
142-
set. It is fixed ON and not configurable.
127+
set. The same 3-tier client protection applies as on the graph path; channel
128+
state and payload class gate neither path (see *Channel-state policy:
129+
deliberately none*). The fallback is fixed ON and not configurable.
143130

144131
---
145132

@@ -149,14 +136,13 @@ With the master switch **on**, the threshold **C**, `snr.hi` **and `snr.lo`** ar
149136
**derived from the repeater's neighbour table** (`simple_repeater`'s `neighbours[]`,
150137
seeded from zero-hop repeater adverts / node-discovery and kept fresh by overheard
151138
forwards), with safe **static fallbacks** when no neighbour data is available. No
152-
per-topology tuning is required. The noise-floor **baseline** (for the relative
153-
noise gate) is learned here too.
139+
per-topology tuning is required.
154140

155141
`MyMesh::updateAdaptiveFloodParams()` runs throttled (~every 1 min) from `loop()`
156142
and caches the **effective** values; the consumption sites read
157143
`effectiveFloodSuppressC()` / `effectiveFloodSuppressSnrHi()` /
158144
`effectiveFloodSuppressSnrLo()`. C/hi/lo are derived under `#if MAX_NEIGHBOURS`
159-
(the table is a build flag); the baseline tracker runs every cycle regardless.
145+
(the table is a build flag).
160146

161147
**Derivation** (only **fresh** neighbours counted — `heard_timestamp` age ≤ 600 s,
162148
i.e. heard within the last 10 min):
@@ -179,9 +165,7 @@ identity, so seeding brand-new neighbours still needs an advert / node-discovery
179165
| `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` |
180166

181167
`snr.lo` does **not** feed back into the density count `n` (which is by timestamp
182-
only), so widening/narrowing the near set cannot oscillate `c`. The **noise-floor
183-
baseline** (fast follow down, slow 1/16 approach up so a permanent rise eventually
184-
re-baselines) makes the noise gate deployment-independent.
168+
only), so widening/narrowing the near set cannot oscillate `c`.
185169

186170
A 2-cycle debounce on `c` prevents flapping when the neighbour count fluctuates (at
187171
a 1-min recompute cadence an adopted change lands within ~2 min; the recompute cost
@@ -233,7 +217,7 @@ static fallback. The refresh is a hardware-only improvement.
233217
| File | Change |
234218
|---|---|
235219
| `src/helpers/FloodSuppression.h` | **New.** Per-hash ring: `{hash, weighted_count, first_snr, strongest_overheard, first_seen, suppressed, active}` + `find` / `touch` / `purge`. |
236-
| `examples/simple_repeater/MyMesh.h` | Helper include; `_flood_supp` + adaptive state (`_fs_eff_c`, `_fs_eff_hi`, `_fs_eff_lo`, `_fs_adaptive_active`, `_fs_floor_baseline`, …); `cancelPendingFloodOutbound`, `updateAdaptiveFloodParams`, `effectiveFloodSuppressC/Hi/Lo`, `touchNeighbourByHash`; `sendNodeDiscoverReq(delay_millis)`. |
220+
| `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)`. |
237221
| `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. |
238222
| `examples/simple_repeater/main.cpp` | Boot discovery: `sendNodeDiscoverReq(…)` gated on `flood_suppress`. |
239223
| `src/helpers/CommonCLI.h` / `CommonCLI.cpp` | `NodePrefs` fields + persisted read/write + defaults + `set/get flood.suppress*` CLI handlers. |

examples/companion_radio/NodePrefs.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,12 @@ class NodePrefs : public ConfigSerializer { // persisted to file
5454
//def("cad", _parent->cad_enabled);
5555
//def("int_thr", _parent->interference_threshold);
5656
def("rxgain", _parent->rx_boosted_gain);
57+
#if 0
58+
// NOTE: these cannot be set (yet) so don't load/save until we can.
59+
// also, fem_rxgain WAS mapped to wrong JSON property previously
5760
def("fem_rxgain", _parent->radio_fem_rxgain);
5861
def("fem_txgain", _parent->radio_fem_txgain);
62+
#endif
5963
def("tx", _parent->tx_power_dbm);
6064
def("af", _parent->airtime_factor);
6165
def("rxdelay", _parent->rx_delay_base);

examples/simple_repeater/MyMesh.cpp

Lines changed: 17 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -282,54 +282,6 @@ bool MyMesh::clientProtectionAllowsSuppress(const mesh::Packet* pkt, uint32_t no
282282
return false; // Tier B
283283
}
284284

285-
// --- Payload-class suppression policy (noise-aware) -----------------------------
286-
// A repeater's retransmit ADDS airtime. On a congested/noisy channel every extra TX
287-
// worsens the collision problem, so "stay silent rather than repeat" applies to the
288-
// payload classes whose loss is cheap/self-healing. The class decides whether a
289-
// redundant rebroadcast may be cancelled on a busy channel:
290-
// 0 = NEVER (payload-critical): REQ, RESPONSE, TXT_MSG, ACK, MULTIPART -- always
291-
// re-forward (subject to the normal dedup/coverage logic); noise never vetoes.
292-
// 1 = CONFIDENCE-ONLY (best-effort but not disposable): TRACE, CONTROL, GRP_TXT,
293-
// GRP_DATA, PATH, ANON_REQ -- suppressible on a quiet channel, forwarded on a
294-
// noisy one. TRACE/CONTROL live here (NOT in tier 2): they are measurement and
295-
// discovery plumbing -- TRACE feeds the coverage graph this suppressor relies
296-
// on, CONTROL drives neighbour discovery -- so dropping them on a noisy channel
297-
// would starve that data and risk a self-reinforcing collapse of the reach graph.
298-
// 2 = CHEAP (self-healing): ADVERT (periodic re-send) -- suppressible even on a noisy
299-
// channel (silence > repeat); adverts are rate-limited and re-sent periodically.
300-
uint8_t MyMesh::floodSuppressTier(const mesh::Packet* pkt) const {
301-
switch (pkt->getPayloadType()) {
302-
case PAYLOAD_TYPE_ADVERT:
303-
return 2; // cheap: self-healing (periodic re-send) -- suppressible even when noisy
304-
case PAYLOAD_TYPE_TRACE:
305-
case PAYLOAD_TYPE_CONTROL:
306-
case PAYLOAD_TYPE_GRP_TXT:
307-
case PAYLOAD_TYPE_GRP_DATA:
308-
case PAYLOAD_TYPE_PATH:
309-
case PAYLOAD_TYPE_ANON_REQ:
310-
return 1; // confidence-only: suppressible only on a quiet channel (forwarded when noisy)
311-
default:
312-
return 0; // never: REQ/RESPONSE/TXT_MSG/ACK/MULTIPART/RAW_CUSTOM
313-
}
314-
}
315-
316-
// Channel-state gate for suppression. On a busy/noisy channel an extra TX amplifies the
317-
// collision problem, so when the channel IS noisy we suppress only the cheap (self-healing)
318-
// class (silence > repeat) and keep the confidence-only classes forwarding. "Noisy" is relative:
319-
// the measured floor is at least `margin` dB above THIS SITE's slowly-tracked quiet baseline
320-
// (see updateAdaptiveFloodParams), so no expert absolute-dBm value is needed. Margin is AUTO
321-
// (firmware default) unless an explicit CLI override is set. Until the baseline is learned
322-
// (first cycle, ~60s) we assume noisy -- the safe direction (keep confidence classes forwarding).
323-
bool MyMesh::noiseGateAllowsSuppress(const mesh::Packet* pkt) const {
324-
uint8_t tier = floodSuppressTier(pkt);
325-
if (tier == 0) return false; // payload-critical: never suppress
326-
int8_t margin = (_prefs.flood_suppress_noise_margin == FLOOD_SUPPRESS_NOISE_MARGIN_AUTO)
327-
? FLOOD_SUPPRESS_NOISE_MARGIN_DEFAULT : _prefs.flood_suppress_noise_margin;
328-
bool noisy = (_fs_floor_baseline <= -999) ||
329-
((int)_radio->getNoiseFloor() >= _fs_floor_baseline + (int)margin);
330-
return noisy ? (tier == 2) : true; // noisy: only cheap; quiet: tier 1 or 2
331-
}
332-
333285
// --- Active TRACE coverage measurement ----------------------------------------
334286
// Send one round-trip coverage TRACE: visit-list [a, b, self] with 2-byte hashes.
335287
// It walks self->a->b->self; the SNR measured at b of a's forward (path_snrs[1])
@@ -956,17 +908,6 @@ int8_t MyMesh::effectiveFloodSuppressSnrLo() const {
956908
// loop(); sets _fs_adaptive_active. Under #if MAX_NEIGHBOURS (else adaptive stays inactive and
957909
// effectiveFloodSuppressC falls back to FLOOD_SUPPRESS_FALLBACK_C).
958910
void MyMesh::updateAdaptiveFloodParams() {
959-
// --- Noise-floor baseline (relative noise gate) ---
960-
// getNoiseFloor() is the median-estimated ambient (RadioLibWrappers). We track its quiet
961-
// minimum as THIS SITE's baseline: fast follow downward (got quieter), slow 1/16 approach
962-
// upward (a persistent rise -- e.g. a new interferer -- slowly becomes the new baseline, so the
963-
// gate eventually re-opens; mirrors the median estimator's bounded hold-release). "noisy" is
964-
// then floor >= baseline + margin -- deployment-independent, no expert absolute-dBm. Runs every
965-
// cycle (60s) when flood suppression is on, independent of MAX_NEIGHBOURS.
966-
int cur_floor = _radio->getNoiseFloor();
967-
if (_fs_floor_baseline <= -999) _fs_floor_baseline = cur_floor; // first sample
968-
else if (cur_floor < _fs_floor_baseline) _fs_floor_baseline = cur_floor; // fast down
969-
else _fs_floor_baseline += (cur_floor - _fs_floor_baseline) / 16; // slow up
970911
#if MAX_NEIGHBOURS
971912
int n = 0;
972913
int8_t snr_x4[MAX_NEIGHBOURS];
@@ -1174,19 +1115,14 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) {
11741115
}
11751116

11761117
// (d) suppress iff no isolated-uncovered peer, every coverage peer covered, and
1177-
// client-protection allows it (3-tier, always active). The noise gate then
1178-
// decides by payload class: on a busy channel only cheap (self-healing)
1179-
// payloads are suppressed (silence > repeat); payload-critical ones forward.
1118+
// client-protection allows it (3-tier, always active). Channel state does not
1119+
// enter the decision: under load the redundant TX itself IS the load.
11801120
if (!e->must_cover_self && allNearNeighboursCovered(*e, now)
11811121
&& clientProtectionAllowsSuppress(pkt, now)) {
1182-
if (noiseGateAllowsSuppress(pkt)) {
1183-
e->suppressed = true;
1184-
_fs_suppressed++; // our rebroadcast was made redundant
1185-
_fs_supp_graph++;
1186-
cancelPendingFloodOutbound(hash);
1187-
} else {
1188-
_fs_noise_blocked++; // graph said redundant, noise gate vetoed
1189-
}
1122+
e->suppressed = true;
1123+
_fs_suppressed++; // our rebroadcast was made redundant
1124+
_fs_supp_graph++;
1125+
cancelPendingFloodOutbound(hash);
11901126
}
11911127
#endif
11921128
}
@@ -1199,8 +1135,9 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) {
11991135
// proof (e.g. the forwarders are rank >cap, so no TRACE edge covers them). The
12001136
// graph result always wins: this only fires when the graph could not prove
12011137
// coverage, and never overrides must_cover_self (an uncovered top-N neighbour M
1202-
// definitively owes coverage to -- only M's own TX can reach it). Same noise gate
1203-
// + client protection as the graph path.
1138+
// definitively owes coverage to -- only M's own TX can reach it). Same client
1139+
// protection as the graph path; channel state and payload class do not gate this
1140+
// (deliberate -- see README).
12041141
if (e && !e->suppressed && !is_new) {
12051142
uint8_t c = effectiveFloodSuppressC();
12061143
if (c > 0 && e->snr_fallback_wcount < 255) {
@@ -1209,8 +1146,7 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) {
12091146
int8_t lo = effectiveFloodSuppressSnrLo();
12101147
e->snr_fallback_wcount += (snr >= hi) ? 2 : (snr < lo) ? 0 : 1;
12111148
if (e->snr_fallback_wcount >= c && !e->snr_fallback_suppressed && !e->must_cover_self &&
1212-
clientProtectionAllowsSuppress(pkt, getRTCClock()->getCurrentTime()) &&
1213-
noiseGateAllowsSuppress(pkt)) {
1149+
clientProtectionAllowsSuppress(pkt, getRTCClock()->getCurrentTime())) {
12141150
e->snr_fallback_suppressed = true;
12151151
e->suppressed = true;
12161152
_fs_suppressed++;
@@ -1696,10 +1632,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
16961632
_fs_pending_c = 0;
16971633
_fs_adaptive_active = false; // until neighbour data is available -> static fallback
16981634
_fs_next_recompute_ms = 0;
1699-
_fs_floor_baseline = -999; // noise-floor baseline: not yet learned -> gate assumes noisy
17001635
_fs_seen = 0;
17011636
_fs_suppressed = 0;
1702-
_fs_supp_graph = _fs_supp_snr_fallback = _fs_noise_blocked = 0;
1637+
_fs_supp_graph = _fs_supp_snr_fallback = 0;
17031638
next_local_advert = next_flood_advert = 0;
17041639
dirty_contacts_expiry = 0;
17051640
set_radio_at = revert_radio_at = 0;
@@ -1746,9 +1681,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
17461681
_prefs.flood_suppress_snr_lo = 0; // dB: weak overheard forward => ignored (preserve edge)
17471682
_prefs.flood_suppress_delay_x = 3; // extra TX-delay multiplier for central flood relays (wider cancel window)
17481683
_prefs.trace_tx_power_dbm = 10; // TX power for coverage TRACE probes only (near links are strong; less disturbance)
1749-
// SNR-repeat fallback is fixed ON (not configurable). The noise gate's margin defaults to AUTO
1750-
// (firmware derives "noisy" relative to this site's baseline); an explicit override is optional.
1751-
_prefs.flood_suppress_noise_margin = FLOOD_SUPPRESS_NOISE_MARGIN_AUTO; // relative gate (auto margin)
1684+
// SNR-repeat fallback is fixed ON (not configurable).
17521685

17531686
// bridge defaults
17541687
_prefs.bridge_enabled = 1; // enabled
@@ -2046,11 +1979,11 @@ void MyMesh::formatFloodSuppressRatioReply(char *reply) {
20461979
if (!_prefs.flood_suppress) return; // plain "> off" when the master switch is off
20471980
StatsFormatHelper::formatFloodSuppressRatio(reply, _fs_suppressed, _fs_seen);
20481981
// Append the suppression-path breakdown: graph=coverage-graph suppressions,
2049-
// snr_fallback=SNR-repeat fallback suppressions, nblk=graph-suppressions vetoed
2050-
// by the noise gate. Lets the operator see WHICH mechanism is doing the work.
1982+
// snr_fallback=SNR-repeat fallback suppressions. Lets the operator see WHICH
1983+
// mechanism is doing the work.
20511984
char extra[64];
2052-
sprintf(extra, " (graph=%lu snr_fallback=%lu nblk=%lu)", (unsigned long)_fs_supp_graph,
2053-
(unsigned long)_fs_supp_snr_fallback, (unsigned long)_fs_noise_blocked);
1985+
sprintf(extra, " (graph=%lu snr_fallback=%lu)", (unsigned long)_fs_supp_graph,
1986+
(unsigned long)_fs_supp_snr_fallback);
20541987
strcat(reply, extra);
20551988
}
20561989

@@ -2205,7 +2138,7 @@ void MyMesh::clearStats() {
22052138
((SimpleMeshTables *)getTables())->resetStats();
22062139
_fs_seen = 0;
22072140
_fs_suppressed = 0;
2208-
_fs_supp_graph = _fs_supp_snr_fallback = _fs_noise_blocked = 0;
2141+
_fs_supp_graph = _fs_supp_snr_fallback = 0;
22092142
_meas_sent = _meas_returned = _meas_edge = _meas_timeout = _meas_neg = 0;
22102143
_meas_harvested = _meas_harvest_neg = 0;
22112144
}

0 commit comments

Comments
 (0)