Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
361 changes: 361 additions & 0 deletions src/helpers/NoiseFloorTracker.h

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src/helpers/radiolib/CustomLLCC68Wrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,13 @@ class CustomLLCC68Wrapper : public RadioLibWrapper {

void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); }

// Changing the LNA gain state moves the noise floor, and this path does not
// go through setParams(). See RadioLibWrapper::resetNoiseFloor(); reset only
// on a successful change, since a rejected one leaves the frontend alone.
bool setRxBoostedGainMode(bool en) override {
return ((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE;
if (((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en) != RADIOLIB_ERR_NONE) return false;
resetNoiseFloor();
return true;
}
bool getRxBoostedGainMode() const override {
return ((CustomLLCC68 *)_radio)->getRxBoostedGainMode();
Expand Down
7 changes: 6 additions & 1 deletion src/helpers/radiolib/CustomLR1110Wrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ class CustomLR1110Wrapper : public RadioLibWrapper {

uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); }

// Changing the LNA gain state moves the noise floor, and this path does not
// go through setParams(). See RadioLibWrapper::resetNoiseFloor(); reset only
// on a successful change, since a rejected one leaves the frontend alone.
bool setRxBoostedGainMode(bool en) override {
return ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE;
if (((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) != RADIOLIB_ERR_NONE) return false;
resetNoiseFloor();
return true;
}
bool getRxBoostedGainMode() const override {
return ((CustomLR1110 *)_radio)->getRxBoostedGainMode();
Expand Down
7 changes: 6 additions & 1 deletion src/helpers/radiolib/CustomSX1262Wrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ class CustomSX1262Wrapper : public RadioLibWrapper {
((CustomSX1262 *)_radio)->sleep(false);
}

// Changing the LNA gain state moves the noise floor, and this path does not
// go through setParams(). See RadioLibWrapper::resetNoiseFloor(); reset only
// on a successful change, since a rejected one leaves the frontend alone.
bool setRxBoostedGainMode(bool en) override {
return ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE;
if (((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) != RADIOLIB_ERR_NONE) return false;
resetNoiseFloor();
return true;
}
bool getRxBoostedGainMode() const override {
return ((CustomSX1262 *)_radio)->getRxBoostedGainMode();
Expand Down
7 changes: 6 additions & 1 deletion src/helpers/radiolib/CustomSX1268Wrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,13 @@ class CustomSX1268Wrapper : public RadioLibWrapper {
}
uint8_t getSpreadingFactor() const override { return ((CustomSX1268 *)_radio)->spreadingFactor; }

// Changing the LNA gain state moves the noise floor, and this path does not
// go through setParams(). See RadioLibWrapper::resetNoiseFloor(); reset only
// on a successful change, since a rejected one leaves the frontend alone.
bool setRxBoostedGainMode(bool en) override {
return ((CustomSX1268 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE;
if (((CustomSX1268 *)_radio)->setRxBoostedGainMode(en) != RADIOLIB_ERR_NONE) return false;
resetNoiseFloor();
return true;
}
bool getRxBoostedGainMode() const override {
return ((CustomSX1268 *)_radio)->getRxBoostedGainMode();
Expand Down
153 changes: 117 additions & 36 deletions src/helpers/radiolib/RadioLibWrappers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,36 @@
#define STATE_TX_DONE 4
#define STATE_INT_READY 16

#define NUM_NOISE_FLOOR_SAMPLES 64
#define SAMPLING_THRESHOLD 14
// How often the noise floor is sampled, in wall-clock terms. Sampling is rate
// limited here rather than taken once per loop() call so the estimate does not
// depend on how fast the host happens to iterate: an idle Linux daemon blocking
// on poll() and an MCU spinning flat out must characterise the channel the same
// way. It also keeps consecutive samples far enough apart to be worth taking --
// back-to-back GET_RSSI_INST reads are correlated, so they add little
// information for their SPI cost.
#ifndef NOISE_SAMPLE_INTERVAL_MS
#define NOISE_SAMPLE_INTERVAL_MS 100
#endif

// Minimum busy-channel margin, as a multiple of the estimated noise sigma. This
// is what sets the false-alarm rate of the interference check: for normally
// distributed noise, P(sample > mean + 3.5 sigma) = 2.3e-4 per isChannelActive()
// call, so a transmit attempt (a handful of calls) defers spuriously about once
// in a thousand -- comfortably absorbed by the CSMA backoff that follows.
//
// Applied as a floor under the operator's configured dB margin, not as a
// replacement for it: interference_threshold keeps meaning dB, but a margin
// narrower than the noise itself can no longer produce continuous false busy
// and trip ERR_EVENT_CAD_TIMEOUT.
#ifndef NOISE_THRESHOLD_SIGMA_K
#define NOISE_THRESHOLD_SIGMA_K 3.5f
#endif

// One debug line per this many samples (20 * 100 ms = 2 s), matching the log
// volume of the batch estimator this replaced.
#ifndef NOISE_LOG_EVERY_N_SAMPLES
#define NOISE_LOG_EVERY_N_SAMPLES 20
#endif

static volatile uint8_t state = STATE_IDLE;

Expand All @@ -34,13 +62,11 @@ void RadioLibWrapper::begin() {
setFlag(); // LoRa packet is already received
}

_noise_floor = 0;
_threshold = 0;
_cad_enabled = false;

// start average out some samples
_num_floor_samples = 0;
_floor_sample_sum = 0;
resetNoiseFloor(); // clears _nf, _noise_floor and the log rate limiter
_next_noise_sample = millis();
}

uint32_t RadioLibWrapper::getRngSeed() {
Expand All @@ -60,11 +86,11 @@ void RadioLibWrapper::idle() {
}

void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) {
// The estimator now runs continuously, so there is no calibration batch to
// start and nothing here is periodic any more -- this only conveys the
// operator's interference threshold. Kept on the mesh::Radio interface, and
// still called on Dispatcher's 2 s timer, so that no caller has to change.
_threshold = threshold;
if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling
_num_floor_samples = 0;
_floor_sample_sum = 0;
}
}

void RadioLibWrapper::doResetAGC() {
Expand All @@ -78,35 +104,75 @@ void RadioLibWrapper::resetAGC() {
doResetAGC();
state = STATE_IDLE; // trigger a startReceive()

// Reset noise floor sampling so it reconverges from scratch.
// Without this, a stuck _noise_floor of -120 makes the sampling threshold
// too low (-106) to accept normal samples (~-105), self-reinforcing the
// stuck value even after the receiver has recovered.
_noise_floor = 0;
_num_floor_samples = 0;
_floor_sample_sum = 0;
// Deliberately does NOT reset the noise floor estimate.
//
// It used to, on the reasoning that the analog frontend had just changed so
// everything learned before it was about a different receiver. Measured on a
// live repeater that was strictly harmful: reset() re-seeds from a single
// sample, and the guard meant to keep that sample clean cannot do its job
// here. resetAGC() has just been through sleep() -> startReceive(), which
// clears the modem's IRQ flags, so a packet already in the air is joined
// mid-symbol -- its preamble and header are long past and neither will ever
// set again for that packet. isReceivingPacket() therefore reports "idle"
// precisely when it is most wrong, and stays wrong for the rest of the
// packet. 20 of 257 re-seeds over 20 h landed on signal that way, throwing
// the reported floor to -85..-95 dBm against a true floor of -114.
//
// Nothing is lost by keeping the estimate: an AGC reset does not move the
// noise floor by anything like the estimator's tracking range, and if it
// genuinely did, the estimator follows real floor changes on its own.
}

void RadioLibWrapper::loop() {
if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) {
if (!isReceivingPacket()) {
int rssi = getCurrentRSSI();
if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD
_num_floor_samples++;
_floor_sample_sum += rssi;
}
}
} else if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES && _floor_sample_sum != 0) {
_noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES;
if (_noise_floor < -120) {
_noise_floor = -120; // clamp to lower bound of -120dBi
}
_floor_sample_sum = 0;
// Only sample while actually listening: during TX or standby the RSSI reading
// describes nothing about the channel.
if (state != STATE_RX) return;

uint32_t now = millis();
if ((int32_t)(now - _next_noise_sample) < 0) return; // not due yet

_next_noise_sample += NOISE_SAMPLE_INTERVAL_MS;
if ((int32_t)(now - _next_noise_sample) >= 0) {
// More than one interval elapsed -- we were transmitting, or the caller
// stopped iterating for a while. Resync rather than catching up, otherwise
// the next few iterations would each fire immediately and feed a burst of
// correlated samples, which is exactly what the fixed rate exists to avoid.
_next_noise_sample = now + NOISE_SAMPLE_INTERVAL_MS;
}

#ifdef MESH_DEBUG_NOISE_FLOOR
MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor);
#endif
// Sampled unconditionally, including mid-packet. NoiseFloorTracker estimates
// the floor as a window minimum, and signal only ever adds power, so readings
// taken during reception are discarded by construction rather than needing to
// be gated out. Two things go away with the gate:
//
// - the selection bias it imposed, by restricting the sample population to
// moments the modem considered quiet;
// - a dependency on isReceivingPacket(), which is not a pure read. It drives
// a timeout state machine and calls clearIrqFlags(), so polling it at
// 10 Hz from the noise sampler would have this path participating in
// packet detection. Sampling the noise floor must not perturb reception.
//
// The gate was also a liability in its own right: before the IRQ-timeout fix
// in CustomSX1262::isReceiving(), a latched PREAMBLE_DETECTED that never
// completed into a packet held it true until the next startReceive(),
// silently suspending noise sampling for seconds at a time.
_nf.addSample(getCurrentRSSI());
_noise_floor = _nf.floorDbm();

// Off unless asked for: upstream made this line opt-in behind
// MESH_DEBUG_NOISE_FLOOR because it drowns the rest of the debug output.
// When it is on, rate limit it rather than printing on every change -- the
// estimate now updates continuously and jitters by ~1 dB, so "print when it
// changes" would emit several lines a second. One line every
// NOISE_LOG_EVERY_N_SAMPLES keeps it at roughly the volume the 2 s batch
// estimator produced.
#ifdef MESH_DEBUG_NOISE_FLOOR
if (++_noise_log_ctr >= NOISE_LOG_EVERY_N_SAMPLES) {
_noise_log_ctr = 0;
MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d (sigma %d/10)",
(int)_noise_floor, (int)(_nf.sigma() * 10.0f));
}
#endif
}

void RadioLibWrapper::startRecv() {
Expand Down Expand Up @@ -196,8 +262,23 @@ int16_t RadioLibWrapper::performChannelScan() {
}

bool RadioLibWrapper::isChannelActive() {
// int.thresh: RSSI-based interference detection (relative to noise floor)
if (_threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true;
// int.thresh: RSSI-based interference detection (relative to noise floor).
// Skipped when the check is disabled (_threshold == 0), and while the
// estimator has no floor yet -- neither is a reason to skip the CAD check
// below, so these are a guard rather than an early return.
if (_threshold != 0 && _nf.ready()) {
// The operator's configured dB margin still means dB, so existing
// interference_threshold settings behave as before. What is new is the floor
// under it: a margin narrower than NOISE_THRESHOLD_SIGMA_K sigma would fire on
// noise alone, and a channel that reads busy continuously does not protect
// anything -- it just delays every packet until getCADFailMaxDuration()
// expires and the node transmits regardless.
float margin = (float) _threshold;
float min_margin = NOISE_THRESHOLD_SIGMA_K * _nf.sigma();
if (margin < min_margin) margin = min_margin;

if (getCurrentRSSI() > (float)_noise_floor + margin) return true;
}

// cad: hardware channel activity detection
if (_cad_enabled) {
Expand Down
49 changes: 45 additions & 4 deletions src/helpers/radiolib/RadioLibWrappers.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include <Mesh.h>
#include <RadioLib.h>
#include <helpers/NoiseFloorTracker.h>

#ifdef USE_CC310_HW_CRYPTO
#include <Adafruit_nRFCrypto.h>
Expand All @@ -18,8 +19,11 @@ class RadioLibWrapper : public mesh::Radio {
uint32_t n_recv, n_sent, n_recv_errors;
int16_t _noise_floor, _threshold;
bool _cad_enabled;
uint16_t _num_floor_samples;
int32_t _floor_sample_sum;
// _num_floor_samples/_floor_sample_sum (the upstream batch estimator) are gone:
// NoiseFloorTracker replaces them with a continuous, fixed-rate estimate.
NoiseFloorTracker _nf;
uint32_t _next_noise_sample; // millis() deadline for the next RSSI read
uint8_t _noise_log_ctr; // rate limiter for the noise-floor debug line
uint8_t _preamble_sf;

void idle();
Expand All @@ -29,7 +33,9 @@ class RadioLibWrapper : public mesh::Radio {
virtual void doResetAGC();

public:
RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; }
RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board)
: _radio(&radio), _board(&board), _next_noise_sample(0), _noise_log_ctr(0), _preamble_sf(0)
{ n_recv = n_sent = 0; }

void begin() override;
virtual void powerOff() { _radio->sleep(); }
Expand All @@ -54,7 +60,42 @@ class RadioLibWrapper : public mesh::Radio {
virtual float getCurrentRSSI() =0;
virtual uint8_t getSpreadingFactor() const { return LORA_SF; }
static uint16_t preambleLengthForSF(uint8_t sf) { return sf <= 8 ? 32 : 16; }
void updatePreamble(uint8_t sf) { _preamble_sf = sf; _radio->setPreambleLength(preambleLengthForSF(sf)); }
// Discard the noise-floor estimate because the receiver it characterises has
// changed. Bandwidth is the big one -- the thermal floor moves ~6 dB going
// from 62.5 to 250 kHz -- but frequency and LNA gain move it too.
//
// Without this the estimate can only *rise* as contaminated sub-windows age
// out of the ring, so a floor that has genuinely jumped up takes the full
// NOISE_TRACKER_SUB_WINDOWS * NOISE_TRACKER_SUB_SAMPLES window (180 s at the
// default sampling rate) to be reported. That asymmetry is right for a floor
// that drifts and wrong for one the operator has just moved: with
// interference_threshold set, isChannelActive() would read busy against a
// stale floor and defer every transmit to getCADFailMaxDuration() for three
// minutes. The batch estimator this replaced re-converged in ~2 s, so the
// reset is what keeps a runtime `set bw` as cheap as it used to be.
//
// _noise_floor goes with it: getNoiseFloor() reports the cached value, and
// leaving it behind would keep serving the old floor to isChannelActive() and
// to telemetry until the first sub-window of the new configuration closes.
//
// The cost, which is the whole reason this is a judgement call rather than an
// obvious win: for the ~3 s until the first sub-window closes, _nf.ready() is
// false, so isChannelActive() skips the interference-threshold branch
// entirely and getNoiseFloor() reports 0. The node transmits over the top of
// anything that check would have caught, and telemetry shows an uncalibrated
// floor. That is accepted deliberately -- 3 s of no RSSI check beats 180 s of
// a wrong one, and the CAD check is unaffected throughout -- but a caller
// adding a new reset site should know it is spending that, not nothing.
void resetNoiseFloor() { _nf.reset(); _noise_floor = 0; _noise_log_ctr = 0; }

// Called by every setParams() override, which is why the reset above lives
// here: it is the one hook every radio family already routes a parameter
// change through.
void updatePreamble(uint8_t sf) {
_preamble_sf = sf;
_radio->setPreambleLength(preambleLengthForSF(sf));
resetNoiseFloor();
}
PacketMillis calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols);
virtual int16_t performChannelScan();

Expand Down
Loading
Loading