Skip to content

Fix/noise floor ratchet, adapt noise to the real floor with recovery in both directions - #2933

Draft
usrflo wants to merge 7 commits into
meshcore-dev:devfrom
usrflo:fix/noise-floor-ratchet
Draft

Fix/noise floor ratchet, adapt noise to the real floor with recovery in both directions#2933
usrflo wants to merge 7 commits into
meshcore-dev:devfrom
usrflo:fix/noise-floor-ratchet

Conversation

@usrflo

@usrflo usrflo commented Jul 12, 2026

Copy link
Copy Markdown

Symptom

On long-running nodes (I saw it with an ufo integration branch, which combines feature/repeated-sending-2 + feature/quiet-dwell), direct-packet resends and dwell-gated TX get deferred or suppressed even on a quiet channel.
The radio's reported noise_floor drifts to the -120 clamp and never recovers, so the RSSI-margin LBT checks (isResendChannelActive, isChannelNoisy, isChannelActive with interference_threshold) stay permanently over-sensitive — every send looks like it collides with noise.

Root cause

RadioLibWrapper::loop() calibrated _noise_floor from a 64-sample block, but only accepted samples that satisfied

rssi < _noise_floor + SAMPLING_THRESHOLD   // SAMPLING_THRESHOLD = 14

That filter is a one-way ratchet: it admits ever-lower samples but rejects anything above the current floor, so the block mean can only move down. Over time it walks to the -120 lower clamp and sticks there. The only thing that reset it was resetAGC setting _noise_floor = 0 — but resetAGC is gated on agc_reset_interval, which defaults to 0 (off), and forcing 0 would anyway open a brief permissive LBT window (margin = RSSI − 0) until the next block completes.

The fix

Replace the ratcheted block mean with the median of the 64-sample block:

  • Accept every idle sample (!isReceivingPacket()) — no downward bias.
  • Sort the 64 samples and take the median (mean of the two middle values). The median rejects transient interference spikes in both directions and recovers upward as well as down.
  • Write _noise_floor only after a full block is collected. The previous value stays valid while the next block is sampled — no reset-to-0, hence no permissive LBT window during reconvergence.
  • Clamp the result to -120 (lower bound of the radio's RSSI range).
  • resetAGC() no longer touches _noise_floor; it only discards the in-progress block (the analog frontend was just reset, so queued samples are stale). _noise_floor itself is left in place because the median estimator no longer needs the hard reset that the ratchet did.

SAMPLING_THRESHOLD is removed (it only fed the ratchet filter).
NUM_NOISE_FLOOR_SAMPLES (64) moves to the header so the sample buffer can be a member array.

chart-noise-floor

usrflo and others added 3 commits July 11, 2026 12:10
The noise-floor calibration sampled only RSSI values below the current
floor + threshold, a one-way ratchet: it accepted ever-lower samples but
never recovered upward, so _noise_floor drifted to the -120 clamp and
stayed there. That left the RSSI-margin LBT (isChannelActive with
interference_threshold, plus isResendChannelActive / isChannelNoisy on
the feature branches that consume _noise_floor) permanently over-sensitive
— resends and dwell-gated TX deferred even on a quiet channel.

Replace the ratcheted block mean with the median of the 64-sample block:
- accepts every idle (!isReceivingPacket) sample — no downward bias;
- median rejects transient interference spikes (high and low outliers) and
  recovers in BOTH directions;
- _noise_floor is written only after a full block, so the previous value
  stays valid while the next block is sampled — no reset-to-0 and thus no
  permissive LBT window (margin = RSSI - 0) during reconvergence.

resetAGC no longer forces _noise_floor = 0 (the stuck-ratchet workaround);
it only discards the in-progress block so a fresh one is measured after the
analog frontend reset.

Verified: Heltec_v3_repeater firmware build (compiles RadioLibWrappers.cpp
against real RadioLib).

Co-Authored-By: Claude <noreply@anthropic.com>
Documents the ratchet-to-median fix on fix/noise-floor-ratchet: symptom,
root cause (one-way ratchet drift to -120), the median-of-64 replacement,
files touched, build verification note (sim does not compile
RadioLibWrappers.cpp; verified via Heltec_v3_repeater), and merge intent.

Co-Authored-By: Claude <noreply@anthropic.com>
@usrflo

usrflo commented Jul 15, 2026

Copy link
Copy Markdown
Author

Problem Proof on dev Branch, _noise_floor = -120 trap, Case C

Patch noise-floor-provocation-C.patch demonstrates on the dev branch that if _noise_floor ever drops to -120, it does not recover on its own - resulting in the RSSI-Margin-LBT becoming permanently hypersensitive. Recovery is only possible via a reboot or resetAGC() (which forces _noise_floor = 0).

  // [TEMP PROVOCATION C ] After ~20 s of healthy operation, seed
  // _noise_floor = -120 to demonstrate the unrecoverable trap. With the floor at -120 the
  // filter cutoff becomes -106; ambient idle RSSI (~-92) lies ABOVE that, so every sample is
  // rejected, the block never completes, and _noise_floor stays stuck at -120 until resetAGC().
  // The 20 s lead-in lets the healthy baseline (floor ~ -92, rejected=0) print first.

$ pio device monitor --baud 115200 --port /dev/meshhtv3
--- Terminal on /dev/meshhtv3 | 115200 8-N-1
--- Available filters and text transformations: debug, default, direct, hexlify, log2file, nocontrol, printable, send_on_enter, time
--- More details at https://bit.ly/pio-monitor-filters
--- Quit: Ctrl+C | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H
DEBUG: floor-sample: floor=-92 cutoff=-78 accepted=1/64 rejected=0 [9056 ms]
DEBUG: noise_floor = -92  [9069 ms]  (block: 64 accepted, 0 rejected above cutoff -78)
DEBUG: floor-sample: floor=-92 cutoff=-78 accepted=1/64 rejected=0 [11057 ms]
DEBUG: noise_floor = -92  [11070 ms]  (block: 64 accepted, 0 rejected above cutoff -78)
DEBUG: floor-sample: floor=-92 cutoff=-78 accepted=1/64 rejected=0 [13058 ms]
DEBUG: noise_floor = -93  [13071 ms]  (block: 64 accepted, 0 rejected above cutoff -78)
DEBUG: floor-sample: floor=-93 cutoff=-79 accepted=1/64 rejected=0 [15059 ms]
DEBUG: noise_floor = -92  [15072 ms]  (block: 64 accepted, 0 rejected above cutoff -79)
DEBUG: floor-sample: floor=-92 cutoff=-78 accepted=1/64 rejected=0 [17060 ms]
DEBUG: noise_floor = -93  [17073 ms]  (block: 64 accepted, 0 rejected above cutoff -78)
10:52:48 - 15/5/2024 U: TX, len=119 (type=4, route=D, payload_len=117)
DEBUG: floor-sample: floor=-93 cutoff=-79 accepted=1/64 rejected=0 [19878 ms]
DEBUG: noise_floor = -97  [19891 ms]  (block: 64 accepted, 0 rejected above cutoff -79)
DEBUG: >>> PROVOCATION C: seeded _noise_floor=-120 (cutoff -106), block reset @ [20000 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=4245 [20878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=9099 [21878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=13953 [22878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=19041 [23878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=24150 [24878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=29260 [25878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=34369 [26878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=39478 [27878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=44587 [28878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=49697 [29878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=54806 [30878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=59915 [31878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=65024 [32878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=4598 [33878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=9707 [34878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=14816 [35878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=19925 [36878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=25035 [37878 ms]
DEBUG: floor-sample: floor=-120 cutoff=-106 accepted=0/64 rejected=30144 [38878 ms]

@usrflo

usrflo commented Jul 15, 2026

Copy link
Copy Markdown
Author

Problem Proof on dev Branch, Drifting Floor, Case B

Patch noise-floor-provocation-B.patch demonstrates the one-way drift of the noise floor on the dev branch, provoked by a reduced threshold.

From 20 s — Drift (threshold = 2): cutoff = floor + 2 slips into the noise; each block censors the upper portion → floor slides monotonically downward, rejected count rises, cutoff follows.
Finally, a stall: as soon as the floor slips below the lower edge of the actual noise, even low-value samples are discarded → accepted count freezes, no more noise_floor lines appear — the same stall as in C, occurring no later than the -120 clamp.

What B demonstrates (vs. C)

Variant	Demonstrates	Mechanism
C	The trap: once at -120 → permanently stuck	Floor set directly to -120
B	The drift: the one-sided bias accumulates block by block, accelerates, and leads to a stall	Floor slides downward past the actual noise floor

Together, they cover: cause (B) and symptom (C).

$ pio device monitor --baud 115200 --port /dev/meshhtv3
--- Terminal on /dev/meshhtv3 | 115200 8-N-1
--- Available filters and text transformations: debug, default, direct, hexlify, log2file, nocontrol, printable, send_on_enter, time
--- More details at https://bit.ly/pio-monitor-filters
--- Quit: Ctrl+C | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H
DEBUG: floor-sample: floor=-88 cutoff=-74 accepted=1/64 rejected=0 [9053 ms]
DEBUG: noise_floor = -90  [9066 ms]  (block: 64 accepted, 0 rejected above cutoff -74)
DEBUG: floor-sample: floor=-90 cutoff=-76 accepted=1/64 rejected=0 [11054 ms]
DEBUG: noise_floor = -90  [11067 ms]  (block: 64 accepted, 0 rejected above cutoff -76)
DEBUG: floor-sample: floor=-90 cutoff=-76 accepted=1/64 rejected=0 [13055 ms]
DEBUG: noise_floor = -90  [13068 ms]  (block: 64 accepted, 0 rejected above cutoff -76)
DEBUG: floor-sample: floor=-90 cutoff=-76 accepted=1/64 rejected=0 [15056 ms]
DEBUG: noise_floor = -90  [15069 ms]  (block: 64 accepted, 0 rejected above cutoff -76)
DEBUG: floor-sample: floor=-90 cutoff=-76 accepted=1/64 rejected=0 [17057 ms]
DEBUG: noise_floor = -90  [17070 ms]  (block: 64 accepted, 0 rejected above cutoff -76)
10:55:13 - 15/5/2024 U: TX, len=119 (type=4, route=D, payload_len=117)
DEBUG: floor-sample: floor=-90 cutoff=-76 accepted=1/64 rejected=0 [19874 ms]
DEBUG: noise_floor = -94  [19888 ms]  (block: 64 accepted, 0 rejected above cutoff -76)
DEBUG: >>> PROVOCATION B: sampling threshold 14 -> 2 (cutoff into noise) @ [20000 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=1 [21059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=4748 [22059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=9498 [23059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=14496 [24059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=19494 [25059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=24493 [26059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=29491 [27059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=34490 [28059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=39488 [29059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=44487 [30059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=49485 [31059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=54484 [32059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=59482 [33059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=64481 [34059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=3943 [35059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=8942 [36059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=13940 [37059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=18939 [38059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=23937 [39059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=28936 [40059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=33934 [41059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=38933 [42059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=0/64 rejected=43931 [43059 ms]
10:55:37 - 15/5/2024 U RAW: 0A401C1D87C180408ADCD9E340B6B7C6AB67E184AC1F
10:55:37 - 15/5/2024 U: RX, len=22 (type=2, route=D, payload_len=20) SNR=11 RSSI=-8 score=1000 time=304 hash=FA52762105A668D3 [1D -> 1C]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=6/64 rejected=47830 [44059 ms]
10:55:38 - 15/5/2024 U RAW: 0E01B7BEFC9BE7003F
10:55:38 - 15/5/2024 U: RX, len=9 (type=3, route=D, payload_len=6) SNR=12 RSSI=-26 score=1000 time=242 hash=965BF82987750713
10:55:38 - 15/5/2024 U: TX, len=8 (type=3, route=D, payload_len=6)
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=26/64 rejected=51142 [45059 ms]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=26/64 rejected=56140 [46059 ms]
10:55:40 - 15/5/2024 U RAW: 0A401C1DC08C688ADD169EBA8C73C8E24AFC42760CBA
10:55:40 - 15/5/2024 U: RX, len=22 (type=2, route=D, payload_len=20) SNR=11 RSSI=-8 score=1000 time=304 hash=5CBE8F7ACDE3FA62 [1D -> 1C]
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=32/64 rejected=60038 [47059 ms]
10:55:41 - 15/5/2024 U RAW: 0E01B7A0F5625500B7
10:55:41 - 15/5/2024 U: RX, len=9 (type=3, route=D, payload_len=6) SNR=12 RSSI=-26 score=1000 time=242 hash=9DFA572F7C6B9C07
10:55:41 - 15/5/2024 U: TX, len=8 (type=3, route=D, payload_len=6)
DEBUG: floor-sample: floor=-94 cutoff=-92 accepted=52/64 rejected=63351 [48059 ms]
10:55:42 - 15/5/2024 U RAW: 0A401C1DF0DA76AC77CB8167C6D7C98E53617530A6BD
10:55:42 - 15/5/2024 U: RX, len=22 (type=2, route=D, payload_len=20) SNR=11 RSSI=-8 score=1000 time=304 hash=AC33E9424DBBD29B [1D -> 1C]
DEBUG: noise_floor = -104  [48340 ms]  (block: 64 accepted, 63652 rejected above cutoff -92)
10:55:42 - 15/5/2024 U RAW: 0E01B773D0F84E0043
10:55:42 - 15/5/2024 U: RX, len=9 (type=3, route=D, payload_len=6) SNR=12 RSSI=-26 score=1000 time=242 hash=D68792B891BDCB64
10:55:42 - 15/5/2024 U: TX, len=8 (type=3, route=D, payload_len=6)
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=1 [49073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=4998 [50073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=9997 [51073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=14995 [52073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=19993 [53073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=24992 [54073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=29990 [55073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=34988 [56073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=39987 [57073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=44985 [58073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=49983 [59073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=54982 [60073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=59980 [61073 ms]
DEBUG: floor-sample: floor=-104 cutoff=-102 accepted=0/64 rejected=64978 [62073 ms]

@usrflo

usrflo commented Jul 15, 2026

Copy link
Copy Markdown
Author

Proof of fix/noise-floor-ratchet (PR 2933), _noise_floor = -120 trap, Case C

Patch fix-noise-floor-ratchet-C.patch demonstrates the noise floor doesn't drop permanently if once lowered using the changes from PR 2933.

$ pio device monitor --baud 115200 --port /dev/meshhtv3
--- Terminal on /dev/meshhtv3 | 115200 8-N-1
--- Available filters and text transformations: debug, default, direct, hexlify, log2file, nocontrol, printable, send_on_enter, time
--- More details at https://bit.ly/pio-monitor-filters
--- Quit: Ctrl+C | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H
DEBUG: floor-sample: floor=-91 accepted=0/64 [5057 ms]
DEBUG: noise_floor = -91 (median) [mean=-90 min=-91 max=-90] [5070 ms]
DEBUG: floor-sample: floor=-91 accepted=0/64 [7058 ms]
DEBUG: noise_floor = -91 (median) [mean=-90 min=-91 max=-90] [7071 ms]
DEBUG: floor-sample: floor=-91 accepted=0/64 [9059 ms]
DEBUG: noise_floor = -91 (median) [mean=-90 min=-91 max=-90] [9072 ms]
DEBUG: floor-sample: floor=-91 accepted=0/64 [11060 ms]
DEBUG: noise_floor = -91 (median) [mean=-90 min=-91 max=-90] [11073 ms]
DEBUG: floor-sample: floor=-91 accepted=0/64 [13061 ms]
DEBUG: noise_floor = -91 (median) [mean=-90 min=-91 max=-90] [13074 ms]
DEBUG: floor-sample: floor=-91 accepted=0/64 [15062 ms]
DEBUG: noise_floor = -90 (median) [mean=-90 min=-91 max=-90] [15075 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [17063 ms]
DEBUG: noise_floor = -91 (median) [mean=-91 min=-92 max=-91] [17076 ms]
10:52:48 - 15/5/2024 U: TX, len=119 (type=4, route=D, payload_len=117)
DEBUG: floor-sample: floor=-91 accepted=0/64 [19881 ms]
DEBUG: noise_floor = -91 (median) [mean=-95 min=-127 max=-90] [19894 ms]
DEBUG: >>> PROVOCATION C: seeded _noise_floor=-120 (expect recovery on next block) @ [20000 ms]
DEBUG: floor-sample: floor=-120 accepted=0/64 [21065 ms]
DEBUG: noise_floor = -91 (median) [mean=-90 min=-92 max=-90] [21078 ms]
DEBUG: floor-sample: floor=-91 accepted=0/64 [23066 ms]
DEBUG: noise_floor = -91 (median) [mean=-90 min=-91 max=-90] [23078 ms]
DEBUG: floor-sample: floor=-91 accepted=0/64 [25067 ms]
DEBUG: noise_floor = -91 (median) [mean=-90 min=-91 max=-90] [25079 ms]
DEBUG: floor-sample: floor=-91 accepted=0/64 [27068 ms]
DEBUG: noise_floor = -90 (median) [mean=-90 min=-91 max=-90] [27080 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [29069 ms]

@usrflo

usrflo commented Jul 15, 2026

Copy link
Copy Markdown
Author

Proof of fix/noise-floor-ratchet (PR 2933), Drifting Floor, Case B

Patch fix-noise-floor-ratchet-B.patch demonstrates the noise floor doesn't drift one-sided when using the changes from PR 2933.

$ pio device monitor --baud 115200 --port /dev/meshhtv3
--- Terminal on /dev/meshhtv3 | 115200 8-N-1
--- Available filters and text transformations: debug, default, direct, hexlify, log2file, nocontrol, printable, send_on_enter, time
--- More details at https://bit.ly/pio-monitor-filters
--- Quit: Ctrl+C | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H
DEBUG: RX Boosted Gain Mode: Enabled
DEBUG: noise_floor = -90 (median) [mean=-89 min=-90 max=-89] [3100 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [5051 ms]
DEBUG: noise_floor = -90 (median) [mean=-90 min=-91 max=-90] [5064 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [7052 ms]
DEBUG: noise_floor = -90 (median) [mean=-89 min=-90 max=-89] [7065 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [9053 ms]
DEBUG: noise_floor = -90 (median) [mean=-90 min=-91 max=-89] [9066 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [11054 ms]
DEBUG: noise_floor = -90 (median) [mean=-90 min=-91 max=-89] [11067 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [13055 ms]
DEBUG: noise_floor = -90 (median) [mean=-90 min=-91 max=-89] [13068 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [15056 ms]
DEBUG: noise_floor = -90 (median) [mean=-90 min=-91 max=-90] [15069 ms]
10:54:32 - 15/5/2024 U RAW: 0A401C1DE928A5B89399ED9A0B15B35CD9A441B1E010
10:54:32 - 15/5/2024 U: RX, len=22 (type=2, route=D, payload_len=20) SNR=12 RSSI=-32 score=1000 time=304 hash=18A4BA9D9F519DA1 [1D -> 1C]
DEBUG: floor-sample: floor=-90 accepted=0/64 [17057 ms]
DEBUG: noise_floor = -77 (median) [mean=-60 min=-90 max=-24] [17070 ms]
10:54:33 - 15/5/2024 U RAW: 0E01B7CABB1460004F
10:54:33 - 15/5/2024 U: RX, len=9 (type=3, route=D, payload_len=6) SNR=11 RSSI=-24 score=1000 time=242 hash=73FC3496B9E94CAF
10:54:33 - 15/5/2024 U: TX, len=8 (type=3, route=D, payload_len=6)
10:54:34 - 15/5/2024 U: TX, len=119 (type=4, route=D, payload_len=117)
DEBUG: floor-sample: floor=-77 accepted=0/64 [19875 ms]
DEBUG: noise_floor = -91 (median) [mean=-95 min=-127 max=-90] [19889 ms]
DEBUG: >>> PROVOCATION B: injecting -120 outliers (median must hold, mean drifts) @ [21059 ms]
DEBUG: floor-sample: floor=-91 accepted=0/64 [21059 ms]
DEBUG: noise_floor = -90 (median) [mean=-97 min=-120 max=-89] [21082 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [23060 ms]
DEBUG: noise_floor = -90 (median) [mean=-97 min=-120 max=-89] [23073 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [25061 ms]
DEBUG: noise_floor = -90 (median) [mean=-97 min=-120 max=-90] [25074 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [27062 ms]
DEBUG: noise_floor = -22 (median) [mean=-46 min=-120 max=-22] [27075 ms]
10:54:43 - 15/5/2024 U RAW: 0A401C1DAFA16FE21459BDF35391087461C1C5AD6A62
10:54:43 - 15/5/2024 U: RX, len=22 (type=2, route=D, payload_len=20) SNR=12 RSSI=-17 score=1000 time=304 hash=EF5F9157ACE0F8DF [1D -> 1C]
10:54:43 - 15/5/2024 U RAW: 0E01B7311E6F0E00AB
10:54:43 - 15/5/2024 U: RX, len=9 (type=3, route=D, payload_len=6) SNR=12 RSSI=-24 score=1000 time=242 hash=E56135797E5E17ED
10:54:43 - 15/5/2024 U: TX, len=8 (type=3, route=D, payload_len=6)
DEBUG: floor-sample: floor=-22 accepted=0/64 [29063 ms]
DEBUG: noise_floor = -92 (median) [mean=-98 min=-120 max=-91] [29076 ms]
10:54:46 - 15/5/2024 U RAW: 0A401C1D8733BC5DE52436078BC247C0E25B66320CD7
10:54:46 - 15/5/2024 U: RX, len=22 (type=2, route=D, payload_len=20) SNR=13 RSSI=0 score=1000 time=304 hash=143B9E7FD6936FAD [1D -> 1C]
DEBUG: floor-sample: floor=-92 accepted=0/64 [31064 ms]
DEBUG: noise_floor = -21 (median) [mean=-45 min=-120 max=-21] [31077 ms]
10:54:47 - 15/5/2024 U RAW: 0E01B7E8EF1A600083
10:54:47 - 15/5/2024 U: RX, len=9 (type=3, route=D, payload_len=6) SNR=12 RSSI=-22 score=1000 time=242 hash=ECBA20C7317FA361
10:54:47 - 15/5/2024 U: TX, len=8 (type=3, route=D, payload_len=6)
DEBUG: floor-sample: floor=-21 accepted=0/64 [33065 ms]
DEBUG: noise_floor = -94 (median) [mean=-100 min=-120 max=-92] [33078 ms]
10:54:49 - 15/5/2024 U RAW: 0A401C1D738E54D75C913FD11DD6BB449CF77630A871
10:54:49 - 15/5/2024 U: RX, len=22 (type=2, route=D, payload_len=20) SNR=12 RSSI=0 score=1000 time=304 hash=275292B1E4896AA4 [1D -> 1C]
10:54:49 - 15/5/2024 U RAW: 0E01B7900F99500089
10:54:49 - 15/5/2024 U: RX, len=9 (type=3, route=D, payload_len=6) SNR=12 RSSI=-23 score=1000 time=242 hash=2290DE9D91996D60
10:54:49 - 15/5/2024 U: TX, len=8 (type=3, route=D, payload_len=6)
DEBUG: floor-sample: floor=-94 accepted=0/64 [35066 ms]
DEBUG: noise_floor = -94 (median) [mean=-100 min=-120 max=-93] [35079 ms]
DEBUG: floor-sample: floor=-94 accepted=0/64 [37067 ms]
DEBUG: noise_floor = -94 (median) [mean=-100 min=-120 max=-93] [37080 ms]
DEBUG: floor-sample: floor=-94 accepted=0/64 [39068 ms]
DEBUG: noise_floor = -94 (median) [mean=-100 min=-120 max=-93] [39081 ms]
DEBUG: floor-sample: floor=-94 accepted=0/64 [41069 ms]
DEBUG: noise_floor = -90 (median) [mean=-97 min=-120 max=-89] [41082 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [43070 ms]
DEBUG: noise_floor = -90 (median) [mean=-97 min=-120 max=-89] [43083 ms]
DEBUG: floor-sample: floor=-90 accepted=0/64 [45071 ms]
DEBUG: noise_floor = -90 (median) [mean=-97 min=-120 max=-88] [45084 ms]

@usrflo

usrflo commented Jul 15, 2026

Copy link
Copy Markdown
Author

Overall picture: 4 test/debug patches (bug ↔ solution)

Above you can find 4 patches that demonstrate a) the problem b) the bugfix of this pull request.

Branch Case B (Drift) Case C (Trap)
dev (Bug) noise-floor-provocation-B.patch → floor slips → stuck noise-floor-provocation-C.patch → permanent stuck
fix/noise-floor-ratchet (solution) fix-noise-floor-ratchet-B.patch → median holds fix-noise-floor-ratchet-C.patch → recovery

usrflo added a commit to usrflo/MeshCore that referenced this pull request Jul 22, 2026
…tection to be replaced by _prefs.interference_threshold when the currentRSSI problem is solved, possibly via PR meshcore-dev#2933
@usrflo

usrflo commented Jul 22, 2026

Copy link
Copy Markdown
Author

This PR ist part of the ufo firmware, latest build at time of writing: v0.6

Magalex2x14 added a commit to Magalex2x14/MeshCore that referenced this pull request Aug 5, 2026
Magalex2x14 added a commit to Magalex2x14/MeshCore that referenced this pull request Aug 6, 2026
ACETyr added a commit to ACETyr/MeshCore that referenced this pull request Aug 8, 2026
The old estimator admitted a sample only if `rssi < _noise_floor + 14`, so each
64-sample block mean was computed from a lower-truncated set and could only move
down. At the -120 clamp the admission threshold becomes -106, ordinary ~-104 idle
samples stop qualifying, `_num_floor_samples` stalls, and the block never
completes again -- the node wedges and goes SILENT on noise_floor rather than
reporting a wrong value. `resetAGC()` was the only escape and is gated on
`agc_reset_interval`, which is 0 (off) on our hardware.

Adopts upstream meshcore-dev#2933: accept every idle sample, reduce the
block to its median (rejects transients in BOTH directions and recovers upward),
write `_noise_floor` only on block completion so the previous value stays valid
during reconvergence, and stop forcing `_noise_floor = 0` in `resetAGC()`.

Conflict note: upstream's hunk also carried the dev-era CC310/PacketMillis block,
which does not exist on this main-based branch. Only the
`NUM_NOISE_FLOOR_SAMPLES` define was taken.

Bench-verified on the RAK4631 rig (869.618/62.5/SF8/CR5, agc.reset.interval=0),
identical stimulus of 250 zero-hop adverts at 0.4s spacing:
  before: -120 at 18.9s, still -120 at 168s, only 6 samples (blocks stalled)
  after:  -104 final, min -106 / max -35, 56 samples, 15 up / 14 down
Wide cadence (4s) does NOT reproduce it -- the quiet gaps let the old estimator
recover. Tight cadence is what compounds the bias.

Closes #3
Magalex2x14 added a commit to Magalex2x14/MeshCore that referenced this pull request Aug 9, 2026
@ACETyr

ACETyr commented Aug 9, 2026

Copy link
Copy Markdown

Independent reproduction, on unmodified firmware - no provocation patch on the device under test. Complements the synthetic proofs above, which need a code change to trigger.

Setup. RAK4631 (SX1262), MeshCore 1.16.0 base (e8d3c53b), built only with MESH_DEBUG=1. 869.618 MHz / 62.5 kHz / SF8 / CR5. agc.reset.interval = 0 and int.thresh = 0, both stock defaults. Stimulus is adverts from a second MeshCore node over the air.

stimulus result
idle bench, 120 s and 299 s -103 +/-1, moves in both directions
90 adverts @ 4 s spacing, 379 s -103, excursions to -90/-91, recovers, no descent
250 adverts @ 0.4 s spacing -120 at 18.9 s, still -120 at 168 s - only 6 blocks completed in 168 s

With this PR applied, identical stimulus (250 @ 0.4 s; 85 packets received vs 88 on the unpatched run):

final -104, min -106 / max -35, 56 blocks in 199 s, 15 up / 14 down, and it recovers from a -35 transient - which the old floor + 14 filter would have rejected as too high.

Useful second signature for anyone else testing this: the block completion rate, not just the value. As the estimator wedges, the noise_floor debug line goes from ~2 s to 14 s to 135 s apart and then effectively stops. A wedged node goes quiet on noise_floor rather than printing a wrong one, so a monitor that only samples the value can read a wedge as "no data" instead of a fault.

Three caveats, so nobody over-reads this:

  1. The 4 s run used flood adverts and the 0.4 s run zero-hop adverts, so cadence is not cleanly isolated in that comparison, and the trigger threshold between 4 s and 0.4 s is unbracketed.
  2. 2.5 adverts/s is a stress level, not typical mesh traffic. This shows the mechanism fires on real RF with stock code; it does not show that it fires at realistic offered load.
  3. On a stock repeater or room server interference_threshold defaults to 0, so isChannelActive() short-circuits at RadioLibWrappers.cpp:195 and a wedged floor is telemetry-only - repeater/room-server stats and the companion UI read -120. It becomes TX-gating for anyone who sets int.thresh, which is what Rssi and noise floor improvements #2842 sets out to re-enable.

Happy to run the same harness against #2842 if that is useful for comparing the two approaches - the rig is a two-node bench with a scripted stimulus, so it is the same effort either way.

ACETyr added a commit to ACETyr/MeshCore that referenced this pull request Aug 9, 2026
The fork carries meshcore-dev#2933 (usrflo's median noise-floor
estimator) ahead of mainline merging it. Nothing in the repo said so, and the
commit that applied it (a028adc) is authored by this fork with the provenance
only in its message body -- easy to lose track of, and easy to mistake for
fork-original work.

Records for each carried patch: who wrote it upstream, which PR, why we are not
waiting, and what has to happen to drop it again. Also notes meshcore-dev#2797 as resolved,
since 1.17 landed it and the replant deduplicated the fork copy automatically.

Calls out that meshcore-dev#2842 is a competing fix for the same root cause, roughly thirty
times the size, and that if it is the one that lands this is not a clean revert
-- it rewrites the estimator our patch touches, so the bench stimulus has to be
re-run against its clamp defaults rather than assumed equivalent.

Explicitly: do not re-submit meshcore-dev#2933 upstream under fork authorship.
ACETyr added a commit to ACETyr/MeshCore that referenced this pull request Aug 9, 2026
a028adc is pushed with fork authorship and no Co-authored-by trailer, so the
credit cannot be fixed in history without rewriting published commits. Decision
2026-08-09: pay it in the release notes instead. Records that any release
carrying the patch must name usrflo and link meshcore-dev#2933, in both the German and
English text, so the requirement survives to whoever cuts fwdfilter8.
@usrflo

usrflo commented Aug 10, 2026

Copy link
Copy Markdown
Author

@ACETyr , thanks for the re-affirmation of this patch under package stress.

And yes, I'd highly appreciate if you could run the same test on #2842 to find out if its extra logic is an improvement or more logic than required.

@ACETyr

ACETyr commented Aug 10, 2026

Copy link
Copy Markdown

@usrflo Ran it. Same rig, same stimulus, three arms.

What was tested against what

Neither PR was merged or rebased onto anything for this. Each ran at its own head, and the control is
the base those heads sit on:

arm commit what it is ver on the device
control 61e21462 plain dev at #2842's branch point, unpatched v1.16.0 (Build: 6 Jun 2026)
#2933 16a5be4b PR head — your branch with dev already merged in up to 626a82fd v1.16.0 (Build: 6 Jun 2026)
#2842 bafa673a PR head — branch point plus the PR's commits v1.16.0 (Build: 6 Jun 2026)

The two PRs branch from dev at different points, but RadioLibWrappers.cpp/.h are byte-identical at
both branch points — the only differences between them are calcMaxPacketMillis and the CC310 RNG —
so a single control is valid for both.

I deliberately did not merge either PR forward onto current dev (f6c25e6a). #2842 conflicts there,
and resolving someone else's PR by hand would have put my judgement inside the measurement.

Setup

RAK4631 (SX1262), 869.618 MHz / 62.5 kHz / SF8 / CR5. Built with MESH_DEBUG=1 as the only added
flag. int.thresh = 0 and agc.reset.interval = 0, re-read from the device and confirmed after every
flash. Stimulus is a second MeshCore node roughly 1 m away sending 250 zero-hop adverts at 0.4 s
spacing — about 100 s of traffic, roughly 75 % channel occupancy at these radio settings — starting
15 s into a 280 s capture. Each arm also got a 280 s quiet capture first. last_rssi during the
stimulus was about −38 dBm. RX-activity lines seen: 117 / 110 / 107 across the three loaded runs, so
all three arms genuinely heard the stimulus.

Results

idle, 280 s quiet during load block cadence during load after load stops
control −105 ±1, 140 blocks, 2.0 s apart −120 from t+17 s onward 5 blocks in 110 s, gaps to 37.8 s back to −105 after ~30 s
#2933 −105 ±1, 140 blocks, 2.0 s apart −56 … −59 30 blocks, gaps ≤ 6.8 s, never stalls back to −105 after ~24 s
#2842 −106/−105, 68 blocks, 4.1 s apart −105/−106, unchanged publishes nothing for 126.5 s resumes after ~26 s

Both PRs fix the ratchet. Neither reached the −120 clamp, neither needed a reboot or resetAGC().
That is the headline and it holds for both.

Where they differ is what gets published while the channel is busy.

#2933 keeps publishing, and what it publishes tracks the channel rather than the idle floor. With the
floor + 14 filter gone, every non-isReceivingPacket() sample is accepted; at ~75 % occupancy most
of a block lands in busy air, so the median is the busy level. It sat at −56…−59 for the whole 100 s
of load — 31 consecutive blocks — and snapped back to −105 within 24 s of the channel going quiet.
Nothing is stuck. But for those two minutes noise_floor did not mean idle noise floor.

#2842 refuses to publish an activity-contaminated batch and keeps the previous value. Its
stats-noise makes the stall legible instead of silent — polled every 20 s through the load:

{"floor":-105,"accepted":47,"min":-106,"median":-105,"max":-104,"rejected_low":9, "rejected_high":31}
{"floor":-105,"accepted":47, ...                                 "rejected_low":19,"rejected_high":61}
{"floor":-105,"accepted":47, ...                                 "rejected_low":22,"rejected_high":70}
{"floor":-105,"accepted":0,"min":0,"median":0,"max":0,"rejected_low":0,"rejected_high":0}  <- 60 s window expired

The batch freezes at 47/64 accepted, the reject counter climbs, the window expires, the batch restarts.
The published floor never moves off −105 — confirmed independently via stats-radio polls, not just
the debug line.

On "improvement or more logic than required"

For the ratchet alone, #2933 is sufficient and is a fraction of the size. The extra logic in #2842 buys
one thing #2933 does not: it keeps the published value meaning idle floor while the channel is
occupied. Whether that is worth it depends on what noise_floor is for.

Of #2842's machinery, the parts that visibly did work on this bench were the high-jump reject gate and
the 50 ms sample rate limit — the latter alone spreads a 64-sample batch over ≥3.2 s instead of one
busy window, which is also why its idle cadence is 4.1 s rather than 2.0 s. The 60 s calibration window
did fire, and is what stops a stalled batch mixing old and new conditions. The four config knobs were
not needed at defaults.

Caveats, so nobody over-reads this

  1. The two nodes are ~1 m apart (last_rssi ≈ −38 dBm). The magnitude of Fix/noise floor ratchet, adapt noise to the real floor with recovery in both directions #2933's −56 is a bench
    artefact; the direction is not. At a real site the excursion would be smaller.
  2. 2.5 adverts/s is a stress level, not typical mesh traffic. This compares mechanisms, not realistic
    offered load.
  3. The trigger threshold between 4 s and 0.4 s cadence is still unbracketed, as in my earlier comment.
  4. Both PR heads are behind current dev. In particular none of the three arms carries Fix RX Boosted Gain setting after AGC reset is triggered #3158 (RX boosted
    gain after AGC reset, merged 2026-08-10). With agc.reset.interval = 0 here, resetAGC() never
    fires, so it should not touch these results — but that is reasoning, not a measurement.
  5. Correction to my comment of 2026-08-09. I reported the unpatched build as "−120 at 18.9 s, still
    −120 at 168 s". On this run it did clear −120, about 30 s after the stimulus stopped. On this bench,
    with agc.reset.interval = 0, the pin is sustained-load-dependent rather than permanent — it needs
    a quiet channel to escape, which your synthetic case C shows it cannot always get. That same comment
    also stated int.thresh = 0; I can no longer confirm that for that particular run — this bench's
    pref read 3 when I picked it up today, left by an unrelated migration test in June. Every arm in
    this comparison was verified at 0 before each run.

@usrflo
usrflo marked this pull request as draft August 10, 2026 21:25
@usrflo

usrflo commented Aug 10, 2026

Copy link
Copy Markdown
Author

@ACETyr, thanks for the analysis/the comparison tests; this is very helpful. I see under load #2933's median tracked the busy channel (-56, permissive LBT) because the 64 samples were taken in a few ms instead of a longer time period.

I was thinking about switching over to #2842 but decided to stay with the minimum approach of this PR.
Regarding #2842 I added 2 changes:

  • a 50 ms sample spacing (block now spans ~3.2 s, so the median rejects transients)
  • a 15 dB one-sided hold (freezes the floor under heavy load)

Would you mind to repeat the comparison test with these 2 changes on occasion?

# Conflicts:
#	src/helpers/radiolib/RadioLibWrappers.cpp
@ACETyr

ACETyr commented Aug 11, 2026

Copy link
Copy Markdown

@usrflo Re-ran it on 0aeece67. Both changes do what you intended: the −56 excursion is gone.

Arms

arm commit note
control 61e21462 plain dev at #2842's branch point, unpatched — re-measured today, not reused
#2933 0aeece67 PR head with the 50 ms spacing and the 15 dB hold

I re-ran the control rather than quoting yesterday's, and that turned out to matter — see the last
section. #2842's numbers below are yesterday's; that build was not re-flashed today.

Same rig as before: RAK4631 (SX1262), 869.618 MHz / 62.5 kHz / SF8 / CR5, MESH_DEBUG=1 as the only
added flag, int.thresh = 0 and agc.reset.interval = 0 re-read from the device after each flash.
Stimulus: a second node ~1 m away, 250 zero-hop adverts at 0.4 s spacing (~100 s, ~75 % occupancy),
starting 15 s into a 280 s capture, preceded by a 280 s quiet capture. Both arms logged ~110 received
adverts (109 / 110) at last_rssi −38…−33 dBm, so both genuinely heard the stimulus.

Results

idle, 280 s quiet during load block cadence during load after load stops
control (today) −104/−103, 140 blocks, 2.0 s apart −120 from t+31.8 s 11 blocks in 156 s, then none still −120 (see below)
#2933 @ 16a5be4b (yesterday) −105 ±1, 140 blocks, 2.0 s −56 … −59 30 blocks, gaps ≤ 6.8 s −105 after ~24 s
#2842 @ bafa673a (yesterday) −106/−105, 68 blocks, 4.1 s −105/−106, unchanged nothing for 126.5 s resumes after ~26 s
#2933 @ 0aeece67 −104 ±1, 70 blocks, 4.0 s apart (min 3.2 s) −104, unchanged for the whole run 2 blocks in 123 s, both HELD publishes again 6 s after the stimulus stops, back to 4.0 s

The idle cadence moved from 2.0 s to 4.0 s with a hard minimum of 3.2 s — that is 64 × 50 ms exactly,
so the rate limit is doing what it says. It now sits where #2842's did (4.1 s).

Under load the published floor never moved off −104. Two blocks completed during the ~110 s of load,
both rejected by the new gate:

    62.38  DEBUG: RadioLibWrapper: noise_floor held at -104 (block median -37 contaminated)
   116.51  DEBUG: RadioLibWrapper: noise_floor held at -104 (block median -37 contaminated)

stats-radio confirmed −104 independently at every 20 s poll through the load. So on this bench
0aeece67 now behaves like #2842 did: the floor keeps meaning idle floor while the channel is busy,
and it is retained rather than wedged.

What it costs

Block completion nearly stalls under load. Before the change: 30 blocks published during the load
window. Now: 2 blocks completed in 123 s, and both were held, so nothing at all was published between
t+7.8 s and t+130.9 s. That is the same trade #2842 makes and I think it is the right one — but it
means the estimator is effectively frozen, not merely conservative, while the channel is occupied.

One practical consequence: on a production build (MESH_DEBUG off) there is no way to tell a held
floor from a stale one. Both look like silence. #2842 exposes stats-noise for exactly this. A
single held-block counter in stats-radio would make the difference legible without the rest of that
machinery.

One thing I did not measure — the hold has no release

This is from reading the code, not from the bench, and it applies to both PRs, so it is not a point
against yours:

if (median > _noise_floor + NOISE_FLOOR_MAX_RISE_DB) { hold } has no timeout, no counter, no window.
If the real ambient floor takes a step of more than 15 dB and stays there — a new permanent
interferer, a switching PSU, a neighbouring site coming online — every subsequent block is held and the
published floor never rises again. A node that booted quiet at −104 and later sits in a −60 dBm
environment keeps reporting −104 indefinitely, i.e. an LBT reference 44 dB too low: over-sensitive,
which is the same end state the ratchet produced, reached from the other direction.

Two things that are not a problem, for completeness:

  • Gradual rises are fine. The floor may climb 15 dB per block, so a slowly rising ambient tracks.
  • Boot-during-interference is fine. begin() sets _noise_floor = 0, and any real median is negative,
    so the first block always publishes; the floor can then fall freely.

#2842 has the same structural gap and its version is symmetric (±14 dB, so a large downward step is
rejected too). Worth deciding deliberately for whichever lands: something like "after N consecutive
held blocks, accept the median" (or step the floor up by MAX_RISE_DB) would bound the exposure.

Also minor: resetAGC() deliberately no longer clears _noise_floor, so with agc.reset.interval > 0
an AGC reset does not release a hold either.

Correction to my caveat 5 of 2026-08-10

Yesterday I reported that the unpatched control cleared the −120 pin about 30 s after the stimulus
stopped, and concluded the pin is sustained-load-dependent rather than permanent. Today's control did
not clear: −120 from t+31.8 s, still −120 at the end of the 280 s capture (155 s after the stimulus
stopped), and still −120 on a stats-radio poll ~3.5 minutes later.

The difference is the channel, not the build. Today's "quiet" capture logged 18 RX-activity lines and a
floor jittering −111…−91, against ±1 dB yesterday; rx_air_secs was 132 by the end of the load
capture. So "it needs a genuinely quiet channel to escape" holds — today's bench simply never gave it
one, and that is the more realistic case. This is also why I re-measured the control instead of quoting
yesterday's.

Caveats

  1. Nodes ~1 m apart (last_rssi −38…−33 dBm). The −37 block median is a bench artefact in magnitude;
    the direction is not.
  2. 2.5 adverts/s is a stress level, not typical offered load.
  3. Rssi and noise floor improvements #2842 was not re-flashed today; its row is yesterday's data, under yesterday's quieter ambient.
  4. The "no release" finding is code reading. The longest I actually held the channel busy is ~110 s.
  5. Both heads are behind current dev; neither carries Fix RX Boosted Gain setting after AGC reset is triggered #3158. With agc.reset.interval = 0, resetAGC()
    never fires, so it should not matter here — reasoning, not measurement.

@usrflo

usrflo commented Aug 12, 2026

Copy link
Copy Markdown
Author

@ACETyr, thanks again for this analysis. There are at least two points I need to focus next: the "hold vs. stalled" aspect and the stucked noise floor in the other direction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants