From d623f6f5f94e36d2af43d3b277729862612470a5 Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Thu, 25 Jun 2026 14:19:57 +0100 Subject: [PATCH 01/13] Stabilise RSSI carrier sense # Conflicts: # examples/simple_repeater/MyMesh.cpp # examples/simple_room_server/MyMesh.cpp # examples/simple_sensor/SensorMesh.cpp # src/Dispatcher.h --- docs/cli_commands.md | 7 ++- src/helpers/radiolib/RadioLibWrappers.cpp | 32 ++++++++++-- src/helpers/radiolib/RadioLibWrappers.h | 2 + src/helpers/radiolib/RssiCarrierSense.h | 41 +++++++++++++++ .../test_classifier.cpp | 52 +++++++++++++++++++ 5 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 src/helpers/radiolib/RssiCarrierSense.h create mode 100644 test/test_rssi_carrier_sense/test_classifier.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 66a9b77afe..18ec42bc83 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -571,9 +571,12 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `set int.thresh ` **Parameters:** -- `value`: Interference threshold value +- `value`: Interference threshold value in dB above the calibrated noise floor. + `0` disables RSSI carrier-sense. When enabled, transmit deferral uses a short + majority of instantaneous RSSI samples so a single spike does not mark the + channel busy. -**Default:** `0.0` +**Default:** `10` --- diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index b6519aefa7..c0d37cd78d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -10,6 +10,8 @@ #define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 +#define RSSI_CARRIER_SENSE_SAMPLES 5 +#define RSSI_CARRIER_SENSE_REQUIRED 3 static volatile uint8_t state = STATE_IDLE; @@ -116,6 +118,10 @@ bool RadioLibWrapper::isInRecvMode() const { return (state & ~STATE_INT_READY) == STATE_RX; } +bool RadioLibWrapper::hasNoiseFloor() const { + return _noise_floor != 0 && _num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES; +} + int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { int len = 0; if (state & STATE_INT_READY) { @@ -179,9 +185,29 @@ void RadioLibWrapper::onSendFinished() { } bool RadioLibWrapper::isChannelActive() { - return _threshold == 0 - ? false // interference check is disabled - : getCurrentRSSI() > _noise_floor + _threshold; + if (_threshold == 0) { + return false; // interference check is disabled + } + if (!hasNoiseFloor()) { + return false; + } + + int16_t samples[RSSI_CARRIER_SENSE_SAMPLES]; + for (uint8_t i = 0; i < RSSI_CARRIER_SENSE_SAMPLES; i++) { + samples[i] = (int16_t)getCurrentRSSI(); + } + + mesh::RssiCarrierSenseConfig config = { + _noise_floor, + _threshold, + true, + RSSI_CARRIER_SENSE_REQUIRED + }; + + // Instantaneous RSSI can spike briefly, so carrier sense requires a short + // majority of busy samples. This is only a local RSSI guard; it cannot detect + // hidden-node collisions at another receiver. + return mesh::RssiCarrierSense::isActive(config, samples, RSSI_CARRIER_SENSE_SAMPLES); } float RadioLibWrapper::getLastRSSI() const { diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index efd3e17931..25d4353993 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -2,6 +2,7 @@ #include #include +#include "RssiCarrierSense.h" class RadioLibWrapper : public mesh::Radio { protected: @@ -15,6 +16,7 @@ class RadioLibWrapper : public mesh::Radio { void idle(); void startRecv(); + bool hasNoiseFloor() const; float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); diff --git a/src/helpers/radiolib/RssiCarrierSense.h b/src/helpers/radiolib/RssiCarrierSense.h new file mode 100644 index 0000000000..d08577e55e --- /dev/null +++ b/src/helpers/radiolib/RssiCarrierSense.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace mesh { + +struct RssiCarrierSenseConfig { + int16_t noise_floor; + int16_t threshold; + bool noise_floor_valid; + uint8_t required_busy_samples; +}; + +class RssiCarrierSense { +public: + static bool isActive(const RssiCarrierSenseConfig& config, const int16_t samples[], uint8_t sample_count) { + if (config.threshold <= 0 || !config.noise_floor_valid || samples == nullptr || sample_count == 0) { + return false; + } + + uint8_t required = config.required_busy_samples; + if (required == 0 || required > sample_count) { + required = sample_count; + } + + uint8_t busy_samples = 0; + const int16_t busy_level = config.noise_floor + config.threshold; + for (uint8_t i = 0; i < sample_count; i++) { + if (samples[i] > busy_level) { + busy_samples++; + if (busy_samples >= required) { + return true; + } + } + } + + return false; + } +}; + +} diff --git a/test/test_rssi_carrier_sense/test_classifier.cpp b/test/test_rssi_carrier_sense/test_classifier.cpp new file mode 100644 index 0000000000..fbf74cae23 --- /dev/null +++ b/test/test_rssi_carrier_sense/test_classifier.cpp @@ -0,0 +1,52 @@ +#include + +#include "helpers/radiolib/RssiCarrierSense.h" + +using namespace mesh; + +TEST(RssiCarrierSense, DisabledThresholdIsInactive) { + int16_t samples[] = {-80, -75, -70}; + RssiCarrierSenseConfig config = {-110, 0, true, 2}; + + EXPECT_FALSE(RssiCarrierSense::isActive(config, samples, 3)); +} + +TEST(RssiCarrierSense, UnconvergedNoiseFloorIsInactive) { + int16_t samples[] = {-80, -75, -70}; + RssiCarrierSenseConfig config = {-110, 10, false, 2}; + + EXPECT_FALSE(RssiCarrierSense::isActive(config, samples, 3)); +} + +TEST(RssiCarrierSense, BelowThresholdSamplesAreInactive) { + int16_t samples[] = {-101, -100, -99, -101, -100}; + RssiCarrierSenseConfig config = {-110, 10, true, 3}; + + EXPECT_FALSE(RssiCarrierSense::isActive(config, samples, 5)); +} + +TEST(RssiCarrierSense, IsolatedSpikeIsInactive) { + int16_t samples[] = {-101, -70, -101, -100, -101}; + RssiCarrierSenseConfig config = {-110, 10, true, 3}; + + EXPECT_FALSE(RssiCarrierSense::isActive(config, samples, 5)); +} + +TEST(RssiCarrierSense, MajorityAboveThresholdIsActive) { + int16_t samples[] = {-99, -98, -101, -97, -102}; + RssiCarrierSenseConfig config = {-110, 10, true, 3}; + + EXPECT_TRUE(RssiCarrierSense::isActive(config, samples, 5)); +} + +TEST(RssiCarrierSense, ExactThresholdBoundaryIsInactive) { + int16_t samples[] = {-100, -100, -100, -101, -102}; + RssiCarrierSenseConfig config = {-110, 10, true, 3}; + + EXPECT_FALSE(RssiCarrierSense::isActive(config, samples, 5)); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 867295f9cd842f087bbdaa22251972aefba83ee9 Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Wed, 24 Jun 2026 18:20:03 +0100 Subject: [PATCH 02/13] Cache packet RSSI metrics after receive --- src/helpers/radiolib/CustomLLCC68Wrapper.h | 2 - src/helpers/radiolib/CustomLR1110Wrapper.h | 3 - src/helpers/radiolib/CustomSTM32WLxWrapper.h | 2 - src/helpers/radiolib/CustomSX1262Wrapper.h | 2 - src/helpers/radiolib/CustomSX1268Wrapper.h | 2 - src/helpers/radiolib/CustomSX1276Wrapper.h | 2 - src/helpers/radiolib/RadioLibWrappers.cpp | 19 ++--- src/helpers/radiolib/RadioLibWrappers.h | 14 +++- test/mocks/RadioLib.h | 49 +++++++++++ .../test_packet_metrics.cpp | 84 +++++++++++++++++++ 10 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 test/mocks/RadioLib.h create mode 100644 test/test_rssi_packet_metrics/test_packet_metrics.cpp diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index 8861f76d24..975eb6650f 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -22,8 +22,6 @@ class CustomLLCC68Wrapper : public RadioLibWrapper { float getCurrentRSSI() override { return ((CustomLLCC68 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomLLCC68 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomLLCC68 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomLLCC68 *)_radio)->spreadingFactor; diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 13efd25b57..381f3f8557 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -31,9 +31,6 @@ class CustomLR1110Wrapper : public RadioLibWrapper { _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts } - float getLastRSSI() const override { return ((CustomLR1110 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); } - uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } void setRxBoostedGainMode(bool en) override { diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index 97bf6820d6..1acaecd8f0 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -23,8 +23,6 @@ class CustomSTM32WLxWrapper : public RadioLibWrapper { float getCurrentRSSI() override { return ((CustomSTM32WLx *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSTM32WLx *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSTM32WLx *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSTM32WLx *)_radio)->spreadingFactor; diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index cc7bb2238b..9b4ab76ac6 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -26,8 +26,6 @@ class CustomSX1262Wrapper : public RadioLibWrapper { float getCurrentRSSI() override { return ((CustomSX1262 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSX1262 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSX1262 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSX1262 *)_radio)->spreadingFactor; diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 9ddea78f3f..0ff6927415 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -26,8 +26,6 @@ class CustomSX1268Wrapper : public RadioLibWrapper { float getCurrentRSSI() override { return ((CustomSX1268 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSX1268 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSX1268 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSX1268 *)_radio)->spreadingFactor; diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index 9d75ce12a1..c26f71e8d4 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -25,8 +25,6 @@ class CustomSX1276Wrapper : public RadioLibWrapper { float getCurrentRSSI() override { return ((CustomSX1276 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSX1276 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSX1276 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSX1276 *)_radio)->spreadingFactor; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index c0d37cd78d..d3f55e04ca 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -2,6 +2,8 @@ #define RADIOLIB_STATIC_ONLY 1 #include "RadioLibWrappers.h" +#include + #define STATE_IDLE 0 #define STATE_RX 1 #define STATE_TX_WAIT 3 @@ -134,6 +136,10 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { len = 0; n_recv_errors++; } else { + // Capture packet signal metrics while the completed packet status is + // still the current radio context. Later instantaneous RSSI sampling + // for carrier sense/noise floor must not change "last packet" stats. + updateLastPacketMetrics(_radio->getRSSI(), _radio->getSNR()); // Serial.print(" readData() -> "); Serial.println(len); n_recv++; } @@ -210,13 +216,6 @@ bool RadioLibWrapper::isChannelActive() { return mesh::RssiCarrierSense::isActive(config, samples, RSSI_CARRIER_SENSE_SAMPLES); } -float RadioLibWrapper::getLastRSSI() const { - return _radio->getRSSI(); -} -float RadioLibWrapper::getLastSNR() const { - return _radio->getSNR(); -} - // Approximate SNR threshold per SF for successful reception (based on Semtech datasheets) static float snr_threshold[] = { -7.5, // SF7 needs at least -7.5 dB SNR @@ -232,8 +231,8 @@ float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) { if (snr < snr_threshold[sf - 7]) return 0.0f; // Below threshold, no chance of success - auto success_rate_based_on_snr = (snr - snr_threshold[sf - 7]) / 10.0; - auto collision_penalty = 1 - (packet_len / 256.0); // Assuming max packet of 256 bytes + auto success_rate_based_on_snr = (snr - snr_threshold[sf - 7]) / 10.0f; + auto collision_penalty = 1.0f - (packet_len / 256.0f); // Assuming max packet of 256 bytes - return max(0.0, min(1.0, success_rate_based_on_snr * collision_penalty)); + return std::max(0.0f, std::min(1.0f, success_rate_based_on_snr * collision_penalty)); } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 25d4353993..71c1e4eda2 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -10,6 +10,7 @@ class RadioLibWrapper : public mesh::Radio { mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; + float _last_packet_rssi, _last_packet_snr; uint16_t _num_floor_samples; int32_t _floor_sample_sum; uint8_t _preamble_sf; @@ -17,12 +18,19 @@ class RadioLibWrapper : public mesh::Radio { void idle(); void startRecv(); bool hasNoiseFloor() const; + void updateLastPacketMetrics(float rssi, float snr) { + _last_packet_rssi = rssi; + _last_packet_snr = snr; + } float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; 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), _last_packet_rssi(0), _last_packet_snr(0), _preamble_sf(0) { + n_recv = n_sent = n_recv_errors = 0; + } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -60,8 +68,8 @@ class RadioLibWrapper : public mesh::Radio { uint32_t getPacketsSent() const { return n_sent; } void resetStats() { n_recv = n_sent = n_recv_errors = 0; } - virtual float getLastRSSI() const override; - virtual float getLastSNR() const override; + virtual float getLastRSSI() const override { return _last_packet_rssi; } + virtual float getLastSNR() const override { return _last_packet_snr; } float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, 10, packet_len); } // assume sf=10 diff --git a/test/mocks/RadioLib.h b/test/mocks/RadioLib.h new file mode 100644 index 0000000000..55216fca9c --- /dev/null +++ b/test/mocks/RadioLib.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_UNKNOWN -1 +#define LORA_SF 10 + +inline long random(long min, long max) { + return min < max ? min : max; +} + +class PhysicalLayer { +public: + virtual ~PhysicalLayer() = default; + virtual void setPacketReceivedAction(void (*func)(void)) { (void)func; } + virtual long random(long max) { return max; } + virtual void setOutputPower(int8_t dbm) { (void)dbm; } + virtual void standby() { } + virtual void sleep() { } + virtual int16_t setPreambleLength(size_t len) { + (void)len; + return RADIOLIB_ERR_NONE; + } + virtual int16_t startReceive() { return RADIOLIB_ERR_NONE; } + virtual size_t getPacketLength(bool update = true) { + (void)update; + return 0; + } + virtual int16_t readData(uint8_t* data, size_t len) { + (void)data; + (void)len; + return RADIOLIB_ERR_NONE; + } + virtual unsigned long getTimeOnAir(size_t len) { + (void)len; + return 0; + } + virtual int16_t startTransmit(uint8_t* data, size_t len) { + (void)data; + (void)len; + return RADIOLIB_ERR_NONE; + } + virtual void finishTransmit() { } + virtual uint8_t randomByte() { return 0; } + virtual float getRSSI() { return RADIOLIB_ERR_UNKNOWN; } + virtual float getSNR() { return RADIOLIB_ERR_UNKNOWN; } +}; diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp new file mode 100644 index 0000000000..27e621c82e --- /dev/null +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -0,0 +1,84 @@ +#include + +#include "helpers/radiolib/RadioLibWrappers.h" + +#include "../../src/helpers/radiolib/RadioLibWrappers.cpp" + +class FakeBoard : public mesh::MainBoard { +public: + uint16_t getBattMilliVolts() override { return 0; } + const char* getManufacturerName() const override { return "test"; } + void reboot() override { } + uint8_t getStartupReason() const override { return BD_STARTUP_NORMAL; } +}; + +class FakePhysicalLayer : public PhysicalLayer { +public: + float packet_rssi = -73.0f; + float packet_snr = 8.25f; + + float getRSSI() override { return packet_rssi; } + float getSNR() override { return packet_snr; } +}; + +class TestRadioLibWrapper : public RadioLibWrapper { +public: + TestRadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + + void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + (void)freq; + (void)bw; + (void)sf; + (void)cr; + } + + float getCurrentRSSI() override { return -120.0f; } + bool isReceivingPacket() override { return false; } + + void cachePacketMetrics(float rssi, float snr) { + updateLastPacketMetrics(rssi, snr); + } +}; + +TEST(RssiPacketMetrics, CachedPacketMetricsAreReportedAsLastMetrics) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.cachePacketMetrics(radio.getRSSI(), radio.getSNR()); + + EXPECT_EQ(-73.0f, wrapper.getLastRSSI()); + EXPECT_EQ(8.25f, wrapper.getLastSNR()); +} + +TEST(RssiPacketMetrics, LaterRadioStatusDoesNotChangeCachedPacketMetrics) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.cachePacketMetrics(radio.getRSSI(), radio.getSNR()); + + radio.packet_rssi = -120.0f; + radio.packet_snr = 0.0f; + + EXPECT_EQ(-73.0f, wrapper.getLastRSSI()); + EXPECT_EQ(8.25f, wrapper.getLastSNR()); +} + +TEST(RssiPacketMetrics, NewPacketMetricsReplacePreviousPacketMetrics) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.cachePacketMetrics(radio.getRSSI(), radio.getSNR()); + + wrapper.cachePacketMetrics(-91.0f, -3.5f); + + EXPECT_EQ(-91.0f, wrapper.getLastRSSI()); + EXPECT_EQ(-3.5f, wrapper.getLastSNR()); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 960e416d4c5cfaca485e582e327357ec8659e4b1 Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Wed, 24 Jun 2026 19:30:55 +0100 Subject: [PATCH 03/13] Fix noise floor calibration feedback loop --- src/helpers/radiolib/RadioLibWrappers.cpp | 37 +++---- src/helpers/radiolib/RadioLibWrappers.h | 3 +- .../test_packet_metrics.cpp | 101 +++++++++++++++++- 3 files changed, 116 insertions(+), 25 deletions(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index d3f55e04ca..55b8853027 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -10,8 +10,6 @@ #define STATE_TX_DONE 4 #define STATE_INT_READY 16 -#define NUM_NOISE_FLOOR_SAMPLES 64 -#define SAMPLING_THRESHOLD 14 #define RSSI_CARRIER_SENSE_SAMPLES 5 #define RSSI_CARRIER_SENSE_REQUIRED 3 @@ -41,9 +39,8 @@ void RadioLibWrapper::begin() { _noise_floor = 0; _threshold = 0; - // start average out some samples + // Start a fresh batch of idle RSSI samples for noise-floor calibration. _num_floor_samples = 0; - _floor_sample_sum = 0; } uint32_t RadioLibWrapper::getRngSeed() { @@ -63,7 +60,6 @@ void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling _num_floor_samples = 0; - _floor_sample_sum = 0; } } @@ -78,32 +74,29 @@ 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. + // Reset noise floor sampling so it reconverges from fresh idle RSSI samples + // after the receiver frontend has been reset. _noise_floor = 0; _num_floor_samples = 0; - _floor_sample_sum = 0; } 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; + _floor_samples[_num_floor_samples] = (int16_t)getCurrentRSSI(); + _num_floor_samples++; + + if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { + std::sort(_floor_samples, _floor_samples + NUM_NOISE_FLOOR_SAMPLES); + _noise_floor = ((int32_t)_floor_samples[(NUM_NOISE_FLOOR_SAMPLES / 2) - 1] + + _floor_samples[NUM_NOISE_FLOOR_SAMPLES / 2]) / 2; + if (_noise_floor < -120) { + _noise_floor = -120; // clamp to lower bound of -120dBi + } + + MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); } } - } 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; - - MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); } } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 71c1e4eda2..e94913b513 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -8,11 +8,12 @@ class RadioLibWrapper : public mesh::Radio { protected: PhysicalLayer* _radio; mesh::MainBoard* _board; + static constexpr uint16_t NUM_NOISE_FLOOR_SAMPLES = 64; uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; float _last_packet_rssi, _last_packet_snr; uint16_t _num_floor_samples; - int32_t _floor_sample_sum; + int16_t _floor_samples[NUM_NOISE_FLOOR_SAMPLES]; uint8_t _preamble_sf; void idle(); diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index 27e621c82e..c5c2c2a6ad 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -1,5 +1,7 @@ #include +#include + #include "helpers/radiolib/RadioLibWrappers.h" #include "../../src/helpers/radiolib/RadioLibWrappers.cpp" @@ -25,6 +27,10 @@ class TestRadioLibWrapper : public RadioLibWrapper { public: TestRadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + std::vector current_rssi_samples; + size_t current_rssi_index = 0; + bool receiving_packet = false; + void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { (void)freq; (void)bw; @@ -32,12 +38,33 @@ class TestRadioLibWrapper : public RadioLibWrapper { (void)cr; } - float getCurrentRSSI() override { return -120.0f; } - bool isReceivingPacket() override { return false; } + float getCurrentRSSI() override { + if (current_rssi_index < current_rssi_samples.size()) { + return current_rssi_samples[current_rssi_index++]; + } + return -120.0f; + } + + bool isReceivingPacket() override { return receiving_packet; } void cachePacketMetrics(float rssi, float snr) { updateLastPacketMetrics(rssi, snr); } + + void setCurrentRssiSamples(const std::vector& samples) { + current_rssi_samples = samples; + current_rssi_index = 0; + } + + void enterReceiveMode() { + startRecv(); + } + + void collectNoiseFloorSamples(uint16_t sample_count = 64) { + for (uint16_t i = 0; i < sample_count; i++) { + loop(); + } + } }; TEST(RssiPacketMetrics, CachedPacketMetricsAreReportedAsLastMetrics) { @@ -78,6 +105,76 @@ TEST(RssiPacketMetrics, NewPacketMetricsReplacePreviousPacketMetrics) { EXPECT_EQ(-3.5f, wrapper.getLastSNR()); } +TEST(RssiNoiseFloor, LowStartupOutliersDoNotDominateMedianFloor) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + std::vector samples; + + samples.insert(samples.end(), 16, -130.0f); + samples.insert(samples.end(), 48, -103.0f); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(samples); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-103, wrapper.getNoiseFloor()); +} + +TEST(RssiNoiseFloor, ClampedLowFloorDoesNotRejectLaterHealthySamples) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(64, -130.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-120, wrapper.getNoiseFloor()); + + wrapper.triggerNoiseFloorCalibrate(0); + wrapper.setCurrentRssiSamples(std::vector(64, -102.0f)); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-102, wrapper.getNoiseFloor()); +} + +TEST(RssiNoiseFloor, VeryLowSamplesClampToLowerBound) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(64, -130.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-120, wrapper.getNoiseFloor()); +} + +TEST(RssiNoiseFloor, ReceivingPacketSkipsNoiseFloorSampling) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(64, -101.0f)); + wrapper.enterReceiveMode(); + + wrapper.receiving_packet = true; + wrapper.collectNoiseFloorSamples(10); + + EXPECT_EQ(0, wrapper.current_rssi_index); + EXPECT_EQ(0, wrapper.getNoiseFloor()); + + wrapper.receiving_packet = false; + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-101, wrapper.getNoiseFloor()); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From 5a0220a6c575f70a8cb720af937da8ca23767710 Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Wed, 24 Jun 2026 20:17:13 +0100 Subject: [PATCH 04/13] Reject suspicious low noise floor samples --- src/Dispatcher.h | 9 +++ src/helpers/StatsFormatHelper.h | 12 ++- src/helpers/radiolib/RadioLibWrappers.cpp | 57 +++++++++++--- src/helpers/radiolib/RadioLibWrappers.h | 19 ++++- .../test_packet_metrics.cpp | 74 ++++++++++++++++--- 5 files changed, 147 insertions(+), 24 deletions(-) diff --git a/src/Dispatcher.h b/src/Dispatcher.h index dd032f130d..c51ea1f32a 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -19,6 +19,14 @@ class MillisecondClock { /** * \brief Abstraction of this device's packet radio. */ +struct NoiseFloorStats { + uint16_t accepted_count; + int16_t sample_min; + int16_t sample_median; + int16_t sample_max; + uint16_t rejected_low_bound_count; +}; + class Radio { public: virtual void begin() { } @@ -62,6 +70,7 @@ class Radio { virtual void loop() { } virtual int getNoiseFloor() const { return 0; } + virtual NoiseFloorStats getNoiseFloorStats() const { return {0, 0, 0, 0, 0}; } virtual void triggerNoiseFloorCalibrate(int threshold) { } diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index bf619133e9..ab67d00184 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -24,13 +24,21 @@ class StatsFormatHelper { RadioDriverType& driver, uint32_t total_air_time_ms, uint32_t total_rx_air_time_ms) { + mesh::NoiseFloorStats nf_stats = radio->getNoiseFloorStats(); sprintf(reply, - "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u}", + "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u," + "\"noise_floor_sample_count\":%u,\"noise_floor_sample_min\":%d,\"noise_floor_sample_median\":%d," + "\"noise_floor_sample_max\":%d,\"noise_floor_rejected_low_bound\":%u}", (int16_t)radio->getNoiseFloor(), (int16_t)driver.getLastRSSI(), driver.getLastSNR(), total_air_time_ms / 1000, - total_rx_air_time_ms / 1000 + total_rx_air_time_ms / 1000, + nf_stats.accepted_count, + nf_stats.sample_min, + nf_stats.sample_median, + nf_stats.sample_max, + nf_stats.rejected_low_bound_count ); } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 55b8853027..bc3307bdc3 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -12,6 +12,8 @@ #define RSSI_CARRIER_SENSE_SAMPLES 5 #define RSSI_CARRIER_SENSE_REQUIRED 3 +#define MIN_NOISE_FLOOR_SAMPLE -120 +#define LOW_BOUND_REJECT_JUMP_DB 14 static volatile uint8_t state = STATE_IDLE; @@ -26,6 +28,14 @@ void setFlag(void) { state |= STATE_INT_READY; } +void RadioLibWrapper::resetNoiseFloorBatch() { + _num_floor_samples = 0; + _floor_sample_min = 0; + _floor_sample_median = 0; + _floor_sample_max = 0; + _floor_rejected_low_bound = 0; +} + void RadioLibWrapper::begin() { _radio->setPacketReceivedAction(setFlag); // this is also SentComplete interrupt _preamble_sf = getSpreadingFactor(); @@ -40,7 +50,7 @@ void RadioLibWrapper::begin() { _threshold = 0; // Start a fresh batch of idle RSSI samples for noise-floor calibration. - _num_floor_samples = 0; + resetNoiseFloorBatch(); } uint32_t RadioLibWrapper::getRngSeed() { @@ -59,7 +69,7 @@ void RadioLibWrapper::idle() { void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling - _num_floor_samples = 0; + resetNoiseFloorBatch(); } } @@ -74,27 +84,54 @@ void RadioLibWrapper::resetAGC() { doResetAGC(); state = STATE_IDLE; // trigger a startReceive() - // Reset noise floor sampling so it reconverges from fresh idle RSSI samples - // after the receiver frontend has been reset. - _noise_floor = 0; - _num_floor_samples = 0; + // Reset the in-progress batch after the receiver frontend has been reset. + // Keep the last published floor until a valid post-reset batch completes, + // because early post-reset RSSI readings can sit at the low reporting bound. + resetNoiseFloorBatch(); } void RadioLibWrapper::loop() { if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { - _floor_samples[_num_floor_samples] = (int16_t)getCurrentRSSI(); + int16_t rssi = (int16_t)getCurrentRSSI(); + if (_noise_floor != 0 && + _noise_floor > MIN_NOISE_FLOOR_SAMPLE && + rssi <= MIN_NOISE_FLOOR_SAMPLE && + (_noise_floor - rssi) >= LOW_BOUND_REJECT_JUMP_DB) { + _floor_rejected_low_bound++; + return; + } + + if (_num_floor_samples == 0) { + _floor_sample_min = rssi; + _floor_sample_max = rssi; + } else { + if (rssi < _floor_sample_min) { + _floor_sample_min = rssi; + } + if (rssi > _floor_sample_max) { + _floor_sample_max = rssi; + } + } + _floor_samples[_num_floor_samples] = rssi; _num_floor_samples++; if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { std::sort(_floor_samples, _floor_samples + NUM_NOISE_FLOOR_SAMPLES); - _noise_floor = ((int32_t)_floor_samples[(NUM_NOISE_FLOOR_SAMPLES / 2) - 1] + - _floor_samples[NUM_NOISE_FLOOR_SAMPLES / 2]) / 2; + _floor_sample_median = ((int32_t)_floor_samples[(NUM_NOISE_FLOOR_SAMPLES / 2) - 1] + + _floor_samples[NUM_NOISE_FLOOR_SAMPLES / 2]) / 2; + _noise_floor = _floor_sample_median; if (_noise_floor < -120) { _noise_floor = -120; // clamp to lower bound of -120dBi } - MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); + MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor=%d accepted=%u min=%d median=%d max=%d rejected_low=%u", + (int)_noise_floor, + _num_floor_samples, + (int)_floor_sample_min, + (int)_floor_sample_median, + (int)_floor_sample_max, + _floor_rejected_low_bound); } } } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index e94913b513..25027712ba 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -14,8 +14,11 @@ class RadioLibWrapper : public mesh::Radio { float _last_packet_rssi, _last_packet_snr; uint16_t _num_floor_samples; int16_t _floor_samples[NUM_NOISE_FLOOR_SAMPLES]; + int16_t _floor_sample_min, _floor_sample_median, _floor_sample_max; + uint16_t _floor_rejected_low_bound; uint8_t _preamble_sf; + void resetNoiseFloorBatch(); void idle(); void startRecv(); bool hasNoiseFloor() const; @@ -29,9 +32,10 @@ class RadioLibWrapper : public mesh::Radio { public: RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : - _radio(&radio), _board(&board), _last_packet_rssi(0), _last_packet_snr(0), _preamble_sf(0) { - n_recv = n_sent = n_recv_errors = 0; - } + _radio(&radio), _board(&board), n_recv(0), n_sent(0), n_recv_errors(0), + _noise_floor(0), _threshold(0), _last_packet_rssi(0), _last_packet_snr(0), + _num_floor_samples(0), _floor_sample_min(0), _floor_sample_median(0), + _floor_sample_max(0), _floor_rejected_low_bound(0), _preamble_sf(0) { } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -59,6 +63,15 @@ class RadioLibWrapper : public mesh::Radio { void updatePreamble(uint8_t sf) { _preamble_sf = sf; _radio->setPreambleLength(preambleLengthForSF(sf)); } int getNoiseFloor() const override { return _noise_floor; } + mesh::NoiseFloorStats getNoiseFloorStats() const override { + return { + _num_floor_samples, + _floor_sample_min, + _floor_sample_median, + _floor_sample_max, + _floor_rejected_low_bound + }; + } void triggerNoiseFloorCalibrate(int threshold) override; void resetAGC() override; diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index c5c2c2a6ad..5c1b05e8c7 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -1,7 +1,9 @@ #include +#include #include +#include "helpers/StatsFormatHelper.h" #include "helpers/radiolib/RadioLibWrappers.h" #include "../../src/helpers/radiolib/RadioLibWrappers.cpp" @@ -51,6 +53,11 @@ class TestRadioLibWrapper : public RadioLibWrapper { updateLastPacketMetrics(rssi, snr); } + void forceNoiseFloor(int16_t noise_floor) { + _noise_floor = noise_floor; + _num_floor_samples = NUM_NOISE_FLOOR_SAMPLES; + } + void setCurrentRssiSamples(const std::vector& samples) { current_rssi_samples = samples; current_rssi_index = 0; @@ -112,14 +119,20 @@ TEST(RssiNoiseFloor, LowStartupOutliersDoNotDominateMedianFloor) { std::vector samples; samples.insert(samples.end(), 16, -130.0f); - samples.insert(samples.end(), 48, -103.0f); + samples.insert(samples.end(), 64, -103.0f); wrapper.begin(); wrapper.setCurrentRssiSamples(samples); wrapper.enterReceiveMode(); - wrapper.collectNoiseFloorSamples(); + wrapper.collectNoiseFloorSamples(80); EXPECT_EQ(-103, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(64, stats.accepted_count); + EXPECT_EQ(-130, stats.sample_min); + EXPECT_EQ(-103, stats.sample_median); + EXPECT_EQ(-103, stats.sample_max); + EXPECT_EQ(0, stats.rejected_low_bound_count); } TEST(RssiNoiseFloor, ClampedLowFloorDoesNotRejectLaterHealthySamples) { @@ -128,20 +141,16 @@ TEST(RssiNoiseFloor, ClampedLowFloorDoesNotRejectLaterHealthySamples) { TestRadioLibWrapper wrapper(radio, board); wrapper.begin(); - wrapper.setCurrentRssiSamples(std::vector(64, -130.0f)); - wrapper.enterReceiveMode(); - wrapper.collectNoiseFloorSamples(); - - EXPECT_EQ(-120, wrapper.getNoiseFloor()); - + wrapper.forceNoiseFloor(-120); wrapper.triggerNoiseFloorCalibrate(0); wrapper.setCurrentRssiSamples(std::vector(64, -102.0f)); + wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(); EXPECT_EQ(-102, wrapper.getNoiseFloor()); } -TEST(RssiNoiseFloor, VeryLowSamplesClampToLowerBound) { +TEST(RssiNoiseFloor, VeryLowSamplesCanPublishWhenNoPreviousFloorExists) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); @@ -152,6 +161,33 @@ TEST(RssiNoiseFloor, VeryLowSamplesClampToLowerBound) { wrapper.collectNoiseFloorSamples(); EXPECT_EQ(-120, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(64, stats.accepted_count); + EXPECT_EQ(-130, stats.sample_min); + EXPECT_EQ(-130, stats.sample_median); + EXPECT_EQ(-130, stats.sample_max); + EXPECT_EQ(0, stats.rejected_low_bound_count); +} + +TEST(RssiNoiseFloor, ResetAGCPreservesPreviousFloorUntilValidBatchCompletes) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.forceNoiseFloor(-103); + wrapper.resetAGC(); + wrapper.enterReceiveMode(); + wrapper.setCurrentRssiSamples(std::vector(64, -130.0f)); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-103, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(0, stats.sample_min); + EXPECT_EQ(0, stats.sample_median); + EXPECT_EQ(0, stats.sample_max); + EXPECT_EQ(64, stats.rejected_low_bound_count); } TEST(RssiNoiseFloor, ReceivingPacketSkipsNoiseFloorSampling) { @@ -175,6 +211,26 @@ TEST(RssiNoiseFloor, ReceivingPacketSkipsNoiseFloorSampling) { EXPECT_EQ(-101, wrapper.getNoiseFloor()); } +TEST(RssiNoiseFloor, RadioStatsExposeCalibrationDiagnostics) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + char reply[512]; + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(64, -101.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + StatsFormatHelper::formatRadioStats(reply, &wrapper, wrapper, 1000, 2000); + + EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_sample_count\":64")); + EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_sample_min\":-101")); + EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_sample_median\":-101")); + EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_sample_max\":-101")); + EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_rejected_low_bound\":0")); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From f20fc98c244c08017031bfb06680bf4bbaea01ce Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Wed, 24 Jun 2026 21:47:59 +0100 Subject: [PATCH 05/13] Reject low-bound startup RSSI samples --- src/helpers/radiolib/RadioLibWrappers.cpp | 15 +++++-- .../test_packet_metrics.cpp | 39 +++++++++++++++---- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index bc3307bdc3..fc1752f690 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -13,6 +13,7 @@ #define RSSI_CARRIER_SENSE_SAMPLES 5 #define RSSI_CARRIER_SENSE_REQUIRED 3 #define MIN_NOISE_FLOOR_SAMPLE -120 +#define LOW_BOUND_REJECT_MARGIN_DB 1 #define LOW_BOUND_REJECT_JUMP_DB 14 static volatile uint8_t state = STATE_IDLE; @@ -94,10 +95,16 @@ void RadioLibWrapper::loop() { if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { int16_t rssi = (int16_t)getCurrentRSSI(); - if (_noise_floor != 0 && - _noise_floor > MIN_NOISE_FLOOR_SAMPLE && - rssi <= MIN_NOISE_FLOOR_SAMPLE && - (_noise_floor - rssi) >= LOW_BOUND_REJECT_JUMP_DB) { + bool low_bound_sample = rssi <= MIN_NOISE_FLOOR_SAMPLE + LOW_BOUND_REJECT_MARGIN_DB; + bool no_published_floor = _noise_floor == 0; + bool healthy_floor_would_jump_down = _noise_floor > MIN_NOISE_FLOOR_SAMPLE && + (_noise_floor - rssi) >= LOW_BOUND_REJECT_JUMP_DB; + + // SX126x RSSI readings can sit on the receiver's low reporting bound + // during startup or after an AGC reset. Those readings are not useful + // calibration input: publishing them traps the floor at -120 dBm until + // healthier samples are allowed back in. + if (low_bound_sample && (no_published_floor || healthy_floor_would_jump_down)) { _floor_rejected_low_bound++; return; } diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index 5c1b05e8c7..e5531a0c85 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -129,10 +129,10 @@ TEST(RssiNoiseFloor, LowStartupOutliersDoNotDominateMedianFloor) { EXPECT_EQ(-103, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); EXPECT_EQ(64, stats.accepted_count); - EXPECT_EQ(-130, stats.sample_min); + EXPECT_EQ(-103, stats.sample_min); EXPECT_EQ(-103, stats.sample_median); EXPECT_EQ(-103, stats.sample_max); - EXPECT_EQ(0, stats.rejected_low_bound_count); + EXPECT_EQ(16, stats.rejected_low_bound_count); } TEST(RssiNoiseFloor, ClampedLowFloorDoesNotRejectLaterHealthySamples) { @@ -150,7 +150,7 @@ TEST(RssiNoiseFloor, ClampedLowFloorDoesNotRejectLaterHealthySamples) { EXPECT_EQ(-102, wrapper.getNoiseFloor()); } -TEST(RssiNoiseFloor, VeryLowSamplesCanPublishWhenNoPreviousFloorExists) { +TEST(RssiNoiseFloor, VeryLowSamplesDoNotPublishWhenNoPreviousFloorExists) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); @@ -160,13 +160,36 @@ TEST(RssiNoiseFloor, VeryLowSamplesCanPublishWhenNoPreviousFloorExists) { wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(); - EXPECT_EQ(-120, wrapper.getNoiseFloor()); + EXPECT_EQ(0, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(0, stats.sample_min); + EXPECT_EQ(0, stats.sample_median); + EXPECT_EQ(0, stats.sample_max); + EXPECT_EQ(64, stats.rejected_low_bound_count); +} + +TEST(RssiNoiseFloor, LowBoundStartupSamplesDoNotContaminateLaterHealthyBatch) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + std::vector samples; + + samples.insert(samples.end(), 64, -120.0f); + samples.insert(samples.end(), 64, -102.0f); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(samples); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(128); + + EXPECT_EQ(-102, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); EXPECT_EQ(64, stats.accepted_count); - EXPECT_EQ(-130, stats.sample_min); - EXPECT_EQ(-130, stats.sample_median); - EXPECT_EQ(-130, stats.sample_max); - EXPECT_EQ(0, stats.rejected_low_bound_count); + EXPECT_EQ(-102, stats.sample_min); + EXPECT_EQ(-102, stats.sample_median); + EXPECT_EQ(-102, stats.sample_max); + EXPECT_EQ(64, stats.rejected_low_bound_count); } TEST(RssiNoiseFloor, ResetAGCPreservesPreviousFloorUntilValidBatchCompletes) { From d66f784c0301ae6f2d54df8b5a2a745b9e77747c Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Wed, 24 Jun 2026 22:29:43 +0100 Subject: [PATCH 06/13] Report high noise floor sample rejects --- docs/cli_commands.md | 11 ++ src/Dispatcher.h | 3 +- src/helpers/StatsFormatHelper.h | 6 +- src/helpers/radiolib/RadioLibWrappers.cpp | 28 ++++- src/helpers/radiolib/RadioLibWrappers.h | 7 +- .../test_packet_metrics.cpp | 116 +++++++++++++++++- 6 files changed, 160 insertions(+), 11 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 18ec42bc83..820d4d3872 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -148,6 +148,17 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Serial Only:** Yes +Returns JSON with: +- `noise_floor`: current radio noise floor estimate in dBm +- `last_rssi`: RSSI from the most recent received packet +- `last_snr`: SNR from the most recent received packet +- `tx_air_secs`: accumulated transmit airtime in seconds +- `rx_air_secs`: accumulated receive airtime estimate in seconds +- `noise_floor_sample_count`: RSSI samples accepted into the current or most recent calibration batch +- `noise_floor_sample_min` / `noise_floor_sample_median` / `noise_floor_sample_max`: accepted RSSI sample range in dBm +- `noise_floor_rejected_low_bound`: low-bound RSSI samples rejected because they would cause a suspicious downward jump +- `noise_floor_rejected_high_bound`: strong RSSI samples rejected because they look like channel activity rather than idle noise + --- ### Packet stats - Packet counters: Received, Sent diff --git a/src/Dispatcher.h b/src/Dispatcher.h index c51ea1f32a..4e498e6472 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -25,6 +25,7 @@ struct NoiseFloorStats { int16_t sample_median; int16_t sample_max; uint16_t rejected_low_bound_count; + uint16_t rejected_high_bound_count; }; class Radio { @@ -70,7 +71,7 @@ class Radio { virtual void loop() { } virtual int getNoiseFloor() const { return 0; } - virtual NoiseFloorStats getNoiseFloorStats() const { return {0, 0, 0, 0, 0}; } + virtual NoiseFloorStats getNoiseFloorStats() const { return {0, 0, 0, 0, 0, 0}; } virtual void triggerNoiseFloorCalibrate(int threshold) { } diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index ab67d00184..2f87b68db9 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -28,7 +28,8 @@ class StatsFormatHelper { sprintf(reply, "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u," "\"noise_floor_sample_count\":%u,\"noise_floor_sample_min\":%d,\"noise_floor_sample_median\":%d," - "\"noise_floor_sample_max\":%d,\"noise_floor_rejected_low_bound\":%u}", + "\"noise_floor_sample_max\":%d,\"noise_floor_rejected_low_bound\":%u," + "\"noise_floor_rejected_high_bound\":%u}", (int16_t)radio->getNoiseFloor(), (int16_t)driver.getLastRSSI(), driver.getLastSNR(), @@ -38,7 +39,8 @@ class StatsFormatHelper { nf_stats.sample_min, nf_stats.sample_median, nf_stats.sample_max, - nf_stats.rejected_low_bound_count + nf_stats.rejected_low_bound_count, + nf_stats.rejected_high_bound_count ); } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index fc1752f690..b4fbf79784 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -13,8 +13,10 @@ #define RSSI_CARRIER_SENSE_SAMPLES 5 #define RSSI_CARRIER_SENSE_REQUIRED 3 #define MIN_NOISE_FLOOR_SAMPLE -120 +#define MAX_NOISE_FLOOR_SAMPLE -80 #define LOW_BOUND_REJECT_MARGIN_DB 1 #define LOW_BOUND_REJECT_JUMP_DB 14 +#define HIGH_BOUND_REJECT_JUMP_DB 14 static volatile uint8_t state = STATE_IDLE; @@ -35,6 +37,7 @@ void RadioLibWrapper::resetNoiseFloorBatch() { _floor_sample_median = 0; _floor_sample_max = 0; _floor_rejected_low_bound = 0; + _floor_rejected_high_bound = 0; } void RadioLibWrapper::begin() { @@ -69,7 +72,13 @@ void RadioLibWrapper::idle() { void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; - if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling + if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES || + (_num_floor_samples == 0 && + (_floor_rejected_low_bound > 0 || _floor_rejected_high_bound > 0))) { + // Start a new stats window after a completed batch, or after a + // rejected-only interval. Without the second case, a device sitting on the + // low RSSI reporting bound can accumulate a stale count for the whole + // uptime even though no calibration batch is progressing. resetNoiseFloorBatch(); } } @@ -99,13 +108,23 @@ void RadioLibWrapper::loop() { bool no_published_floor = _noise_floor == 0; bool healthy_floor_would_jump_down = _noise_floor > MIN_NOISE_FLOOR_SAMPLE && (_noise_floor - rssi) >= LOW_BOUND_REJECT_JUMP_DB; + bool high_bound_sample = rssi >= MAX_NOISE_FLOOR_SAMPLE; + bool healthy_floor_would_jump_up = _noise_floor > MIN_NOISE_FLOOR_SAMPLE && + (rssi - _noise_floor) >= HIGH_BOUND_REJECT_JUMP_DB; // SX126x RSSI readings can sit on the receiver's low reporting bound // during startup or after an AGC reset. Those readings are not useful // calibration input: publishing them traps the floor at -120 dBm until // healthier samples are allowed back in. if (low_bound_sample && (no_published_floor || healthy_floor_would_jump_down)) { - _floor_rejected_low_bound++; + _floor_rejected_low_bound = mesh::incrementStatCounter(_floor_rejected_low_bound); + return; + } + // Strong instantaneous RSSI is channel activity, not idle floor. Median + // resists a few of these samples, but rejecting them keeps the batch + // diagnostics honest and prevents a busy period from becoming the floor. + if ((no_published_floor && high_bound_sample) || healthy_floor_would_jump_up) { + _floor_rejected_high_bound = mesh::incrementStatCounter(_floor_rejected_high_bound); return; } @@ -132,13 +151,14 @@ void RadioLibWrapper::loop() { _noise_floor = -120; // clamp to lower bound of -120dBi } - MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor=%d accepted=%u min=%d median=%d max=%d rejected_low=%u", + MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor=%d accepted=%u min=%d median=%d max=%d rejected_low=%u rejected_high=%u", (int)_noise_floor, _num_floor_samples, (int)_floor_sample_min, (int)_floor_sample_median, (int)_floor_sample_max, - _floor_rejected_low_bound); + _floor_rejected_low_bound, + _floor_rejected_high_bound); } } } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 25027712ba..83b51a655c 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -16,6 +16,7 @@ class RadioLibWrapper : public mesh::Radio { int16_t _floor_samples[NUM_NOISE_FLOOR_SAMPLES]; int16_t _floor_sample_min, _floor_sample_median, _floor_sample_max; uint16_t _floor_rejected_low_bound; + uint16_t _floor_rejected_high_bound; uint8_t _preamble_sf; void resetNoiseFloorBatch(); @@ -35,7 +36,8 @@ class RadioLibWrapper : public mesh::Radio { _radio(&radio), _board(&board), n_recv(0), n_sent(0), n_recv_errors(0), _noise_floor(0), _threshold(0), _last_packet_rssi(0), _last_packet_snr(0), _num_floor_samples(0), _floor_sample_min(0), _floor_sample_median(0), - _floor_sample_max(0), _floor_rejected_low_bound(0), _preamble_sf(0) { } + _floor_sample_max(0), _floor_rejected_low_bound(0), + _floor_rejected_high_bound(0), _preamble_sf(0) { } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -69,7 +71,8 @@ class RadioLibWrapper : public mesh::Radio { _floor_sample_min, _floor_sample_median, _floor_sample_max, - _floor_rejected_low_bound + _floor_rejected_low_bound, + _floor_rejected_high_bound }; } void triggerNoiseFloorCalibrate(int threshold) override; diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index e5531a0c85..1777b946a0 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -67,8 +67,8 @@ class TestRadioLibWrapper : public RadioLibWrapper { startRecv(); } - void collectNoiseFloorSamples(uint16_t sample_count = 64) { - for (uint16_t i = 0; i < sample_count; i++) { + void collectNoiseFloorSamples(uint32_t sample_count = 64) { + for (uint32_t i = 0; i < sample_count; i++) { loop(); } } @@ -167,6 +167,115 @@ TEST(RssiNoiseFloor, VeryLowSamplesDoNotPublishWhenNoPreviousFloorExists) { EXPECT_EQ(0, stats.sample_median); EXPECT_EQ(0, stats.sample_max); EXPECT_EQ(64, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_high_bound_count); +} + +TEST(RssiNoiseFloor, LowBoundRejectCountSaturates) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(70000, -130.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(70000); + + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(UINT16_MAX, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_high_bound_count); +} + +TEST(RssiNoiseFloor, RejectedOnlyBatchResetsOnCalibrationTrigger) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(12, -130.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(12); + + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(12, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_high_bound_count); + + wrapper.triggerNoiseFloorCalibrate(0); + + stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(0, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_high_bound_count); +} + +TEST(RssiNoiseFloor, StrongStartupRssiDoesNotPublishWhenNoPreviousFloorExists) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(64, -58.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(0, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(0, stats.sample_min); + EXPECT_EQ(0, stats.sample_median); + EXPECT_EQ(0, stats.sample_max); + EXPECT_EQ(0, stats.rejected_low_bound_count); + EXPECT_EQ(64, stats.rejected_high_bound_count); +} + +TEST(RssiNoiseFloor, StrongRssiSamplesDoNotContaminateHealthyBatch) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + std::vector samples; + + samples.insert(samples.end(), 8, -58.0f); + samples.insert(samples.end(), 64, -100.0f); + + wrapper.begin(); + wrapper.forceNoiseFloor(-100); + wrapper.triggerNoiseFloorCalibrate(0); + wrapper.setCurrentRssiSamples(samples); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(72); + + EXPECT_EQ(-100, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(64, stats.accepted_count); + EXPECT_EQ(-100, stats.sample_min); + EXPECT_EQ(-100, stats.sample_median); + EXPECT_EQ(-100, stats.sample_max); + EXPECT_EQ(0, stats.rejected_low_bound_count); + EXPECT_EQ(8, stats.rejected_high_bound_count); +} + +TEST(RssiNoiseFloor, RejectedHighOnlyBatchResetsOnCalibrationTrigger) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(12, -58.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(12); + + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(0, stats.rejected_low_bound_count); + EXPECT_EQ(12, stats.rejected_high_bound_count); + + wrapper.triggerNoiseFloorCalibrate(0); + + stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(0, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_high_bound_count); } TEST(RssiNoiseFloor, LowBoundStartupSamplesDoNotContaminateLaterHealthyBatch) { @@ -190,6 +299,7 @@ TEST(RssiNoiseFloor, LowBoundStartupSamplesDoNotContaminateLaterHealthyBatch) { EXPECT_EQ(-102, stats.sample_median); EXPECT_EQ(-102, stats.sample_max); EXPECT_EQ(64, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_high_bound_count); } TEST(RssiNoiseFloor, ResetAGCPreservesPreviousFloorUntilValidBatchCompletes) { @@ -211,6 +321,7 @@ TEST(RssiNoiseFloor, ResetAGCPreservesPreviousFloorUntilValidBatchCompletes) { EXPECT_EQ(0, stats.sample_median); EXPECT_EQ(0, stats.sample_max); EXPECT_EQ(64, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_high_bound_count); } TEST(RssiNoiseFloor, ReceivingPacketSkipsNoiseFloorSampling) { @@ -252,6 +363,7 @@ TEST(RssiNoiseFloor, RadioStatsExposeCalibrationDiagnostics) { EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_sample_median\":-101")); EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_sample_max\":-101")); EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_rejected_low_bound\":0")); + EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_rejected_high_bound\":0")); } int main(int argc, char **argv) { From e038f490d75c62bccf4d2a6d677074b1609e2b7c Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Thu, 25 Jun 2026 00:32:19 +0100 Subject: [PATCH 07/13] Treat clamped noise floor as untrusted --- src/helpers/radiolib/RadioLibWrappers.cpp | 11 +++--- .../test_packet_metrics.cpp | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index b4fbf79784..4651323914 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -105,25 +105,26 @@ void RadioLibWrapper::loop() { if (!isReceivingPacket()) { int16_t rssi = (int16_t)getCurrentRSSI(); bool low_bound_sample = rssi <= MIN_NOISE_FLOOR_SAMPLE + LOW_BOUND_REJECT_MARGIN_DB; - bool no_published_floor = _noise_floor == 0; - bool healthy_floor_would_jump_down = _noise_floor > MIN_NOISE_FLOOR_SAMPLE && + bool trusted_published_floor = _noise_floor != 0 && _noise_floor > MIN_NOISE_FLOOR_SAMPLE; + bool no_trusted_floor = !trusted_published_floor; + bool healthy_floor_would_jump_down = trusted_published_floor && (_noise_floor - rssi) >= LOW_BOUND_REJECT_JUMP_DB; bool high_bound_sample = rssi >= MAX_NOISE_FLOOR_SAMPLE; - bool healthy_floor_would_jump_up = _noise_floor > MIN_NOISE_FLOOR_SAMPLE && + bool healthy_floor_would_jump_up = trusted_published_floor && (rssi - _noise_floor) >= HIGH_BOUND_REJECT_JUMP_DB; // SX126x RSSI readings can sit on the receiver's low reporting bound // during startup or after an AGC reset. Those readings are not useful // calibration input: publishing them traps the floor at -120 dBm until // healthier samples are allowed back in. - if (low_bound_sample && (no_published_floor || healthy_floor_would_jump_down)) { + if (low_bound_sample && (no_trusted_floor || healthy_floor_would_jump_down)) { _floor_rejected_low_bound = mesh::incrementStatCounter(_floor_rejected_low_bound); return; } // Strong instantaneous RSSI is channel activity, not idle floor. Median // resists a few of these samples, but rejecting them keeps the batch // diagnostics honest and prevents a busy period from becoming the floor. - if ((no_published_floor && high_bound_sample) || healthy_floor_would_jump_up) { + if ((no_trusted_floor && high_bound_sample) || healthy_floor_would_jump_up) { _floor_rejected_high_bound = mesh::incrementStatCounter(_floor_rejected_high_bound); return; } diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index 1777b946a0..ee84a1f99a 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -150,6 +150,44 @@ TEST(RssiNoiseFloor, ClampedLowFloorDoesNotRejectLaterHealthySamples) { EXPECT_EQ(-102, wrapper.getNoiseFloor()); } +TEST(RssiNoiseFloor, ClampedLowFloorStillRejectsLaterLowBoundSamples) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.forceNoiseFloor(-120); + wrapper.triggerNoiseFloorCalibrate(0); + wrapper.setCurrentRssiSamples(std::vector(64, -120.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-120, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(64, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_high_bound_count); +} + +TEST(RssiNoiseFloor, ClampedLowFloorStillRejectsStrongSamples) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.forceNoiseFloor(-120); + wrapper.triggerNoiseFloorCalibrate(0); + wrapper.setCurrentRssiSamples(std::vector(64, -58.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-120, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(0, stats.rejected_low_bound_count); + EXPECT_EQ(64, stats.rejected_high_bound_count); +} + TEST(RssiNoiseFloor, VeryLowSamplesDoNotPublishWhenNoPreviousFloorExists) { FakePhysicalLayer radio; FakeBoard board; From d345fecc6a6ca0b5ec08951a5a3bb96deb232f8d Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Thu, 25 Jun 2026 00:35:46 +0100 Subject: [PATCH 08/13] Treat low-bound noise floor as uncalibrated --- src/helpers/radiolib/RadioLibWrappers.cpp | 70 +++++++++++++++++-- src/helpers/radiolib/RadioLibWrappers.h | 14 +--- .../test_packet_metrics.cpp | 20 +++++- 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 4651323914..001e63e281 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -20,6 +20,14 @@ static volatile uint8_t state = STATE_IDLE; +static bool isTrustedNoiseFloorValue(int16_t noise_floor) { + return noise_floor != 0 && noise_floor > MIN_NOISE_FLOOR_SAMPLE; +} + +static uint16_t addStatCounter(uint16_t value, uint16_t increment) { + return (uint16_t)mesh::cappedStatCounter((uint32_t)value + increment); +} + // this function is called when a complete packet // is transmitted by the module static @@ -31,11 +39,15 @@ void setFlag(void) { state |= STATE_INT_READY; } -void RadioLibWrapper::resetNoiseFloorBatch() { +void RadioLibWrapper::resetNoiseFloorSamples() { _num_floor_samples = 0; _floor_sample_min = 0; _floor_sample_median = 0; _floor_sample_max = 0; +} + +void RadioLibWrapper::resetNoiseFloorBatch() { + resetNoiseFloorSamples(); _floor_rejected_low_bound = 0; _floor_rejected_high_bound = 0; } @@ -105,7 +117,7 @@ void RadioLibWrapper::loop() { if (!isReceivingPacket()) { int16_t rssi = (int16_t)getCurrentRSSI(); bool low_bound_sample = rssi <= MIN_NOISE_FLOOR_SAMPLE + LOW_BOUND_REJECT_MARGIN_DB; - bool trusted_published_floor = _noise_floor != 0 && _noise_floor > MIN_NOISE_FLOOR_SAMPLE; + bool trusted_published_floor = isTrustedNoiseFloorValue(_noise_floor); bool no_trusted_floor = !trusted_published_floor; bool healthy_floor_would_jump_down = trusted_published_floor && (_noise_floor - rssi) >= LOW_BOUND_REJECT_JUMP_DB; @@ -147,11 +159,31 @@ void RadioLibWrapper::loop() { std::sort(_floor_samples, _floor_samples + NUM_NOISE_FLOOR_SAMPLES); _floor_sample_median = ((int32_t)_floor_samples[(NUM_NOISE_FLOOR_SAMPLES / 2) - 1] + _floor_samples[NUM_NOISE_FLOOR_SAMPLES / 2]) / 2; - _noise_floor = _floor_sample_median; - if (_noise_floor < -120) { - _noise_floor = -120; // clamp to lower bound of -120dBi + bool completed_low_bound_batch = + _floor_sample_median <= MIN_NOISE_FLOOR_SAMPLE + LOW_BOUND_REJECT_MARGIN_DB; + bool completed_high_activity_batch = + no_trusted_floor && _floor_sample_median >= MAX_NOISE_FLOOR_SAMPLE; + bool completed_batch_would_jump_down = trusted_published_floor && + (_noise_floor - _floor_sample_median) >= LOW_BOUND_REJECT_JUMP_DB; + bool completed_batch_would_jump_up = trusted_published_floor && + (_floor_sample_median - _noise_floor) >= HIGH_BOUND_REJECT_JUMP_DB; + + // The per-sample gates should normally stop invalid batches before + // they complete. Keep the publish path defensive as well, so a stale + // or boundary-filled batch cannot re-publish -120 dBm as a real floor. + if (completed_low_bound_batch || completed_batch_would_jump_down) { + _floor_rejected_low_bound = addStatCounter(_floor_rejected_low_bound, _num_floor_samples); + resetNoiseFloorSamples(); + return; + } + if (completed_high_activity_batch || completed_batch_would_jump_up) { + _floor_rejected_high_bound = addStatCounter(_floor_rejected_high_bound, _num_floor_samples); + resetNoiseFloorSamples(); + return; } + _noise_floor = _floor_sample_median; + MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor=%d accepted=%u min=%d median=%d max=%d rejected_low=%u rejected_high=%u", (int)_noise_floor, _num_floor_samples, @@ -179,7 +211,33 @@ bool RadioLibWrapper::isInRecvMode() const { } bool RadioLibWrapper::hasNoiseFloor() const { - return _noise_floor != 0 && _num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES; + return isTrustedNoiseFloorValue(_noise_floor) && _num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES; +} + +int RadioLibWrapper::getNoiseFloor() const { + return isTrustedNoiseFloorValue(_noise_floor) ? _noise_floor : 0; +} + +mesh::NoiseFloorStats RadioLibWrapper::getNoiseFloorStats() const { + if (!isTrustedNoiseFloorValue(_noise_floor) && _num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { + return { + 0, + 0, + 0, + 0, + _floor_rejected_low_bound, + _floor_rejected_high_bound + }; + } + + return { + _num_floor_samples, + _floor_sample_min, + _floor_sample_median, + _floor_sample_max, + _floor_rejected_low_bound, + _floor_rejected_high_bound + }; } int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 83b51a655c..4b0f35ecfa 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -19,6 +19,7 @@ class RadioLibWrapper : public mesh::Radio { uint16_t _floor_rejected_high_bound; uint8_t _preamble_sf; + void resetNoiseFloorSamples(); void resetNoiseFloorBatch(); void idle(); void startRecv(); @@ -64,17 +65,8 @@ class RadioLibWrapper : public mesh::Radio { static uint16_t preambleLengthForSF(uint8_t sf) { return sf <= 8 ? 32 : 16; } void updatePreamble(uint8_t sf) { _preamble_sf = sf; _radio->setPreambleLength(preambleLengthForSF(sf)); } - int getNoiseFloor() const override { return _noise_floor; } - mesh::NoiseFloorStats getNoiseFloorStats() const override { - return { - _num_floor_samples, - _floor_sample_min, - _floor_sample_median, - _floor_sample_max, - _floor_rejected_low_bound, - _floor_rejected_high_bound - }; - } + int getNoiseFloor() const override; + mesh::NoiseFloorStats getNoiseFloorStats() const override; void triggerNoiseFloorCalibrate(int threshold) override; void resetAGC() override; diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index ee84a1f99a..31d88ebe07 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -162,7 +162,7 @@ TEST(RssiNoiseFloor, ClampedLowFloorStillRejectsLaterLowBoundSamples) { wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(); - EXPECT_EQ(-120, wrapper.getNoiseFloor()); + EXPECT_EQ(0, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); EXPECT_EQ(0, stats.accepted_count); EXPECT_EQ(64, stats.rejected_low_bound_count); @@ -181,7 +181,7 @@ TEST(RssiNoiseFloor, ClampedLowFloorStillRejectsStrongSamples) { wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(); - EXPECT_EQ(-120, wrapper.getNoiseFloor()); + EXPECT_EQ(0, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); EXPECT_EQ(0, stats.accepted_count); EXPECT_EQ(0, stats.rejected_low_bound_count); @@ -340,6 +340,22 @@ TEST(RssiNoiseFloor, LowBoundStartupSamplesDoNotContaminateLaterHealthyBatch) { EXPECT_EQ(0, stats.rejected_high_bound_count); } +TEST(RssiNoiseFloor, CompletedLowBoundBatchIsNotReportedAsAccepted) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.forceNoiseFloorStats(-120, 64, -121, -120, -119, 0, 0); + + EXPECT_EQ(0, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(0, stats.sample_min); + EXPECT_EQ(0, stats.sample_median); + EXPECT_EQ(0, stats.sample_max); +} + TEST(RssiNoiseFloor, ResetAGCPreservesPreviousFloorUntilValidBatchCompletes) { FakePhysicalLayer radio; FakeBoard board; From 10fab34a911b49597909a3988ac47fb10c85cd64 Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Thu, 25 Jun 2026 01:09:44 +0100 Subject: [PATCH 09/13] Rate limit noise floor calibration --- docs/cli_commands.md | 18 ++++++ examples/simple_repeater/MyMesh.cpp | 4 ++ examples/simple_repeater/MyMesh.h | 3 + examples/simple_room_server/MyMesh.cpp | 4 ++ examples/simple_room_server/MyMesh.h | 3 + examples/simple_sensor/SensorMesh.cpp | 4 ++ examples/simple_sensor/SensorMesh.h | 3 + src/Dispatcher.h | 5 ++ src/helpers/CommonCLI.cpp | 34 +++++++++- src/helpers/CommonCLI.h | 10 +++ src/helpers/radiolib/RadioLibWrappers.cpp | 44 +++++++++++++ src/helpers/radiolib/RadioLibWrappers.h | 17 ++++- .../test_packet_metrics.cpp | 63 ++++++++++++++++++- 13 files changed, 208 insertions(+), 4 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 820d4d3872..892cf29baa 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -161,6 +161,24 @@ Returns JSON with: --- +### Noise-floor calibration settings +**Usage:** `get noise.sample.ms` + +**Usage:** `set noise.sample.ms ` + +**Usage:** `get noise.window.secs` + +**Usage:** `set noise.window.secs ` + +**Serial Only:** No + +Controls the RSSI sampling cadence and maximum calibration attempt window used by noise-floor calibration. + +- `noise.sample.ms`: minimum delay between instantaneous RSSI samples. Range: `50`-`5000` ms. Default: `250`. +- `noise.window.secs`: maximum age of a partial calibration batch before it is discarded. Range: `1`-`600` seconds. Default: `30`. + +--- + ### Packet stats - Packet counters: Received, Sent **Usage:** `stats-packets` diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 096907494b..9464034735 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -893,6 +893,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max_unscoped = 64; _prefs.flood_max_advert = 8; _prefs.interference_threshold = 0; // disabled + _prefs.noise_sample_interval_ms = DEFAULT_NOISE_SAMPLE_INTERVAL_MS; + _prefs.noise_calib_window_secs = DEFAULT_NOISE_CALIB_WINDOW_SECS; // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -961,6 +963,8 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setTxPower(_prefs.tx_power_dbm); + radio_driver.setNoiseFloorCalibration(_prefs.noise_sample_interval_ms, + _prefs.noise_calib_window_secs * 1000U); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 7597c6c6f6..1aa825a195 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -217,6 +217,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void startRegionsLoad() override; bool saveRegions() override; void onDefaultRegionChanged(const RegionEntry* r) override; + void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint16_t max_calib_window_secs) override { + _radio->setNoiseFloorCalibration(sample_interval_ms, ((uint32_t)max_calib_window_secs) * 1000U); + } mesh::LocalIdentity& getSelfId() override { return self_id; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 98b22fdb72..cc3b6982d2 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -650,6 +650,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max_unscoped = 64; _prefs.flood_max_advert = 8; _prefs.interference_threshold = 0; // disabled + _prefs.noise_sample_interval_ms = DEFAULT_NOISE_SAMPLE_INTERVAL_MS; + _prefs.noise_calib_window_secs = DEFAULT_NOISE_CALIB_WINDOW_SECS; #ifdef ROOM_PASSWORD StrHelper::strncpy(_prefs.guest_password, ROOM_PASSWORD, sizeof(_prefs.guest_password)); #endif @@ -699,6 +701,8 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setTxPower(_prefs.tx_power_dbm); + radio_driver.setNoiseFloorCalibration(_prefs.noise_sample_interval_ms, + _prefs.noise_calib_window_secs * 1000U); updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 5277ddad61..7dd073b28d 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -213,6 +213,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void startRegionsLoad() override; bool saveRegions() override; void onDefaultRegionChanged(const RegionEntry* r) override; + void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint16_t max_calib_window_secs) override { + _radio->setNoiseFloorCalibration(sample_interval_ms, ((uint32_t)max_calib_window_secs) * 1000U); + } mesh::LocalIdentity& getSelfId() override { return self_id; } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 879fcbf026..0c5e817686 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -726,6 +726,8 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.disable_fwd = true; _prefs.flood_max = 64; _prefs.interference_threshold = 0; // disabled + _prefs.noise_sample_interval_ms = DEFAULT_NOISE_SAMPLE_INTERVAL_MS; + _prefs.noise_calib_window_secs = DEFAULT_NOISE_CALIB_WINDOW_SECS; // GPS defaults _prefs.gps_enabled = 0; @@ -765,6 +767,8 @@ void SensorMesh::begin(FILESYSTEM* fs) { } radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + radio_driver.setNoiseFloorCalibration(_prefs.noise_sample_interval_ms, + _prefs.noise_calib_window_secs * 1000U); radio_driver.setTxPower(_prefs.tx_power_dbm); updateAdvertTimer(); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index c9f135f65e..df0902dd99 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -74,6 +74,9 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; + void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint16_t max_calib_window_secs) override { + _radio->setNoiseFloorCalibration(sample_interval_ms, ((uint32_t)max_calib_window_secs) * 1000U); + } mesh::LocalIdentity& getSelfId() override { return self_id; } void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override { } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 4e498e6472..24c2bbc2a9 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -86,6 +86,11 @@ class Radio { virtual float getLastRSSI() const { return 0; } virtual float getLastSNR() const { return 0; } + + virtual void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint32_t max_calib_window_ms) { + (void)sample_interval_ms; + (void)max_calib_window_ms; + } }; /** diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b78ad6ebd6..9dbc4dceed 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -91,7 +91,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 file.read((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291 file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 - // next: 293 + file.read((uint8_t *)&_prefs->noise_sample_interval_ms, sizeof(_prefs->noise_sample_interval_ms)); // 293 + file.read((uint8_t *)&_prefs->noise_calib_window_secs, sizeof(_prefs->noise_calib_window_secs)); // 295 + // next: 297 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -121,6 +123,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // sanitise settings _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean + _prefs->noise_sample_interval_ms = constrain(_prefs->noise_sample_interval_ms, 50, 5000); + _prefs->noise_calib_window_secs = constrain(_prefs->noise_calib_window_secs, 1, 600); file.close(); } @@ -184,7 +188,9 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 file.write((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291 file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 - // next: 293 + file.write((uint8_t *)&_prefs->noise_sample_interval_ms, sizeof(_prefs->noise_sample_interval_ms)); // 293 + file.write((uint8_t *)&_prefs->noise_calib_window_secs, sizeof(_prefs->noise_calib_window_secs)); // 295 + // next: 297 file.close(); } @@ -504,6 +510,26 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->agc_reset_interval = atoi(&config[19]) / 4; savePrefs(); sprintf(reply, "OK - interval rounded to %d", ((uint32_t) _prefs->agc_reset_interval) * 4); + } else if (memcmp(config, "noise.sample.ms ", 16) == 0) { + uint32_t interval_ms = _atoi(&config[16]); + if (interval_ms >= 50 && interval_ms <= 5000) { + _prefs->noise_sample_interval_ms = (uint16_t)interval_ms; + _callbacks->setNoiseFloorCalibration(_prefs->noise_sample_interval_ms, _prefs->noise_calib_window_secs); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 50-5000 ms"); + } + } else if (memcmp(config, "noise.window.secs ", 18) == 0) { + uint32_t window_secs = _atoi(&config[18]); + if (window_secs >= 1 && window_secs <= 600) { + _prefs->noise_calib_window_secs = (uint16_t)window_secs; + _callbacks->setNoiseFloorCalibration(_prefs->noise_sample_interval_ms, _prefs->noise_calib_window_secs); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 1-600 seconds"); + } } else if (memcmp(config, "multi.acks ", 11) == 0) { _prefs->multi_acks = atoi(&config[11]); savePrefs(); @@ -778,6 +804,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold); } else if (memcmp(config, "agc.reset.interval", 18) == 0) { sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4); + } else if (memcmp(config, "noise.sample.ms", 15) == 0) { + sprintf(reply, "> %u", (uint32_t)_prefs->noise_sample_interval_ms); + } else if (memcmp(config, "noise.window.secs", 17) == 0) { + sprintf(reply, "> %u", (uint32_t)_prefs->noise_calib_window_secs); } else if (memcmp(config, "multi.acks", 10) == 0) { sprintf(reply, "> %d", (uint32_t) _prefs->multi_acks); } else if (memcmp(config, "allow.read.only", 15) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index b509c2b31a..b19a5c4dba 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -19,6 +19,9 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 +#define DEFAULT_NOISE_SAMPLE_INTERVAL_MS 250 +#define DEFAULT_NOISE_CALIB_WINDOW_SECS 30 + struct NodePrefs { // persisted to file float airtime_factor; char node_name[32]; @@ -63,6 +66,8 @@ struct NodePrefs { // persisted to file uint8_t rx_boosted_gain; // power settings uint8_t path_hash_mode; // which path mode to use when sending uint8_t loop_detect; + uint16_t noise_sample_interval_ms; + uint16_t noise_calib_window_secs; }; class CommonCLICallbacks { @@ -112,6 +117,11 @@ class CommonCLICallbacks { virtual void setRxBoostedGain(bool enable) { // no op by default }; + + virtual void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint16_t max_calib_window_secs) { + (void)sample_interval_ms; + (void)max_calib_window_secs; + }; }; class CommonCLI { diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 001e63e281..0e0b06c0ad 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -24,6 +24,10 @@ static bool isTrustedNoiseFloorValue(int16_t noise_floor) { return noise_floor != 0 && noise_floor > MIN_NOISE_FLOOR_SAMPLE; } +static bool elapsedAtLeast(unsigned long now, unsigned long started_at, uint32_t interval_ms) { + return (uint32_t)(now - started_at) >= interval_ms; +} + static uint16_t addStatCounter(uint16_t value, uint16_t increment) { return (uint16_t)mesh::cappedStatCounter((uint32_t)value + increment); } @@ -44,6 +48,10 @@ void RadioLibWrapper::resetNoiseFloorSamples() { _floor_sample_min = 0; _floor_sample_median = 0; _floor_sample_max = 0; + _noise_floor_batch_started_at = 0; + _last_noise_floor_sample_at = 0; + _noise_floor_batch_active = false; + _has_last_noise_floor_sample = false; } void RadioLibWrapper::resetNoiseFloorBatch() { @@ -95,6 +103,20 @@ void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { } } +unsigned long RadioLibWrapper::getMillis() const { +#if defined(ARDUINO) + return millis(); +#else + return 0; +#endif +} + +void RadioLibWrapper::setNoiseFloorCalibration(uint16_t sample_interval_ms, uint32_t max_calib_window_ms) { + _noise_floor_sample_interval_ms = sample_interval_ms; + _noise_floor_max_calib_window_ms = max_calib_window_ms; + resetNoiseFloorBatch(); +} + void RadioLibWrapper::doResetAGC() { _radio->sleep(); // warm sleep to reset analog frontend } @@ -115,6 +137,28 @@ void RadioLibWrapper::resetAGC() { void RadioLibWrapper::loop() { if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { + unsigned long now = getMillis(); + if (_noise_floor_batch_active && + _noise_floor_max_calib_window_ms > 0 && + elapsedAtLeast(now, _noise_floor_batch_started_at, _noise_floor_max_calib_window_ms)) { + // A calibration window that cannot complete is probably observing + // unstable receiver state or channel activity. Drop the partial + // window so old accepted samples cannot mix with later conditions. + resetNoiseFloorBatch(); + return; + } + if (_has_last_noise_floor_sample && + _noise_floor_sample_interval_ms > 0 && + !elapsedAtLeast(now, _last_noise_floor_sample_at, _noise_floor_sample_interval_ms)) { + return; + } + if (!_noise_floor_batch_active) { + _noise_floor_batch_started_at = now; + _noise_floor_batch_active = true; + } + _last_noise_floor_sample_at = now; + _has_last_noise_floor_sample = true; + int16_t rssi = (int16_t)getCurrentRSSI(); bool low_bound_sample = rssi <= MIN_NOISE_FLOOR_SAMPLE + LOW_BOUND_REJECT_MARGIN_DB; bool trusted_published_floor = isTrustedNoiseFloorValue(_noise_floor); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 4b0f35ecfa..3601718255 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -9,6 +9,8 @@ class RadioLibWrapper : public mesh::Radio { PhysicalLayer* _radio; mesh::MainBoard* _board; static constexpr uint16_t NUM_NOISE_FLOOR_SAMPLES = 64; + static constexpr uint16_t DEFAULT_NOISE_FLOOR_SAMPLE_INTERVAL_MS = 250; + static constexpr uint32_t DEFAULT_NOISE_FLOOR_MAX_CALIB_WINDOW_MS = 30000; uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; float _last_packet_rssi, _last_packet_snr; @@ -17,6 +19,12 @@ class RadioLibWrapper : public mesh::Radio { int16_t _floor_sample_min, _floor_sample_median, _floor_sample_max; uint16_t _floor_rejected_low_bound; uint16_t _floor_rejected_high_bound; + uint16_t _noise_floor_sample_interval_ms; + uint32_t _noise_floor_max_calib_window_ms; + unsigned long _noise_floor_batch_started_at; + unsigned long _last_noise_floor_sample_at; + bool _noise_floor_batch_active; + bool _has_last_noise_floor_sample; uint8_t _preamble_sf; void resetNoiseFloorSamples(); @@ -24,6 +32,7 @@ class RadioLibWrapper : public mesh::Radio { void idle(); void startRecv(); bool hasNoiseFloor() const; + virtual unsigned long getMillis() const; void updateLastPacketMetrics(float rssi, float snr) { _last_packet_rssi = rssi; _last_packet_snr = snr; @@ -38,7 +47,12 @@ class RadioLibWrapper : public mesh::Radio { _noise_floor(0), _threshold(0), _last_packet_rssi(0), _last_packet_snr(0), _num_floor_samples(0), _floor_sample_min(0), _floor_sample_median(0), _floor_sample_max(0), _floor_rejected_low_bound(0), - _floor_rejected_high_bound(0), _preamble_sf(0) { } + _floor_rejected_high_bound(0), + _noise_floor_sample_interval_ms(DEFAULT_NOISE_FLOOR_SAMPLE_INTERVAL_MS), + _noise_floor_max_calib_window_ms(DEFAULT_NOISE_FLOOR_MAX_CALIB_WINDOW_MS), + _noise_floor_batch_started_at(0), _last_noise_floor_sample_at(0), + _noise_floor_batch_active(false), _has_last_noise_floor_sample(false), + _preamble_sf(0) { } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -67,6 +81,7 @@ class RadioLibWrapper : public mesh::Radio { int getNoiseFloor() const override; mesh::NoiseFloorStats getNoiseFloorStats() const override; + void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint32_t max_calib_window_ms) override; void triggerNoiseFloorCalibrate(int threshold) override; void resetAGC() override; diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index 31d88ebe07..288f855e6c 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -27,10 +27,13 @@ class FakePhysicalLayer : public PhysicalLayer { class TestRadioLibWrapper : public RadioLibWrapper { public: - TestRadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + TestRadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { + setNoiseFloorCalibration(0, 30000); + } std::vector current_rssi_samples; size_t current_rssi_index = 0; + unsigned long current_millis = 0; bool receiving_packet = false; void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { @@ -49,6 +52,12 @@ class TestRadioLibWrapper : public RadioLibWrapper { bool isReceivingPacket() override { return receiving_packet; } + unsigned long getMillis() const override { return current_millis; } + + void advanceMillis(unsigned long millis) { + current_millis += millis; + } + void cachePacketMetrics(float rssi, float snr) { updateLastPacketMetrics(rssi, snr); } @@ -399,6 +408,58 @@ TEST(RssiNoiseFloor, ReceivingPacketSkipsNoiseFloorSampling) { EXPECT_EQ(-101, wrapper.getNoiseFloor()); } +TEST(RssiNoiseFloor, SamplingIsRateLimited) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setNoiseFloorCalibration(250, 30000); + wrapper.setCurrentRssiSamples(std::vector(64, -101.0f)); + wrapper.enterReceiveMode(); + + wrapper.collectNoiseFloorSamples(10); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(1, stats.accepted_count); + + wrapper.advanceMillis(249); + wrapper.collectNoiseFloorSamples(10); + stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(1, stats.accepted_count); + + wrapper.advanceMillis(1); + wrapper.collectNoiseFloorSamples(10); + stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(2, stats.accepted_count); +} + +TEST(RssiNoiseFloor, CalibrationWindowDropsStalePartialBatch) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setNoiseFloorCalibration(250, 1000); + wrapper.setCurrentRssiSamples(std::vector(64, -101.0f)); + wrapper.enterReceiveMode(); + + wrapper.collectNoiseFloorSamples(); + wrapper.advanceMillis(250); + wrapper.collectNoiseFloorSamples(); + + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(2, stats.accepted_count); + + wrapper.advanceMillis(750); + wrapper.collectNoiseFloorSamples(1); + + stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(0, stats.sample_min); + EXPECT_EQ(0, stats.sample_median); + EXPECT_EQ(0, stats.sample_max); +} + TEST(RssiNoiseFloor, RadioStatsExposeCalibrationDiagnostics) { FakePhysicalLayer radio; FakeBoard board; From 4c45a4e432c799281ef473731eccaecab8f1826a Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Thu, 25 Jun 2026 01:34:17 +0100 Subject: [PATCH 10/13] Tune noise floor calibration for low RSSI --- docs/cli_commands.md | 8 +- src/helpers/CommonCLI.h | 4 +- src/helpers/radiolib/RadioLibWrappers.cpp | 34 +++---- src/helpers/radiolib/RadioLibWrappers.h | 4 +- .../test_packet_metrics.cpp | 94 ++++++++++++------- 5 files changed, 83 insertions(+), 61 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 892cf29baa..99b07f05a5 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -155,8 +155,8 @@ Returns JSON with: - `tx_air_secs`: accumulated transmit airtime in seconds - `rx_air_secs`: accumulated receive airtime estimate in seconds - `noise_floor_sample_count`: RSSI samples accepted into the current or most recent calibration batch -- `noise_floor_sample_min` / `noise_floor_sample_median` / `noise_floor_sample_max`: accepted RSSI sample range in dBm -- `noise_floor_rejected_low_bound`: low-bound RSSI samples rejected because they would cause a suspicious downward jump +- `noise_floor_sample_min` / `noise_floor_sample_median` / `noise_floor_sample_max`: accepted RSSI sample range in dBm. `noise_floor` is estimated from the lower quartile of the accepted batch. +- `noise_floor_rejected_low_bound`: RSSI samples rejected because they would cause a suspicious downward jump - `noise_floor_rejected_high_bound`: strong RSSI samples rejected because they look like channel activity rather than idle noise --- @@ -174,8 +174,8 @@ Returns JSON with: Controls the RSSI sampling cadence and maximum calibration attempt window used by noise-floor calibration. -- `noise.sample.ms`: minimum delay between instantaneous RSSI samples. Range: `50`-`5000` ms. Default: `250`. -- `noise.window.secs`: maximum age of a partial calibration batch before it is discarded. Range: `1`-`600` seconds. Default: `30`. +- `noise.sample.ms`: minimum delay between instantaneous RSSI samples. Range: `50`-`5000` ms. Default: `50`. +- `noise.window.secs`: maximum age of a partial calibration batch before it is discarded. Range: `1`-`600` seconds. Default: `60`. --- diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index b19a5c4dba..783a75d9b2 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -19,8 +19,8 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 -#define DEFAULT_NOISE_SAMPLE_INTERVAL_MS 250 -#define DEFAULT_NOISE_CALIB_WINDOW_SECS 30 +#define DEFAULT_NOISE_SAMPLE_INTERVAL_MS 50 +#define DEFAULT_NOISE_CALIB_WINDOW_SECS 60 struct NodePrefs { // persisted to file float airtime_factor; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 0e0b06c0ad..1a104c0775 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -12,16 +12,15 @@ #define RSSI_CARRIER_SENSE_SAMPLES 5 #define RSSI_CARRIER_SENSE_REQUIRED 3 -#define MIN_NOISE_FLOOR_SAMPLE -120 +#define MIN_NOISE_FLOOR_SAMPLE -130 #define MAX_NOISE_FLOOR_SAMPLE -80 -#define LOW_BOUND_REJECT_MARGIN_DB 1 #define LOW_BOUND_REJECT_JUMP_DB 14 #define HIGH_BOUND_REJECT_JUMP_DB 14 static volatile uint8_t state = STATE_IDLE; static bool isTrustedNoiseFloorValue(int16_t noise_floor) { - return noise_floor != 0 && noise_floor > MIN_NOISE_FLOOR_SAMPLE; + return noise_floor != 0; } static bool elapsedAtLeast(unsigned long now, unsigned long started_at, uint32_t interval_ms) { @@ -160,7 +159,9 @@ void RadioLibWrapper::loop() { _has_last_noise_floor_sample = true; int16_t rssi = (int16_t)getCurrentRSSI(); - bool low_bound_sample = rssi <= MIN_NOISE_FLOOR_SAMPLE + LOW_BOUND_REJECT_MARGIN_DB; + if (rssi < MIN_NOISE_FLOOR_SAMPLE) { + rssi = MIN_NOISE_FLOOR_SAMPLE; + } bool trusted_published_floor = isTrustedNoiseFloorValue(_noise_floor); bool no_trusted_floor = !trusted_published_floor; bool healthy_floor_would_jump_down = trusted_published_floor && @@ -169,11 +170,11 @@ void RadioLibWrapper::loop() { bool healthy_floor_would_jump_up = trusted_published_floor && (rssi - _noise_floor) >= HIGH_BOUND_REJECT_JUMP_DB; - // SX126x RSSI readings can sit on the receiver's low reporting bound - // during startup or after an AGC reset. Those readings are not useful - // calibration input: publishing them traps the floor at -120 dBm until - // healthier samples are allowed back in. - if (low_bound_sample && (no_trusted_floor || healthy_floor_would_jump_down)) { + // Once a plausible floor exists, sudden large jumps are more likely to + // be AGC/channel artefacts than a real idle-floor change. Without a + // trusted floor, low RSSI samples are allowed because analyser readings + // around -118 dBm, with occasional lower samples, are expected. + if (healthy_floor_would_jump_down) { _floor_rejected_low_bound = mesh::incrementStatCounter(_floor_rejected_low_bound); return; } @@ -203,19 +204,18 @@ void RadioLibWrapper::loop() { std::sort(_floor_samples, _floor_samples + NUM_NOISE_FLOOR_SAMPLES); _floor_sample_median = ((int32_t)_floor_samples[(NUM_NOISE_FLOOR_SAMPLES / 2) - 1] + _floor_samples[NUM_NOISE_FLOOR_SAMPLES / 2]) / 2; - bool completed_low_bound_batch = - _floor_sample_median <= MIN_NOISE_FLOOR_SAMPLE + LOW_BOUND_REJECT_MARGIN_DB; + int16_t floor_estimate = _floor_samples[NUM_NOISE_FLOOR_SAMPLES / 4]; bool completed_high_activity_batch = - no_trusted_floor && _floor_sample_median >= MAX_NOISE_FLOOR_SAMPLE; + no_trusted_floor && floor_estimate >= MAX_NOISE_FLOOR_SAMPLE; bool completed_batch_would_jump_down = trusted_published_floor && - (_noise_floor - _floor_sample_median) >= LOW_BOUND_REJECT_JUMP_DB; + (_noise_floor - floor_estimate) >= LOW_BOUND_REJECT_JUMP_DB; bool completed_batch_would_jump_up = trusted_published_floor && - (_floor_sample_median - _noise_floor) >= HIGH_BOUND_REJECT_JUMP_DB; + (floor_estimate - _noise_floor) >= HIGH_BOUND_REJECT_JUMP_DB; // The per-sample gates should normally stop invalid batches before // they complete. Keep the publish path defensive as well, so a stale - // or boundary-filled batch cannot re-publish -120 dBm as a real floor. - if (completed_low_bound_batch || completed_batch_would_jump_down) { + // or activity-filled batch cannot make the published floor jump. + if (completed_batch_would_jump_down) { _floor_rejected_low_bound = addStatCounter(_floor_rejected_low_bound, _num_floor_samples); resetNoiseFloorSamples(); return; @@ -226,7 +226,7 @@ void RadioLibWrapper::loop() { return; } - _noise_floor = _floor_sample_median; + _noise_floor = floor_estimate; MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor=%d accepted=%u min=%d median=%d max=%d rejected_low=%u rejected_high=%u", (int)_noise_floor, diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 3601718255..3c3416a2bd 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -9,8 +9,8 @@ class RadioLibWrapper : public mesh::Radio { PhysicalLayer* _radio; mesh::MainBoard* _board; static constexpr uint16_t NUM_NOISE_FLOOR_SAMPLES = 64; - static constexpr uint16_t DEFAULT_NOISE_FLOOR_SAMPLE_INTERVAL_MS = 250; - static constexpr uint32_t DEFAULT_NOISE_FLOOR_MAX_CALIB_WINDOW_MS = 30000; + static constexpr uint16_t DEFAULT_NOISE_FLOOR_SAMPLE_INTERVAL_MS = 50; + static constexpr uint32_t DEFAULT_NOISE_FLOOR_MAX_CALIB_WINDOW_MS = 60000; uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; float _last_packet_rssi, _last_packet_snr; diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index 288f855e6c..8d23dc7838 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -121,7 +121,7 @@ TEST(RssiPacketMetrics, NewPacketMetricsReplacePreviousPacketMetrics) { EXPECT_EQ(-3.5f, wrapper.getLastSNR()); } -TEST(RssiNoiseFloor, LowStartupOutliersDoNotDominateMedianFloor) { +TEST(RssiNoiseFloor, LowStartupSamplesDoNotDominateLowerQuartileFloor) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); @@ -138,13 +138,13 @@ TEST(RssiNoiseFloor, LowStartupOutliersDoNotDominateMedianFloor) { EXPECT_EQ(-103, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); EXPECT_EQ(64, stats.accepted_count); - EXPECT_EQ(-103, stats.sample_min); + EXPECT_EQ(-130, stats.sample_min); EXPECT_EQ(-103, stats.sample_median); EXPECT_EQ(-103, stats.sample_max); - EXPECT_EQ(16, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_low_bound_count); } -TEST(RssiNoiseFloor, ClampedLowFloorDoesNotRejectLaterHealthySamples) { +TEST(RssiNoiseFloor, LowFloorDoesNotRejectLaterHealthySamples) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); @@ -152,14 +152,14 @@ TEST(RssiNoiseFloor, ClampedLowFloorDoesNotRejectLaterHealthySamples) { wrapper.begin(); wrapper.forceNoiseFloor(-120); wrapper.triggerNoiseFloorCalibrate(0); - wrapper.setCurrentRssiSamples(std::vector(64, -102.0f)); + wrapper.setCurrentRssiSamples(std::vector(64, -112.0f)); wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(); - EXPECT_EQ(-102, wrapper.getNoiseFloor()); + EXPECT_EQ(-112, wrapper.getNoiseFloor()); } -TEST(RssiNoiseFloor, ClampedLowFloorStillRejectsLaterLowBoundSamples) { +TEST(RssiNoiseFloor, LowFloorAcceptsLaterAnalyserPlausibleLowSamples) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); @@ -171,14 +171,14 @@ TEST(RssiNoiseFloor, ClampedLowFloorStillRejectsLaterLowBoundSamples) { wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(); - EXPECT_EQ(0, wrapper.getNoiseFloor()); + EXPECT_EQ(-120, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); - EXPECT_EQ(0, stats.accepted_count); - EXPECT_EQ(64, stats.rejected_low_bound_count); + EXPECT_EQ(64, stats.accepted_count); + EXPECT_EQ(0, stats.rejected_low_bound_count); EXPECT_EQ(0, stats.rejected_high_bound_count); } -TEST(RssiNoiseFloor, ClampedLowFloorStillRejectsStrongSamples) { +TEST(RssiNoiseFloor, LowFloorStillRejectsLargeUpwardJumps) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); @@ -190,30 +190,30 @@ TEST(RssiNoiseFloor, ClampedLowFloorStillRejectsStrongSamples) { wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(); - EXPECT_EQ(0, wrapper.getNoiseFloor()); + EXPECT_EQ(-120, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); EXPECT_EQ(0, stats.accepted_count); EXPECT_EQ(0, stats.rejected_low_bound_count); EXPECT_EQ(64, stats.rejected_high_bound_count); } -TEST(RssiNoiseFloor, VeryLowSamplesDoNotPublishWhenNoPreviousFloorExists) { +TEST(RssiNoiseFloor, VeryLowSamplesAreClampedAndCanPublishWhenNoPreviousFloorExists) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); wrapper.begin(); - wrapper.setCurrentRssiSamples(std::vector(64, -130.0f)); + wrapper.setCurrentRssiSamples(std::vector(64, -140.0f)); wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(); - EXPECT_EQ(0, wrapper.getNoiseFloor()); + EXPECT_EQ(-130, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); - EXPECT_EQ(0, stats.accepted_count); - EXPECT_EQ(0, stats.sample_min); - EXPECT_EQ(0, stats.sample_median); - EXPECT_EQ(0, stats.sample_max); - EXPECT_EQ(64, stats.rejected_low_bound_count); + EXPECT_EQ(64, stats.accepted_count); + EXPECT_EQ(-130, stats.sample_min); + EXPECT_EQ(-130, stats.sample_median); + EXPECT_EQ(-130, stats.sample_max); + EXPECT_EQ(0, stats.rejected_low_bound_count); EXPECT_EQ(0, stats.rejected_high_bound_count); } @@ -223,6 +223,8 @@ TEST(RssiNoiseFloor, LowBoundRejectCountSaturates) { TestRadioLibWrapper wrapper(radio, board); wrapper.begin(); + wrapper.forceNoiseFloor(-100); + wrapper.triggerNoiseFloorCalibrate(0); wrapper.setCurrentRssiSamples(std::vector(70000, -130.0f)); wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(70000); @@ -239,6 +241,8 @@ TEST(RssiNoiseFloor, RejectedOnlyBatchResetsOnCalibrationTrigger) { TestRadioLibWrapper wrapper(radio, board); wrapper.begin(); + wrapper.forceNoiseFloor(-100); + wrapper.triggerNoiseFloorCalibrate(0); wrapper.setCurrentRssiSamples(std::vector(12, -130.0f)); wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(12); @@ -325,31 +329,49 @@ TEST(RssiNoiseFloor, RejectedHighOnlyBatchResetsOnCalibrationTrigger) { EXPECT_EQ(0, stats.rejected_high_bound_count); } -TEST(RssiNoiseFloor, LowBoundStartupSamplesDoNotContaminateLaterHealthyBatch) { +TEST(RssiNoiseFloor, LowBoundStartupSamplesCanPublishAnalyserPlausibleFloor) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(64, -120.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-120, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(64, stats.accepted_count); + EXPECT_EQ(-120, stats.sample_min); + EXPECT_EQ(-120, stats.sample_median); + EXPECT_EQ(-120, stats.sample_max); + EXPECT_EQ(0, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_high_bound_count); +} + +TEST(RssiNoiseFloor, LowerQuartileResistsIntermittentHighRssi) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); std::vector samples; - samples.insert(samples.end(), 64, -120.0f); - samples.insert(samples.end(), 64, -102.0f); + samples.insert(samples.end(), 20, -118.0f); + samples.insert(samples.end(), 44, -104.0f); wrapper.begin(); wrapper.setCurrentRssiSamples(samples); wrapper.enterReceiveMode(); - wrapper.collectNoiseFloorSamples(128); + wrapper.collectNoiseFloorSamples(); - EXPECT_EQ(-102, wrapper.getNoiseFloor()); + EXPECT_EQ(-118, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); EXPECT_EQ(64, stats.accepted_count); - EXPECT_EQ(-102, stats.sample_min); - EXPECT_EQ(-102, stats.sample_median); - EXPECT_EQ(-102, stats.sample_max); - EXPECT_EQ(64, stats.rejected_low_bound_count); - EXPECT_EQ(0, stats.rejected_high_bound_count); + EXPECT_EQ(-118, stats.sample_min); + EXPECT_EQ(-104, stats.sample_median); + EXPECT_EQ(-104, stats.sample_max); } -TEST(RssiNoiseFloor, CompletedLowBoundBatchIsNotReportedAsAccepted) { +TEST(RssiNoiseFloor, CompletedLowBoundBatchIsReportedAsAccepted) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); @@ -357,12 +379,12 @@ TEST(RssiNoiseFloor, CompletedLowBoundBatchIsNotReportedAsAccepted) { wrapper.begin(); wrapper.forceNoiseFloorStats(-120, 64, -121, -120, -119, 0, 0); - EXPECT_EQ(0, wrapper.getNoiseFloor()); + EXPECT_EQ(-120, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); - EXPECT_EQ(0, stats.accepted_count); - EXPECT_EQ(0, stats.sample_min); - EXPECT_EQ(0, stats.sample_median); - EXPECT_EQ(0, stats.sample_max); + EXPECT_EQ(64, stats.accepted_count); + EXPECT_EQ(-121, stats.sample_min); + EXPECT_EQ(-120, stats.sample_median); + EXPECT_EQ(-119, stats.sample_max); } TEST(RssiNoiseFloor, ResetAGCPreservesPreviousFloorUntilValidBatchCompletes) { From 3b70e5e666bbd59ef6b4263a179fdec2689e491d Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Thu, 25 Jun 2026 10:42:33 +0100 Subject: [PATCH 11/13] Configure noise floor clamps and refresh sampling --- docs/cli_commands.md | 12 ++ examples/simple_repeater/MyMesh.cpp | 4 + examples/simple_repeater/MyMesh.h | 3 + examples/simple_room_server/MyMesh.cpp | 4 + examples/simple_room_server/MyMesh.h | 3 + examples/simple_sensor/SensorMesh.cpp | 4 + examples/simple_sensor/SensorMesh.h | 3 + src/Dispatcher.cpp | 9 +- src/Dispatcher.h | 11 ++ src/helpers/CommonCLI.cpp | 42 ++++++- src/helpers/CommonCLI.h | 14 +++ src/helpers/radiolib/RadioLibWrappers.cpp | 65 ++++++++-- src/helpers/radiolib/RadioLibWrappers.h | 12 +- .../test_packet_metrics.cpp | 112 +++++++++++++++++- 14 files changed, 277 insertions(+), 21 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 99b07f05a5..aa08b3773e 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -170,12 +170,24 @@ Returns JSON with: **Usage:** `set noise.window.secs ` +**Usage:** `get noise.clamp.low` + +**Usage:** `set noise.clamp.low ` + +**Usage:** `get noise.clamp.high` + +**Usage:** `set noise.clamp.high ` + **Serial Only:** No Controls the RSSI sampling cadence and maximum calibration attempt window used by noise-floor calibration. - `noise.sample.ms`: minimum delay between instantaneous RSSI samples. Range: `50`-`5000` ms. Default: `50`. - `noise.window.secs`: maximum age of a partial calibration batch before it is discarded. Range: `1`-`600` seconds. Default: `60`. +- `noise.clamp.low`: lowest RSSI value accepted into calibration after clamping. Range: `-150` to `-80` dBm. Default: `-125`. +- `noise.clamp.high`: RSSI level treated as channel activity rather than idle noise. Range: `-120` to `-40` dBm. Default: `-80`. + +`noise.clamp.low` must be lower than `noise.clamp.high`. --- diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 9464034735..788fb46ebd 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -895,6 +895,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.interference_threshold = 0; // disabled _prefs.noise_sample_interval_ms = DEFAULT_NOISE_SAMPLE_INTERVAL_MS; _prefs.noise_calib_window_secs = DEFAULT_NOISE_CALIB_WINDOW_SECS; + _prefs.noise_clamp_low_dbm = DEFAULT_NOISE_CLAMP_LOW_DBM; + _prefs.noise_clamp_high_dbm = DEFAULT_NOISE_CLAMP_HIGH_DBM; // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -965,6 +967,8 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setNoiseFloorCalibration(_prefs.noise_sample_interval_ms, _prefs.noise_calib_window_secs * 1000U); + radio_driver.setNoiseFloorClamps(_prefs.noise_clamp_low_dbm, + _prefs.noise_clamp_high_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 1aa825a195..60593961e5 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -220,6 +220,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint16_t max_calib_window_secs) override { _radio->setNoiseFloorCalibration(sample_interval_ms, ((uint32_t)max_calib_window_secs) * 1000U); } + void setNoiseFloorClamps(int16_t low_bound, int16_t high_bound) override { + _radio->setNoiseFloorClamps(low_bound, high_bound); + } mesh::LocalIdentity& getSelfId() override { return self_id; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index cc3b6982d2..9e9204cda8 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -652,6 +652,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.interference_threshold = 0; // disabled _prefs.noise_sample_interval_ms = DEFAULT_NOISE_SAMPLE_INTERVAL_MS; _prefs.noise_calib_window_secs = DEFAULT_NOISE_CALIB_WINDOW_SECS; + _prefs.noise_clamp_low_dbm = DEFAULT_NOISE_CLAMP_LOW_DBM; + _prefs.noise_clamp_high_dbm = DEFAULT_NOISE_CLAMP_HIGH_DBM; #ifdef ROOM_PASSWORD StrHelper::strncpy(_prefs.guest_password, ROOM_PASSWORD, sizeof(_prefs.guest_password)); #endif @@ -703,6 +705,8 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setNoiseFloorCalibration(_prefs.noise_sample_interval_ms, _prefs.noise_calib_window_secs * 1000U); + radio_driver.setNoiseFloorClamps(_prefs.noise_clamp_low_dbm, + _prefs.noise_clamp_high_dbm); updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 7dd073b28d..6a496fbe35 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -216,6 +216,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint16_t max_calib_window_secs) override { _radio->setNoiseFloorCalibration(sample_interval_ms, ((uint32_t)max_calib_window_secs) * 1000U); } + void setNoiseFloorClamps(int16_t low_bound, int16_t high_bound) override { + _radio->setNoiseFloorClamps(low_bound, high_bound); + } mesh::LocalIdentity& getSelfId() override { return self_id; } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 0c5e817686..1a8ffd2dff 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -728,6 +728,8 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.interference_threshold = 0; // disabled _prefs.noise_sample_interval_ms = DEFAULT_NOISE_SAMPLE_INTERVAL_MS; _prefs.noise_calib_window_secs = DEFAULT_NOISE_CALIB_WINDOW_SECS; + _prefs.noise_clamp_low_dbm = DEFAULT_NOISE_CLAMP_LOW_DBM; + _prefs.noise_clamp_high_dbm = DEFAULT_NOISE_CLAMP_HIGH_DBM; // GPS defaults _prefs.gps_enabled = 0; @@ -769,6 +771,8 @@ void SensorMesh::begin(FILESYSTEM* fs) { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setNoiseFloorCalibration(_prefs.noise_sample_interval_ms, _prefs.noise_calib_window_secs * 1000U); + radio_driver.setNoiseFloorClamps(_prefs.noise_clamp_low_dbm, + _prefs.noise_clamp_high_dbm); radio_driver.setTxPower(_prefs.tx_power_dbm); updateAdvertTimer(); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index df0902dd99..053f3b322b 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -77,6 +77,9 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint16_t max_calib_window_secs) override { _radio->setNoiseFloorCalibration(sample_interval_ms, ((uint32_t)max_calib_window_secs) * 1000U); } + void setNoiseFloorClamps(int16_t low_bound, int16_t high_bound) override { + _radio->setNoiseFloorClamps(low_bound, high_bound); + } mesh::LocalIdentity& getSelfId() override { return self_id; } void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override { } diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 9d7a11131d..ce8f72f453 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -52,6 +52,10 @@ void Dispatcher::updateTxBudget() { } } +void Dispatcher::scheduleNoiseFloorRefreshAfterRadioAnomaly() { + _radio->scheduleNoiseFloorCalibration(DEFAULT_NOISE_FLOOR_SETTLE_MS); +} + int Dispatcher::calcRxDelay(float score, uint32_t air_time) const { return (int) ((pow(10, 0.85f - score) - 1.0) * air_time); } @@ -117,6 +121,7 @@ void Dispatcher::loop() { MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): WARNING: outbound packed send timed out!", getLogDateTime()); _radio->onSendFinished(); + scheduleNoiseFloorRefreshAfterRadioAnomaly(); logTxFail(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); releasePacket(outbound); // return to pool @@ -294,6 +299,7 @@ void Dispatcher::checkSend() { _err_flags |= ERR_EVENT_CAD_TIMEOUT; MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): CAD busy max duration reached!", getLogDateTime()); + scheduleNoiseFloorRefreshAfterRadioAnomaly(); // channel activity has gone on too long... (Radio might be in a bad state) // force the pending transmit below... } else { @@ -329,6 +335,7 @@ void Dispatcher::checkSend() { if (!success) { MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): ERROR: send start failed!", getLogDateTime()); + scheduleNoiseFloorRefreshAfterRadioAnomaly(); logTxFail(outbound, outbound->getRawLength()); releasePacket(outbound); // return to pool @@ -386,4 +393,4 @@ unsigned long Dispatcher::futureMillis(int millis_from_now) const { return _ms->getMillis() + millis_from_now; } -} \ No newline at end of file +} diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 24c2bbc2a9..feebb8fc1d 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -91,6 +91,15 @@ class Radio { (void)sample_interval_ms; (void)max_calib_window_ms; } + + virtual void setNoiseFloorClamps(int16_t low_bound, int16_t high_bound) { + (void)low_bound; + (void)high_bound; + } + + virtual void scheduleNoiseFloorCalibration(uint32_t settle_ms) { + (void)settle_ms; + } }; /** @@ -124,6 +133,7 @@ typedef uint32_t DispatcherAction; #define ERR_EVENT_CAD_TIMEOUT (1 << 1) #define ERR_EVENT_STARTRX_TIMEOUT (1 << 2) +#define DEFAULT_NOISE_FLOOR_SETTLE_MS 500 /** * \brief The low-level task that manages detecting incoming Packets, and the queueing * and scheduling of outbound Packets. @@ -144,6 +154,7 @@ class Dispatcher { void processRecvPacket(Packet* pkt); void updateTxBudget(); + void scheduleNoiseFloorRefreshAfterRadioAnomaly(); protected: PacketManager* _mgr; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 9dbc4dceed..ec4e7504a0 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -93,7 +93,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.read((uint8_t *)&_prefs->noise_sample_interval_ms, sizeof(_prefs->noise_sample_interval_ms)); // 293 file.read((uint8_t *)&_prefs->noise_calib_window_secs, sizeof(_prefs->noise_calib_window_secs)); // 295 - // next: 297 + file.read((uint8_t *)&_prefs->noise_clamp_low_dbm, sizeof(_prefs->noise_clamp_low_dbm)); // 297 + file.read((uint8_t *)&_prefs->noise_clamp_high_dbm, sizeof(_prefs->noise_clamp_high_dbm)); // 299 + // next: 301 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -125,6 +127,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean _prefs->noise_sample_interval_ms = constrain(_prefs->noise_sample_interval_ms, 50, 5000); _prefs->noise_calib_window_secs = constrain(_prefs->noise_calib_window_secs, 1, 600); + _prefs->noise_clamp_low_dbm = constrain(_prefs->noise_clamp_low_dbm, MIN_NOISE_CLAMP_LOW_DBM, MAX_NOISE_CLAMP_LOW_DBM); + _prefs->noise_clamp_high_dbm = constrain(_prefs->noise_clamp_high_dbm, MIN_NOISE_CLAMP_HIGH_DBM, MAX_NOISE_CLAMP_HIGH_DBM); + if (_prefs->noise_clamp_low_dbm >= _prefs->noise_clamp_high_dbm) { + _prefs->noise_clamp_low_dbm = DEFAULT_NOISE_CLAMP_LOW_DBM; + _prefs->noise_clamp_high_dbm = DEFAULT_NOISE_CLAMP_HIGH_DBM; + } file.close(); } @@ -190,7 +198,9 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.write((uint8_t *)&_prefs->noise_sample_interval_ms, sizeof(_prefs->noise_sample_interval_ms)); // 293 file.write((uint8_t *)&_prefs->noise_calib_window_secs, sizeof(_prefs->noise_calib_window_secs)); // 295 - // next: 297 + file.write((uint8_t *)&_prefs->noise_clamp_low_dbm, sizeof(_prefs->noise_clamp_low_dbm)); // 297 + file.write((uint8_t *)&_prefs->noise_clamp_high_dbm, sizeof(_prefs->noise_clamp_high_dbm)); // 299 + // next: 301 file.close(); } @@ -530,6 +540,30 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error, must be 1-600 seconds"); } + } else if (memcmp(config, "noise.clamp.low ", 16) == 0) { + int clamp_low = atoi(&config[16]); + if (clamp_low < MIN_NOISE_CLAMP_LOW_DBM || clamp_low > MAX_NOISE_CLAMP_LOW_DBM) { + strcpy(reply, "Error, must be -150 to -80 dBm"); + } else if (clamp_low >= _prefs->noise_clamp_high_dbm) { + strcpy(reply, "Error, must be below noise.clamp.high"); + } else { + _prefs->noise_clamp_low_dbm = (int16_t)clamp_low; + _callbacks->setNoiseFloorClamps(_prefs->noise_clamp_low_dbm, _prefs->noise_clamp_high_dbm); + savePrefs(); + strcpy(reply, "OK"); + } + } else if (memcmp(config, "noise.clamp.high ", 17) == 0) { + int clamp_high = atoi(&config[17]); + if (clamp_high < MIN_NOISE_CLAMP_HIGH_DBM || clamp_high > MAX_NOISE_CLAMP_HIGH_DBM) { + strcpy(reply, "Error, must be -120 to -40 dBm"); + } else if (_prefs->noise_clamp_low_dbm >= clamp_high) { + strcpy(reply, "Error, must be above noise.clamp.low"); + } else { + _prefs->noise_clamp_high_dbm = (int16_t)clamp_high; + _callbacks->setNoiseFloorClamps(_prefs->noise_clamp_low_dbm, _prefs->noise_clamp_high_dbm); + savePrefs(); + strcpy(reply, "OK"); + } } else if (memcmp(config, "multi.acks ", 11) == 0) { _prefs->multi_acks = atoi(&config[11]); savePrefs(); @@ -808,6 +842,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %u", (uint32_t)_prefs->noise_sample_interval_ms); } else if (memcmp(config, "noise.window.secs", 17) == 0) { sprintf(reply, "> %u", (uint32_t)_prefs->noise_calib_window_secs); + } else if (memcmp(config, "noise.clamp.low", 15) == 0) { + sprintf(reply, "> %d", (int)_prefs->noise_clamp_low_dbm); + } else if (memcmp(config, "noise.clamp.high", 16) == 0) { + sprintf(reply, "> %d", (int)_prefs->noise_clamp_high_dbm); } else if (memcmp(config, "multi.acks", 10) == 0) { sprintf(reply, "> %d", (uint32_t) _prefs->multi_acks); } else if (memcmp(config, "allow.read.only", 15) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 783a75d9b2..32621f46ce 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -21,6 +21,13 @@ #define DEFAULT_NOISE_SAMPLE_INTERVAL_MS 50 #define DEFAULT_NOISE_CALIB_WINDOW_SECS 60 +#define DEFAULT_NOISE_CLAMP_LOW_DBM -125 +#define DEFAULT_NOISE_CLAMP_HIGH_DBM -80 + +#define MIN_NOISE_CLAMP_LOW_DBM -150 +#define MAX_NOISE_CLAMP_LOW_DBM -80 +#define MIN_NOISE_CLAMP_HIGH_DBM -120 +#define MAX_NOISE_CLAMP_HIGH_DBM -40 struct NodePrefs { // persisted to file float airtime_factor; @@ -68,6 +75,8 @@ struct NodePrefs { // persisted to file uint8_t loop_detect; uint16_t noise_sample_interval_ms; uint16_t noise_calib_window_secs; + int16_t noise_clamp_low_dbm; + int16_t noise_clamp_high_dbm; }; class CommonCLICallbacks { @@ -122,6 +131,11 @@ class CommonCLICallbacks { (void)sample_interval_ms; (void)max_calib_window_secs; }; + + virtual void setNoiseFloorClamps(int16_t low_bound, int16_t high_bound) { + (void)low_bound; + (void)high_bound; + }; }; class CommonCLI { diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 1a104c0775..1274ed3f8c 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -12,8 +12,6 @@ #define RSSI_CARRIER_SENSE_SAMPLES 5 #define RSSI_CARRIER_SENSE_REQUIRED 3 -#define MIN_NOISE_FLOOR_SAMPLE -130 -#define MAX_NOISE_FLOOR_SAMPLE -80 #define LOW_BOUND_REJECT_JUMP_DB 14 #define HIGH_BOUND_REJECT_JUMP_DB 14 @@ -27,8 +25,31 @@ static bool elapsedAtLeast(unsigned long now, unsigned long started_at, uint32_t return (uint32_t)(now - started_at) >= interval_ms; } +static bool millisReached(unsigned long now, unsigned long timestamp) { + return (long)(now - timestamp) >= 0; +} + +static uint16_t incrementNoiseFloorCounter(uint16_t value) { + return value < UINT16_MAX ? value + 1 : UINT16_MAX; +} + static uint16_t addStatCounter(uint16_t value, uint16_t increment) { - return (uint16_t)mesh::cappedStatCounter((uint32_t)value + increment); + uint32_t next = (uint32_t)value + increment; + return next < UINT16_MAX ? (uint16_t)next : UINT16_MAX; +} + +static int16_t medianOfSamples(const int16_t samples[], uint16_t count) { + int16_t sorted[64]; + for (uint16_t i = 0; i < count; i++) { + sorted[i] = samples[i]; + } + std::sort(sorted, sorted + count); + + uint16_t midpoint = count / 2; + if ((count & 1) != 0) { + return sorted[midpoint]; + } + return ((int32_t)sorted[midpoint - 1] + sorted[midpoint]) / 2; } // this function is called when a complete packet @@ -55,6 +76,7 @@ void RadioLibWrapper::resetNoiseFloorSamples() { void RadioLibWrapper::resetNoiseFloorBatch() { resetNoiseFloorSamples(); + _noise_floor_calibration_scheduled_at = 0; _floor_rejected_low_bound = 0; _floor_rejected_high_bound = 0; } @@ -116,6 +138,20 @@ void RadioLibWrapper::setNoiseFloorCalibration(uint16_t sample_interval_ms, uint resetNoiseFloorBatch(); } +void RadioLibWrapper::setNoiseFloorClamps(int16_t low_bound, int16_t high_bound) { + if (low_bound >= high_bound) { + return; + } + _noise_floor_low_bound = low_bound; + _noise_floor_high_bound = high_bound; + resetNoiseFloorBatch(); +} + +void RadioLibWrapper::scheduleNoiseFloorCalibration(uint32_t settle_ms) { + unsigned long scheduled_at = getMillis() + settle_ms; + _noise_floor_calibration_scheduled_at = scheduled_at == 0 ? 1 : scheduled_at; +} + void RadioLibWrapper::doResetAGC() { _radio->sleep(); // warm sleep to reset analog frontend } @@ -134,6 +170,14 @@ void RadioLibWrapper::resetAGC() { } void RadioLibWrapper::loop() { + if (_noise_floor_calibration_scheduled_at != 0 && + millisReached(getMillis(), _noise_floor_calibration_scheduled_at)) { + // Abnormal TX/CAD paths can leave partial RSSI samples tied to a + // transient frontend state. Drop the partial batch after a short settle + // delay, then let normal RX-idle sampling rebuild it. + resetNoiseFloorBatch(); + } + if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { unsigned long now = getMillis(); @@ -159,14 +203,14 @@ void RadioLibWrapper::loop() { _has_last_noise_floor_sample = true; int16_t rssi = (int16_t)getCurrentRSSI(); - if (rssi < MIN_NOISE_FLOOR_SAMPLE) { - rssi = MIN_NOISE_FLOOR_SAMPLE; + if (rssi < _noise_floor_low_bound) { + rssi = _noise_floor_low_bound; } bool trusted_published_floor = isTrustedNoiseFloorValue(_noise_floor); bool no_trusted_floor = !trusted_published_floor; bool healthy_floor_would_jump_down = trusted_published_floor && (_noise_floor - rssi) >= LOW_BOUND_REJECT_JUMP_DB; - bool high_bound_sample = rssi >= MAX_NOISE_FLOOR_SAMPLE; + bool high_bound_sample = rssi >= _noise_floor_high_bound; bool healthy_floor_would_jump_up = trusted_published_floor && (rssi - _noise_floor) >= HIGH_BOUND_REJECT_JUMP_DB; @@ -175,14 +219,14 @@ void RadioLibWrapper::loop() { // trusted floor, low RSSI samples are allowed because analyser readings // around -118 dBm, with occasional lower samples, are expected. if (healthy_floor_would_jump_down) { - _floor_rejected_low_bound = mesh::incrementStatCounter(_floor_rejected_low_bound); + _floor_rejected_low_bound = incrementNoiseFloorCounter(_floor_rejected_low_bound); return; } // Strong instantaneous RSSI is channel activity, not idle floor. Median // resists a few of these samples, but rejecting them keeps the batch // diagnostics honest and prevents a busy period from becoming the floor. if ((no_trusted_floor && high_bound_sample) || healthy_floor_would_jump_up) { - _floor_rejected_high_bound = mesh::incrementStatCounter(_floor_rejected_high_bound); + _floor_rejected_high_bound = incrementNoiseFloorCounter(_floor_rejected_high_bound); return; } @@ -199,14 +243,13 @@ void RadioLibWrapper::loop() { } _floor_samples[_num_floor_samples] = rssi; _num_floor_samples++; + _floor_sample_median = medianOfSamples(_floor_samples, _num_floor_samples); if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { std::sort(_floor_samples, _floor_samples + NUM_NOISE_FLOOR_SAMPLES); - _floor_sample_median = ((int32_t)_floor_samples[(NUM_NOISE_FLOOR_SAMPLES / 2) - 1] + - _floor_samples[NUM_NOISE_FLOOR_SAMPLES / 2]) / 2; int16_t floor_estimate = _floor_samples[NUM_NOISE_FLOOR_SAMPLES / 4]; bool completed_high_activity_batch = - no_trusted_floor && floor_estimate >= MAX_NOISE_FLOOR_SAMPLE; + no_trusted_floor && floor_estimate >= _noise_floor_high_bound; bool completed_batch_would_jump_down = trusted_published_floor && (_noise_floor - floor_estimate) >= LOW_BOUND_REJECT_JUMP_DB; bool completed_batch_would_jump_up = trusted_published_floor && diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 3c3416a2bd..d2bff2867a 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -11,8 +11,11 @@ class RadioLibWrapper : public mesh::Radio { static constexpr uint16_t NUM_NOISE_FLOOR_SAMPLES = 64; static constexpr uint16_t DEFAULT_NOISE_FLOOR_SAMPLE_INTERVAL_MS = 50; static constexpr uint32_t DEFAULT_NOISE_FLOOR_MAX_CALIB_WINDOW_MS = 60000; + static constexpr int16_t DEFAULT_NOISE_FLOOR_LOW_BOUND = -125; + static constexpr int16_t DEFAULT_NOISE_FLOOR_HIGH_BOUND = -80; uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; + int16_t _noise_floor_low_bound, _noise_floor_high_bound; float _last_packet_rssi, _last_packet_snr; uint16_t _num_floor_samples; int16_t _floor_samples[NUM_NOISE_FLOOR_SAMPLES]; @@ -21,6 +24,7 @@ class RadioLibWrapper : public mesh::Radio { uint16_t _floor_rejected_high_bound; uint16_t _noise_floor_sample_interval_ms; uint32_t _noise_floor_max_calib_window_ms; + unsigned long _noise_floor_calibration_scheduled_at; unsigned long _noise_floor_batch_started_at; unsigned long _last_noise_floor_sample_at; bool _noise_floor_batch_active; @@ -44,12 +48,16 @@ class RadioLibWrapper : public mesh::Radio { public: RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), n_recv(0), n_sent(0), n_recv_errors(0), - _noise_floor(0), _threshold(0), _last_packet_rssi(0), _last_packet_snr(0), + _noise_floor(0), _threshold(0), + _noise_floor_low_bound(DEFAULT_NOISE_FLOOR_LOW_BOUND), + _noise_floor_high_bound(DEFAULT_NOISE_FLOOR_HIGH_BOUND), + _last_packet_rssi(0), _last_packet_snr(0), _num_floor_samples(0), _floor_sample_min(0), _floor_sample_median(0), _floor_sample_max(0), _floor_rejected_low_bound(0), _floor_rejected_high_bound(0), _noise_floor_sample_interval_ms(DEFAULT_NOISE_FLOOR_SAMPLE_INTERVAL_MS), _noise_floor_max_calib_window_ms(DEFAULT_NOISE_FLOOR_MAX_CALIB_WINDOW_MS), + _noise_floor_calibration_scheduled_at(0), _noise_floor_batch_started_at(0), _last_noise_floor_sample_at(0), _noise_floor_batch_active(false), _has_last_noise_floor_sample(false), _preamble_sf(0) { } @@ -82,6 +90,8 @@ class RadioLibWrapper : public mesh::Radio { int getNoiseFloor() const override; mesh::NoiseFloorStats getNoiseFloorStats() const override; void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint32_t max_calib_window_ms) override; + void setNoiseFloorClamps(int16_t low_bound, int16_t high_bound) override; + void scheduleNoiseFloorCalibration(uint32_t settle_ms) override; void triggerNoiseFloorCalibrate(int threshold) override; void resetAGC() override; diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index 8d23dc7838..7386d6fefd 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -67,6 +67,19 @@ class TestRadioLibWrapper : public RadioLibWrapper { _num_floor_samples = NUM_NOISE_FLOOR_SAMPLES; } + void forceNoiseFloorStats(int16_t noise_floor, uint16_t accepted_count, + int16_t sample_min, int16_t sample_median, + int16_t sample_max, uint16_t rejected_low_bound, + uint16_t rejected_high_bound) { + _noise_floor = noise_floor; + _num_floor_samples = accepted_count; + _floor_sample_min = sample_min; + _floor_sample_median = sample_median; + _floor_sample_max = sample_max; + _floor_rejected_low_bound = rejected_low_bound; + _floor_rejected_high_bound = rejected_high_bound; + } + void setCurrentRssiSamples(const std::vector& samples) { current_rssi_samples = samples; current_rssi_index = 0; @@ -138,7 +151,7 @@ TEST(RssiNoiseFloor, LowStartupSamplesDoNotDominateLowerQuartileFloor) { EXPECT_EQ(-103, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); EXPECT_EQ(64, stats.accepted_count); - EXPECT_EQ(-130, stats.sample_min); + EXPECT_EQ(-125, stats.sample_min); EXPECT_EQ(-103, stats.sample_median); EXPECT_EQ(-103, stats.sample_max); EXPECT_EQ(0, stats.rejected_low_bound_count); @@ -197,7 +210,7 @@ TEST(RssiNoiseFloor, LowFloorStillRejectsLargeUpwardJumps) { EXPECT_EQ(64, stats.rejected_high_bound_count); } -TEST(RssiNoiseFloor, VeryLowSamplesAreClampedAndCanPublishWhenNoPreviousFloorExists) { +TEST(RssiNoiseFloor, VeryLowSamplesUseDefaultLowClampAndCanPublishWhenNoPreviousFloorExists) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); @@ -207,16 +220,52 @@ TEST(RssiNoiseFloor, VeryLowSamplesAreClampedAndCanPublishWhenNoPreviousFloorExi wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(); - EXPECT_EQ(-130, wrapper.getNoiseFloor()); + EXPECT_EQ(-125, wrapper.getNoiseFloor()); mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); EXPECT_EQ(64, stats.accepted_count); - EXPECT_EQ(-130, stats.sample_min); - EXPECT_EQ(-130, stats.sample_median); - EXPECT_EQ(-130, stats.sample_max); + EXPECT_EQ(-125, stats.sample_min); + EXPECT_EQ(-125, stats.sample_median); + EXPECT_EQ(-125, stats.sample_max); EXPECT_EQ(0, stats.rejected_low_bound_count); EXPECT_EQ(0, stats.rejected_high_bound_count); } +TEST(RssiNoiseFloor, RuntimeLowClampOverridesDefaultClamp) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setNoiseFloorClamps(-135, -80); + wrapper.setCurrentRssiSamples(std::vector(64, -140.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-135, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(64, stats.accepted_count); + EXPECT_EQ(-135, stats.sample_min); + EXPECT_EQ(-135, stats.sample_median); + EXPECT_EQ(-135, stats.sample_max); +} + +TEST(RssiNoiseFloor, RuntimeHighClampRejectsConfiguredActivityBound) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setNoiseFloorClamps(-125, -90); + wrapper.setCurrentRssiSamples(std::vector(12, -89.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(12); + + EXPECT_EQ(0, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + EXPECT_EQ(12, stats.rejected_high_bound_count); +} + TEST(RssiNoiseFloor, LowBoundRejectCountSaturates) { FakePhysicalLayer radio; FakeBoard board; @@ -235,6 +284,23 @@ TEST(RssiNoiseFloor, LowBoundRejectCountSaturates) { EXPECT_EQ(0, stats.rejected_high_bound_count); } +TEST(RssiNoiseFloor, PartialBatchMedianReflectsAcceptedSamples) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setCurrentRssiSamples({-110.0f, -100.0f, -90.0f}); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(3); + + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(3, stats.accepted_count); + EXPECT_EQ(-110, stats.sample_min); + EXPECT_EQ(-100, stats.sample_median); + EXPECT_EQ(-90, stats.sample_max); +} + TEST(RssiNoiseFloor, RejectedOnlyBatchResetsOnCalibrationTrigger) { FakePhysicalLayer radio; FakeBoard board; @@ -455,6 +521,40 @@ TEST(RssiNoiseFloor, SamplingIsRateLimited) { EXPECT_EQ(2, stats.accepted_count); } +TEST(RssiNoiseFloor, ScheduledCalibrationRefreshResetsAfterSettleDelay) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setNoiseFloorCalibration(1000, 30000); + wrapper.setCurrentRssiSamples(std::vector(64, -101.0f)); + wrapper.enterReceiveMode(); + + wrapper.collectNoiseFloorSamples(1); + wrapper.advanceMillis(1000); + wrapper.collectNoiseFloorSamples(1); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(2, stats.accepted_count); + + wrapper.scheduleNoiseFloorCalibration(500); + wrapper.receiving_packet = true; + wrapper.advanceMillis(499); + wrapper.collectNoiseFloorSamples(1); + stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(2, stats.accepted_count); + + wrapper.advanceMillis(1); + wrapper.collectNoiseFloorSamples(1); + stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(0, stats.accepted_count); + + wrapper.receiving_packet = false; + wrapper.collectNoiseFloorSamples(1); + stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(1, stats.accepted_count); +} + TEST(RssiNoiseFloor, CalibrationWindowDropsStalePartialBatch) { FakePhysicalLayer radio; FakeBoard board; From d5b9c12d4b76a65672d0891fae0812a7090f047b Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Thu, 25 Jun 2026 14:57:24 +0100 Subject: [PATCH 12/13] Expose noise floor stats output --- docs/cli_commands.md | 14 ++++++++++++ examples/simple_repeater/MyMesh.cpp | 4 ++++ examples/simple_repeater/MyMesh.h | 1 + examples/simple_room_server/MyMesh.cpp | 4 ++++ examples/simple_room_server/MyMesh.h | 1 + examples/simple_sensor/SensorMesh.cpp | 4 ++++ examples/simple_sensor/SensorMesh.h | 1 + src/helpers/CommonCLI.cpp | 2 ++ src/helpers/CommonCLI.h | 1 + src/helpers/StatsFormatHelper.h | 15 +++++++++++++ .../test_packet_metrics.cpp | 22 +++++++++++++++++++ 11 files changed, 69 insertions(+) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index aa08b3773e..dd61cca206 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -161,6 +161,20 @@ Returns JSON with: --- +### Noise-floor calibration stats +**Usage:** `stats-noise` + +**Serial Only:** No + +Returns JSON with: +- `floor`: current radio noise floor estimate in dBm +- `accepted`: RSSI samples accepted into the current or most recent calibration batch +- `min` / `median` / `max`: accepted RSSI sample range in dBm. `floor` is estimated from the lower quartile of the accepted batch. +- `rejected_low`: RSSI samples rejected because they would cause a suspicious downward jump +- `rejected_high`: strong RSSI samples rejected because they look like channel activity rather than idle noise + +--- + ### Noise-floor calibration settings **Usage:** `get noise.sample.ms` diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 788fb46ebd..4058eaf254 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1155,6 +1155,10 @@ void MyMesh::formatRadioStatsReply(char *reply) { StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); } +void MyMesh::formatNoiseFloorStatsReply(char *reply) { + StatsFormatHelper::formatNoiseFloorStats(reply, _radio); +} + void MyMesh::formatPacketStatsReply(char *reply) { StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 60593961e5..9237220a41 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -213,6 +213,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void removeNeighbor(const uint8_t* pubkey, int key_len) override; void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; + void formatNoiseFloorStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; void startRegionsLoad() override; bool saveRegions() override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 9e9204cda8..2a797c2ca5 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -859,6 +859,10 @@ void MyMesh::formatRadioStatsReply(char *reply) { StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); } +void MyMesh::formatNoiseFloorStatsReply(char *reply) { + StatsFormatHelper::formatNoiseFloorStats(reply, _radio); +} + void MyMesh::formatPacketStatsReply(char *reply) { StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 6a496fbe35..e585b2c6f4 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -209,6 +209,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { } void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; + void formatNoiseFloorStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; void startRegionsLoad() override; bool saveRegions() override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 1a8ffd2dff..4c911512f6 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -861,6 +861,10 @@ void SensorMesh::formatRadioStatsReply(char *reply) { StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); } +void SensorMesh::formatNoiseFloorStatsReply(char *reply) { + StatsFormatHelper::formatNoiseFloorStats(reply, _radio); +} + void SensorMesh::formatPacketStatsReply(char *reply) { StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 053f3b322b..9f1c204d3c 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -73,6 +73,7 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { } void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; + void formatNoiseFloorStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; void setNoiseFloorCalibration(uint16_t sample_interval_ms, uint16_t max_calib_window_secs) override { _radio->setNoiseFloorCalibration(sample_interval_ms, ((uint32_t)max_calib_window_secs) * 1000U); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index ec4e7504a0..8f64af1157 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -487,6 +487,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _callbacks->formatPacketStatsReply(reply); } else if (sender_timestamp == 0 && memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) { _callbacks->formatRadioStatsReply(reply); + } else if (memcmp(command, "stats-noise", 11) == 0 && (command[11] == 0 || command[11] == ' ')) { + _callbacks->formatNoiseFloorStatsReply(reply); } else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { _callbacks->formatStatsReply(reply); } else { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 32621f46ce..e2a8ed08da 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -99,6 +99,7 @@ class CommonCLICallbacks { }; virtual void formatStatsReply(char *reply) = 0; virtual void formatRadioStatsReply(char *reply) = 0; + virtual void formatNoiseFloorStatsReply(char *reply) = 0; virtual void formatPacketStatsReply(char *reply) = 0; virtual mesh::LocalIdentity& getSelfId() = 0; virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0; diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index 2f87b68db9..861ac99045 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -44,6 +44,21 @@ class StatsFormatHelper { ); } + static void formatNoiseFloorStats(char* reply, mesh::Radio* radio) { + mesh::NoiseFloorStats nf_stats = radio->getNoiseFloorStats(); + sprintf(reply, + "{\"floor\":%d,\"accepted\":%u,\"min\":%d,\"median\":%d,\"max\":%d," + "\"rejected_low\":%u,\"rejected_high\":%u}", + (int16_t)radio->getNoiseFloor(), + nf_stats.accepted_count, + nf_stats.sample_min, + nf_stats.sample_median, + nf_stats.sample_max, + nf_stats.rejected_low_bound_count, + nf_stats.rejected_high_bound_count + ); + } + template static void formatPacketStats(char* reply, RadioDriverType& driver, diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index 7386d6fefd..927a2ed321 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -603,6 +603,28 @@ TEST(RssiNoiseFloor, RadioStatsExposeCalibrationDiagnostics) { EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_rejected_high_bound\":0")); } +TEST(RssiNoiseFloor, NoiseStatsExposeCalibrationDiagnostics) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + char reply[512]; + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(64, -101.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + StatsFormatHelper::formatNoiseFloorStats(reply, &wrapper); + + EXPECT_NE(nullptr, std::strstr(reply, "\"floor\":-101")); + EXPECT_NE(nullptr, std::strstr(reply, "\"accepted\":64")); + EXPECT_NE(nullptr, std::strstr(reply, "\"min\":-101")); + EXPECT_NE(nullptr, std::strstr(reply, "\"median\":-101")); + EXPECT_NE(nullptr, std::strstr(reply, "\"max\":-101")); + EXPECT_NE(nullptr, std::strstr(reply, "\"rejected_low\":0")); + EXPECT_NE(nullptr, std::strstr(reply, "\"rejected_high\":0")); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From ba6687772de5f4ce755e983a0eaefffdd67ba7fc Mon Sep 17 00:00:00 2001 From: YouGottaHackThat Date: Thu, 25 Jun 2026 15:16:38 +0100 Subject: [PATCH 13/13] Restore radio stats compatibility --- docs/cli_commands.md | 13 ++--- src/helpers/CommonCLI.cpp | 2 +- src/helpers/StatsFormatHelper.h | 14 +---- src/helpers/radiolib/RadioLibWrappers.cpp | 28 ++-------- src/helpers/radiolib/RadioLibWrappers.h | 1 - src/helpers/radiolib/RssiCarrierSense.h | 41 --------------- .../test_classifier.cpp | 52 ------------------- .../test_packet_metrics.cpp | 17 +++--- 8 files changed, 19 insertions(+), 149 deletions(-) delete mode 100644 src/helpers/radiolib/RssiCarrierSense.h delete mode 100644 test/test_rssi_carrier_sense/test_classifier.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index dd61cca206..52ca861d85 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -146,7 +146,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore ### Radio Stats - Noise floor, Last RSSI/SNR, Airtime, Receive errors **Usage:** `stats-radio` -**Serial Only:** Yes +**Serial Only:** No Returns JSON with: - `noise_floor`: current radio noise floor estimate in dBm @@ -154,10 +154,6 @@ Returns JSON with: - `last_snr`: SNR from the most recent received packet - `tx_air_secs`: accumulated transmit airtime in seconds - `rx_air_secs`: accumulated receive airtime estimate in seconds -- `noise_floor_sample_count`: RSSI samples accepted into the current or most recent calibration batch -- `noise_floor_sample_min` / `noise_floor_sample_median` / `noise_floor_sample_max`: accepted RSSI sample range in dBm. `noise_floor` is estimated from the lower quartile of the accepted batch. -- `noise_floor_rejected_low_bound`: RSSI samples rejected because they would cause a suspicious downward jump -- `noise_floor_rejected_high_bound`: strong RSSI samples rejected because they look like channel activity rather than idle noise --- @@ -626,12 +622,9 @@ Controls the RSSI sampling cadence and maximum calibration attempt window used b - `set int.thresh ` **Parameters:** -- `value`: Interference threshold value in dB above the calibrated noise floor. - `0` disables RSSI carrier-sense. When enabled, transmit deferral uses a short - majority of instantaneous RSSI samples so a single spike does not mark the - channel busy. +- `value`: Interference threshold value -**Default:** `10` +**Default:** `0.0` --- diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 8f64af1157..356dcfdf23 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -485,7 +485,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re strcpy(reply, " EOF"); } else if (sender_timestamp == 0 && memcmp(command, "stats-packets", 13) == 0 && (command[13] == 0 || command[13] == ' ')) { _callbacks->formatPacketStatsReply(reply); - } else if (sender_timestamp == 0 && memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) { + } else if (memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) { _callbacks->formatRadioStatsReply(reply); } else if (memcmp(command, "stats-noise", 11) == 0 && (command[11] == 0 || command[11] == ' ')) { _callbacks->formatNoiseFloorStatsReply(reply); diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index 861ac99045..36038b7c44 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -24,23 +24,13 @@ class StatsFormatHelper { RadioDriverType& driver, uint32_t total_air_time_ms, uint32_t total_rx_air_time_ms) { - mesh::NoiseFloorStats nf_stats = radio->getNoiseFloorStats(); sprintf(reply, - "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u," - "\"noise_floor_sample_count\":%u,\"noise_floor_sample_min\":%d,\"noise_floor_sample_median\":%d," - "\"noise_floor_sample_max\":%d,\"noise_floor_rejected_low_bound\":%u," - "\"noise_floor_rejected_high_bound\":%u}", + "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u}", (int16_t)radio->getNoiseFloor(), (int16_t)driver.getLastRSSI(), driver.getLastSNR(), total_air_time_ms / 1000, - total_rx_air_time_ms / 1000, - nf_stats.accepted_count, - nf_stats.sample_min, - nf_stats.sample_median, - nf_stats.sample_max, - nf_stats.rejected_low_bound_count, - nf_stats.rejected_high_bound_count + total_rx_air_time_ms / 1000 ); } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 1274ed3f8c..02b2ea617d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -10,8 +10,6 @@ #define STATE_TX_DONE 4 #define STATE_INT_READY 16 -#define RSSI_CARRIER_SENSE_SAMPLES 5 -#define RSSI_CARRIER_SENSE_REQUIRED 3 #define LOW_BOUND_REJECT_JUMP_DB 14 #define HIGH_BOUND_REJECT_JUMP_DB 14 @@ -394,29 +392,9 @@ void RadioLibWrapper::onSendFinished() { } bool RadioLibWrapper::isChannelActive() { - if (_threshold == 0) { - return false; // interference check is disabled - } - if (!hasNoiseFloor()) { - return false; - } - - int16_t samples[RSSI_CARRIER_SENSE_SAMPLES]; - for (uint8_t i = 0; i < RSSI_CARRIER_SENSE_SAMPLES; i++) { - samples[i] = (int16_t)getCurrentRSSI(); - } - - mesh::RssiCarrierSenseConfig config = { - _noise_floor, - _threshold, - true, - RSSI_CARRIER_SENSE_REQUIRED - }; - - // Instantaneous RSSI can spike briefly, so carrier sense requires a short - // majority of busy samples. This is only a local RSSI guard; it cannot detect - // hidden-node collisions at another receiver. - return mesh::RssiCarrierSense::isActive(config, samples, RSSI_CARRIER_SENSE_SAMPLES); + return _threshold == 0 + ? false // interference check is disabled + : getCurrentRSSI() > _noise_floor + _threshold; } // Approximate SNR threshold per SF for successful reception (based on Semtech datasheets) diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index d2bff2867a..41cf462911 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -2,7 +2,6 @@ #include #include -#include "RssiCarrierSense.h" class RadioLibWrapper : public mesh::Radio { protected: diff --git a/src/helpers/radiolib/RssiCarrierSense.h b/src/helpers/radiolib/RssiCarrierSense.h deleted file mode 100644 index d08577e55e..0000000000 --- a/src/helpers/radiolib/RssiCarrierSense.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include - -namespace mesh { - -struct RssiCarrierSenseConfig { - int16_t noise_floor; - int16_t threshold; - bool noise_floor_valid; - uint8_t required_busy_samples; -}; - -class RssiCarrierSense { -public: - static bool isActive(const RssiCarrierSenseConfig& config, const int16_t samples[], uint8_t sample_count) { - if (config.threshold <= 0 || !config.noise_floor_valid || samples == nullptr || sample_count == 0) { - return false; - } - - uint8_t required = config.required_busy_samples; - if (required == 0 || required > sample_count) { - required = sample_count; - } - - uint8_t busy_samples = 0; - const int16_t busy_level = config.noise_floor + config.threshold; - for (uint8_t i = 0; i < sample_count; i++) { - if (samples[i] > busy_level) { - busy_samples++; - if (busy_samples >= required) { - return true; - } - } - } - - return false; - } -}; - -} diff --git a/test/test_rssi_carrier_sense/test_classifier.cpp b/test/test_rssi_carrier_sense/test_classifier.cpp deleted file mode 100644 index fbf74cae23..0000000000 --- a/test/test_rssi_carrier_sense/test_classifier.cpp +++ /dev/null @@ -1,52 +0,0 @@ -#include - -#include "helpers/radiolib/RssiCarrierSense.h" - -using namespace mesh; - -TEST(RssiCarrierSense, DisabledThresholdIsInactive) { - int16_t samples[] = {-80, -75, -70}; - RssiCarrierSenseConfig config = {-110, 0, true, 2}; - - EXPECT_FALSE(RssiCarrierSense::isActive(config, samples, 3)); -} - -TEST(RssiCarrierSense, UnconvergedNoiseFloorIsInactive) { - int16_t samples[] = {-80, -75, -70}; - RssiCarrierSenseConfig config = {-110, 10, false, 2}; - - EXPECT_FALSE(RssiCarrierSense::isActive(config, samples, 3)); -} - -TEST(RssiCarrierSense, BelowThresholdSamplesAreInactive) { - int16_t samples[] = {-101, -100, -99, -101, -100}; - RssiCarrierSenseConfig config = {-110, 10, true, 3}; - - EXPECT_FALSE(RssiCarrierSense::isActive(config, samples, 5)); -} - -TEST(RssiCarrierSense, IsolatedSpikeIsInactive) { - int16_t samples[] = {-101, -70, -101, -100, -101}; - RssiCarrierSenseConfig config = {-110, 10, true, 3}; - - EXPECT_FALSE(RssiCarrierSense::isActive(config, samples, 5)); -} - -TEST(RssiCarrierSense, MajorityAboveThresholdIsActive) { - int16_t samples[] = {-99, -98, -101, -97, -102}; - RssiCarrierSenseConfig config = {-110, 10, true, 3}; - - EXPECT_TRUE(RssiCarrierSense::isActive(config, samples, 5)); -} - -TEST(RssiCarrierSense, ExactThresholdBoundaryIsInactive) { - int16_t samples[] = {-100, -100, -100, -101, -102}; - RssiCarrierSenseConfig config = {-110, 10, true, 3}; - - EXPECT_FALSE(RssiCarrierSense::isActive(config, samples, 5)); -} - -int main(int argc, char **argv) { - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/test/test_rssi_packet_metrics/test_packet_metrics.cpp b/test/test_rssi_packet_metrics/test_packet_metrics.cpp index 927a2ed321..ca90d09f46 100644 --- a/test/test_rssi_packet_metrics/test_packet_metrics.cpp +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -582,25 +582,28 @@ TEST(RssiNoiseFloor, CalibrationWindowDropsStalePartialBatch) { EXPECT_EQ(0, stats.sample_max); } -TEST(RssiNoiseFloor, RadioStatsExposeCalibrationDiagnostics) { +TEST(RssiNoiseFloor, RadioStatsKeepOriginalCompactShape) { FakePhysicalLayer radio; FakeBoard board; TestRadioLibWrapper wrapper(radio, board); char reply[512]; wrapper.begin(); + wrapper.cachePacketMetrics(radio.getRSSI(), radio.getSNR()); wrapper.setCurrentRssiSamples(std::vector(64, -101.0f)); wrapper.enterReceiveMode(); wrapper.collectNoiseFloorSamples(); StatsFormatHelper::formatRadioStats(reply, &wrapper, wrapper, 1000, 2000); - EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_sample_count\":64")); - EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_sample_min\":-101")); - EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_sample_median\":-101")); - EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_sample_max\":-101")); - EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_rejected_low_bound\":0")); - EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor_rejected_high_bound\":0")); + EXPECT_NE(nullptr, std::strstr(reply, "\"noise_floor\":-101")); + EXPECT_NE(nullptr, std::strstr(reply, "\"last_rssi\":-73")); + EXPECT_NE(nullptr, std::strstr(reply, "\"last_snr\":8.25")); + EXPECT_NE(nullptr, std::strstr(reply, "\"tx_air_secs\":1")); + EXPECT_NE(nullptr, std::strstr(reply, "\"rx_air_secs\":2")); + EXPECT_EQ(nullptr, std::strstr(reply, "\"noise_floor_sample_count\"")); + EXPECT_EQ(nullptr, std::strstr(reply, "\"noise_floor_rejected_low_bound\"")); + EXPECT_EQ(nullptr, std::strstr(reply, "\"noise_floor_rejected_high_bound\"")); } TEST(RssiNoiseFloor, NoiseStatsExposeCalibrationDiagnostics) {