diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 66a9b77afe..52ca861d85 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -146,7 +146,58 @@ 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 +- `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 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` + +**Usage:** `set noise.sample.ms ` + +**Usage:** `get noise.window.secs` + +**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 096907494b..4058eaf254 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -893,6 +893,10 @@ 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; + _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 @@ -961,6 +965,10 @@ 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.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", @@ -1147,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 7597c6c6f6..9237220a41 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -213,10 +213,17 @@ 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; 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); + } + 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 98b22fdb72..2a797c2ca5 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -650,6 +650,10 @@ 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; + _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 @@ -699,6 +703,10 @@ 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.setNoiseFloorClamps(_prefs.noise_clamp_low_dbm, + _prefs.noise_clamp_high_dbm); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -851,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 5277ddad61..e585b2c6f4 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -209,10 +209,17 @@ 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; 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); + } + 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 879fcbf026..4c911512f6 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -726,6 +726,10 @@ 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; + _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; @@ -765,6 +769,10 @@ 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(); @@ -853,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 c9f135f65e..9f1c204d3c 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -73,7 +73,14 @@ 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); + } + 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 dd032f130d..feebb8fc1d 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -19,6 +19,15 @@ 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; + uint16_t rejected_high_bound_count; +}; + class Radio { public: virtual void begin() { } @@ -62,6 +71,7 @@ class Radio { virtual void loop() { } virtual int getNoiseFloor() const { return 0; } + virtual NoiseFloorStats getNoiseFloorStats() const { return {0, 0, 0, 0, 0, 0}; } virtual void triggerNoiseFloorCalibrate(int threshold) { } @@ -76,6 +86,20 @@ 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; + } + + 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; + } }; /** @@ -109,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. @@ -129,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 b78ad6ebd6..356dcfdf23 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -91,7 +91,11 @@ 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 + 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); @@ -121,6 +125,14 @@ 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); + _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(); } @@ -184,7 +196,11 @@ 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 + 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(); } @@ -469,8 +485,10 @@ 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); } else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { _callbacks->formatStatsReply(reply); } else { @@ -504,6 +522,50 @@ 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, "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(); @@ -778,6 +840,14 @@ 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, "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 b509c2b31a..e2a8ed08da 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -19,6 +19,16 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 +#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; char node_name[32]; @@ -63,6 +73,10 @@ 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; + int16_t noise_clamp_low_dbm; + int16_t noise_clamp_high_dbm; }; class CommonCLICallbacks { @@ -85,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; @@ -112,6 +127,16 @@ 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; + }; + + virtual void setNoiseFloorClamps(int16_t low_bound, int16_t high_bound) { + (void)low_bound; + (void)high_bound; + }; }; class CommonCLI { diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index bf619133e9..36038b7c44 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -34,6 +34,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/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 b6519aefa7..02b2ea617d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -2,17 +2,54 @@ #define RADIOLIB_STATIC_ONLY 1 #include "RadioLibWrappers.h" +#include + #define STATE_IDLE 0 #define STATE_RX 1 #define STATE_TX_WAIT 3 #define STATE_TX_DONE 4 #define STATE_INT_READY 16 -#define NUM_NOISE_FLOOR_SAMPLES 64 -#define SAMPLING_THRESHOLD 14 +#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; +} + +static bool elapsedAtLeast(unsigned long now, unsigned long started_at, uint32_t interval_ms) { + 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) { + 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 // is transmitted by the module static @@ -24,6 +61,24 @@ void setFlag(void) { state |= STATE_INT_READY; } +void RadioLibWrapper::resetNoiseFloorSamples() { + _num_floor_samples = 0; + _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() { + resetNoiseFloorSamples(); + _noise_floor_calibration_scheduled_at = 0; + _floor_rejected_low_bound = 0; + _floor_rejected_high_bound = 0; +} + void RadioLibWrapper::begin() { _radio->setPacketReceivedAction(setFlag); // this is also SentComplete interrupt _preamble_sf = getSpreadingFactor(); @@ -37,9 +92,8 @@ void RadioLibWrapper::begin() { _noise_floor = 0; _threshold = 0; - // start average out some samples - _num_floor_samples = 0; - _floor_sample_sum = 0; + // Start a fresh batch of idle RSSI samples for noise-floor calibration. + resetNoiseFloorBatch(); } uint32_t RadioLibWrapper::getRngSeed() { @@ -57,10 +111,43 @@ 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; - _floor_sample_sum = 0; + 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(); + } +} + +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::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() { @@ -74,32 +161,124 @@ void RadioLibWrapper::resetAGC() { doResetAGC(); state = STATE_IDLE; // trigger a startReceive() - // Reset noise floor sampling so it reconverges from scratch. - // Without this, a stuck _noise_floor of -120 makes the sampling threshold - // too low (-106) to accept normal samples (~-105), self-reinforcing the - // stuck value even after the receiver has recovered. - _noise_floor = 0; - _num_floor_samples = 0; - _floor_sample_sum = 0; + // 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 (_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()) { - int rssi = getCurrentRSSI(); - if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD - _num_floor_samples++; - _floor_sample_sum += rssi; + 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; } - } - } 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; + 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; - MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); + int16_t rssi = (int16_t)getCurrentRSSI(); + 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 >= _noise_floor_high_bound; + bool healthy_floor_would_jump_up = trusted_published_floor && + (rssi - _noise_floor) >= HIGH_BOUND_REJECT_JUMP_DB; + + // 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 = 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 = incrementNoiseFloorCounter(_floor_rejected_high_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++; + _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); + int16_t floor_estimate = _floor_samples[NUM_NOISE_FLOOR_SAMPLES / 4]; + bool completed_high_activity_batch = + 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 && + (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 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; + } + 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_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, + _num_floor_samples, + (int)_floor_sample_min, + (int)_floor_sample_median, + (int)_floor_sample_max, + _floor_rejected_low_bound, + _floor_rejected_high_bound); + } + } } } @@ -116,6 +295,36 @@ bool RadioLibWrapper::isInRecvMode() const { return (state & ~STATE_INT_READY) == STATE_RX; } +bool RadioLibWrapper::hasNoiseFloor() const { + 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) { int len = 0; if (state & STATE_INT_READY) { @@ -128,6 +337,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++; } @@ -179,18 +392,11 @@ void RadioLibWrapper::onSendFinished() { } bool RadioLibWrapper::isChannelActive() { - return _threshold == 0 + return _threshold == 0 ? false // interference check is disabled : getCurrentRSSI() > _noise_floor + _threshold; } -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 @@ -206,8 +412,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 efd3e17931..41cf462911 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -7,20 +7,59 @@ class RadioLibWrapper : public mesh::Radio { protected: PhysicalLayer* _radio; mesh::MainBoard* _board; + 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; - int32_t _floor_sample_sum; + 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; + 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; + bool _has_last_noise_floor_sample; uint8_t _preamble_sf; + void resetNoiseFloorSamples(); + void resetNoiseFloorBatch(); 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; + } 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), n_recv(0), n_sent(0), n_recv_errors(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) { } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -47,7 +86,11 @@ 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; } + 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; @@ -58,8 +101,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..ca90d09f46 --- /dev/null +++ b/test/test_rssi_packet_metrics/test_packet_metrics.cpp @@ -0,0 +1,634 @@ +#include + +#include +#include + +#include "helpers/StatsFormatHelper.h" +#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) { + 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 { + (void)freq; + (void)bw; + (void)sf; + (void)cr; + } + + 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; } + + 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); + } + + void forceNoiseFloor(int16_t noise_floor) { + _noise_floor = noise_floor; + _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; + } + + void enterReceiveMode() { + startRecv(); + } + + void collectNoiseFloorSamples(uint32_t sample_count = 64) { + for (uint32_t i = 0; i < sample_count; i++) { + loop(); + } + } +}; + +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()); +} + +TEST(RssiNoiseFloor, LowStartupSamplesDoNotDominateLowerQuartileFloor) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + std::vector samples; + + samples.insert(samples.end(), 16, -130.0f); + samples.insert(samples.end(), 64, -103.0f); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(samples); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(80); + + EXPECT_EQ(-103, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(64, stats.accepted_count); + 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); +} + +TEST(RssiNoiseFloor, LowFloorDoesNotRejectLaterHealthySamples) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.forceNoiseFloor(-120); + wrapper.triggerNoiseFloorCalibrate(0); + wrapper.setCurrentRssiSamples(std::vector(64, -112.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-112, wrapper.getNoiseFloor()); +} + +TEST(RssiNoiseFloor, LowFloorAcceptsLaterAnalyserPlausibleLowSamples) { + 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(64, stats.accepted_count); + EXPECT_EQ(0, stats.rejected_low_bound_count); + EXPECT_EQ(0, stats.rejected_high_bound_count); +} + +TEST(RssiNoiseFloor, LowFloorStillRejectsLargeUpwardJumps) { + 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, VeryLowSamplesUseDefaultLowClampAndCanPublishWhenNoPreviousFloorExists) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(std::vector(64, -140.0f)); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-125, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(64, stats.accepted_count); + 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; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.forceNoiseFloor(-100); + wrapper.triggerNoiseFloorCalibrate(0); + 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, 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; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.forceNoiseFloor(-100); + wrapper.triggerNoiseFloorCalibrate(0); + 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, 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(), 20, -118.0f); + samples.insert(samples.end(), 44, -104.0f); + + wrapper.begin(); + wrapper.setCurrentRssiSamples(samples); + wrapper.enterReceiveMode(); + wrapper.collectNoiseFloorSamples(); + + EXPECT_EQ(-118, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + EXPECT_EQ(64, stats.accepted_count); + EXPECT_EQ(-118, stats.sample_min); + EXPECT_EQ(-104, stats.sample_median); + EXPECT_EQ(-104, stats.sample_max); +} + +TEST(RssiNoiseFloor, CompletedLowBoundBatchIsReportedAsAccepted) { + FakePhysicalLayer radio; + FakeBoard board; + TestRadioLibWrapper wrapper(radio, board); + + wrapper.begin(); + wrapper.forceNoiseFloorStats(-120, 64, -121, -120, -119, 0, 0); + + EXPECT_EQ(-120, wrapper.getNoiseFloor()); + mesh::NoiseFloorStats stats = wrapper.getNoiseFloorStats(); + 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) { + 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); + EXPECT_EQ(0, stats.rejected_high_bound_count); +} + +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()); +} + +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, 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; + 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, 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\":-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) { + 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(); +}