From 36054c25b7fe45a6fab90b448fedeca2fe4f0ced Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Thu, 9 Jul 2026 15:30:38 +0200 Subject: [PATCH] Dynamic CAD sensitivity Probe every 4 seconds to measure actual channel activity. If channel seems busy we adjust CAD to be less sensitive. If channel seems empty maximum CAD sensitivity. --- examples/simple_repeater/MyMesh.h | 1 + examples/simple_room_server/MyMesh.h | 1 + examples/simple_sensor/SensorMesh.h | 1 + src/Dispatcher.h | 8 +++ src/Mesh.cpp | 2 +- src/helpers/CommonCLI.cpp | 8 ++- src/helpers/CommonCLI.h | 1 + src/helpers/radiolib/CustomLLCC68Wrapper.h | 1 + src/helpers/radiolib/CustomLR1110Wrapper.h | 6 ++ src/helpers/radiolib/CustomSTM32WLxWrapper.h | 1 + src/helpers/radiolib/CustomSX1262Wrapper.h | 1 + src/helpers/radiolib/CustomSX1268Wrapper.h | 1 + src/helpers/radiolib/RadioLibWrappers.cpp | 65 ++++++++++++++++++++ src/helpers/radiolib/RadioLibWrappers.h | 16 +++++ 14 files changed, 111 insertions(+), 2 deletions(-) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 0b2e7491b7..6c586ade95 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -216,6 +216,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; + mesh::Radio* getRadio() override { return _radio; } void formatPacketStatsReply(char *reply) override; void startRegionsLoad() override; bool saveRegions() override; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 6bab9dc2d0..9a7a44764a 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -212,6 +212,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { } void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; + mesh::Radio* getRadio() override { return _radio; } void formatPacketStatsReply(char *reply) override; void startRegionsLoad() override; bool saveRegions() override; diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 1d65b8772b..efa3da0151 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; + mesh::Radio* getRadio() override { return _radio; } void formatPacketStatsReply(char *reply) override; mesh::LocalIdentity& getSelfId() override { return self_id; } void saveIdentity(const mesh::LocalIdentity& new_id) override; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3ec..fa7b04ad89 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -67,6 +67,14 @@ class Radio { virtual void setCADEnabled(bool enable) { } + /** + * \brief query CAD auto-calibration state. + * \param peak_offset current detPeak offset above the radio's default + * \param hits/count ambient CAD detections in the last evaluated probe window + * \returns false if this radio doesn't support CAD detPeak tuning. + */ + virtual bool getCADCalibState(uint8_t& peak_offset, uint8_t& hits, uint8_t& count) const { return false; } + virtual void resetAGC() { } virtual bool isInRecvMode() const = 0; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index c11f37cacf..fe3dd9ad6b 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -27,7 +27,7 @@ uint8_t Mesh::getExtraAckTransmitCount() const { } uint32_t Mesh::getCADFailRetryDelay() const { - return _rng->nextInt(1, 4)*120; + return 120 + _rng->nextInt(0, 241); // continuous 120..360ms, so nearby nodes don't retry in lock-step } int Mesh::searchPeersByHash(const uint8_t* hash) { diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c95e3e34b0..b71417191b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -811,7 +811,13 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else if (memcmp(config, "int.thresh", 10) == 0) { sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold); } else if (memcmp(config, "cad", 3) == 0) { - sprintf(reply, "> %s", _prefs->cad_enabled ? "on" : "off"); + uint8_t peak_offset, hits, count; + mesh::Radio* radio = _callbacks->getRadio(); + if (_prefs->cad_enabled && radio != NULL && radio->getCADCalibState(peak_offset, hits, count)) { + sprintf(reply, "> on (detPeak +%d, ambient %d/%d)", (int)peak_offset, (int)hits, (int)count); + } else { + sprintf(reply, "> %s", _prefs->cad_enabled ? "on" : "off"); + } } else if (memcmp(config, "agc.reset.interval", 18) == 0) { sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4); } else if (memcmp(config, "multi.acks", 10) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index f3abcf4772..0ee1bc4427 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -81,6 +81,7 @@ class CommonCLICallbacks { virtual void eraseLogFile() = 0; virtual void dumpLogFile() = 0; virtual void setTxPower(int8_t power_dbm) = 0; + virtual mesh::Radio* getRadio() { return NULL; } virtual void formatNeighborsReply(char *reply) = 0; virtual void removeNeighbor(const uint8_t* pubkey, int key_len) { // no op by default diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index 851fd644b1..d916dc7a03 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -30,6 +30,7 @@ class CustomLLCC68Wrapper : public RadioLibWrapper { return packetScoreInt(snr, sf, packet_len); } uint8_t getSpreadingFactor() const override { return ((CustomLLCC68 *)_radio)->spreadingFactor; } + uint8_t getCADDetPeakBase() const override { return getSpreadingFactor() + 13; } // Semtech DS.SX1261-2 recommended detPeak void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index c6b1acb472..1c954eb018 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -40,6 +40,12 @@ class CustomLR1110Wrapper : public RadioLibWrapper { float getLastSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); } uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } + uint8_t getCADDetPeakBase() const override { + // RadioLib LR11x0 default detPeak per SF5..12 (different scale than SX126x) + static const uint8_t detPeakValues[8] = { 48, 48, 50, 55, 55, 59, 61, 65 }; + uint8_t sf = getSpreadingFactor(); + return (sf >= 5 && sf <= 12) ? detPeakValues[sf - 5] : 0; + } bool setRxBoostedGainMode(bool en) override { return ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index 97bf6820d6..c0f962852a 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -31,6 +31,7 @@ class CustomSTM32WLxWrapper : public RadioLibWrapper { return packetScoreInt(snr, sf, packet_len); } uint8_t getSpreadingFactor() const override { return ((CustomSTM32WLx *)_radio)->spreadingFactor; } + uint8_t getCADDetPeakBase() const override { return getSpreadingFactor() + 13; } // Semtech DS.SX1261-2 recommended detPeak void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } }; diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 1d103f5790..657adae9c8 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -34,6 +34,7 @@ class CustomSX1262Wrapper : public RadioLibWrapper { return packetScoreInt(snr, sf, packet_len); } uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; } + uint8_t getCADDetPeakBase() const override { return getSpreadingFactor() + 13; } // Semtech DS.SX1261-2 recommended detPeak virtual void powerOff() override { ((CustomSX1262 *)_radio)->sleep(false); } diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index bce56b9963..2aab9339e0 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -34,6 +34,7 @@ class CustomSX1268Wrapper : public RadioLibWrapper { return packetScoreInt(snr, sf, packet_len); } uint8_t getSpreadingFactor() const override { return ((CustomSX1268 *)_radio)->spreadingFactor; } + uint8_t getCADDetPeakBase() const override { return getSpreadingFactor() + 13; } // Semtech DS.SX1261-2 recommended detPeak void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 5e72336c05..1210b1260f 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -11,6 +11,14 @@ #define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 +// ambient CAD auto-calibration: probe CAD periodically while idle, and raise/lower detPeak +// so ambient activity (sub-decode-threshold distant traffic, interference) stops tripping LBT +#define CAD_PROBE_INTERVAL 4000 // millis between ambient CAD probes +#define CAD_PROBE_WINDOW 32 // probes per evaluation window +#define CAD_PROBE_RAISE_HITS 10 // >= ~30% ambient detect rate -> desensitize +#define CAD_PROBE_LOWER_HITS 3 // <= ~10% ambient detect rate -> re-sensitize +#define CAD_PEAK_OFFSET_MAX 10 + static volatile uint8_t state = STATE_IDLE; // this function is called when a complete packet @@ -37,6 +45,10 @@ void RadioLibWrapper::begin() { _noise_floor = 0; _threshold = 0; _cad_enabled = false; + _cad_peak_offset = 0; + _cad_probe_count = _cad_probe_hits = 0; + _cad_last_count = _cad_last_hits = 0; + _last_cad_probe = 0; // start average out some samples _num_floor_samples = 0; @@ -102,6 +114,44 @@ void RadioLibWrapper::loop() { MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); } + + if (_cad_enabled && state == STATE_RX && getCADDetPeakBase() > 0 + && millis() - _last_cad_probe >= CAD_PROBE_INTERVAL) { + _last_cad_probe = millis(); + if (!isReceivingPacket()) { + int16_t result = performChannelScan(); + // CAD-done DIO interrupt sets STATE_INT_READY via setFlag() ISR. Clear it + // before restarting RX so recvRaw() doesn't try to read a non-existent packet. + state = STATE_IDLE; + startRecv(); + + if (result == RADIOLIB_CHANNEL_FREE) { + _cad_probe_count++; + } else if (result == RADIOLIB_LORA_DETECTED || result == RADIOLIB_PREAMBLE_DETECTED) { + _cad_probe_count++; + _cad_probe_hits++; + } + // else: scan error, don't count the sample + + if (_cad_probe_hits >= CAD_PROBE_RAISE_HITS) { // ambient rate too high, don't wait out the window + if (_cad_peak_offset < CAD_PEAK_OFFSET_MAX) { + _cad_peak_offset++; + MESH_DEBUG_PRINTLN("RadioLibWrapper: CAD ambient %d/%d, detPeak offset -> %d", + (int)_cad_probe_hits, (int)_cad_probe_count, (int)_cad_peak_offset); + } + _cad_last_hits = _cad_probe_hits; _cad_last_count = _cad_probe_count; + _cad_probe_count = _cad_probe_hits = 0; + } else if (_cad_probe_count >= CAD_PROBE_WINDOW) { + if (_cad_probe_hits <= CAD_PROBE_LOWER_HITS && _cad_peak_offset > 0) { + _cad_peak_offset--; + MESH_DEBUG_PRINTLN("RadioLibWrapper: CAD ambient %d/%d, detPeak offset -> %d", + (int)_cad_probe_hits, (int)_cad_probe_count, (int)_cad_peak_offset); + } + _cad_last_hits = _cad_probe_hits; _cad_last_count = _cad_probe_count; + _cad_probe_count = _cad_probe_hits = 0; + } + } + } } void RadioLibWrapper::startRecv() { @@ -180,6 +230,21 @@ void RadioLibWrapper::onSendFinished() { } int16_t RadioLibWrapper::performChannelScan() { + uint8_t peak_base = getCADDetPeakBase(); + if (_cad_peak_offset > 0 && peak_base > 0) { + ChannelScanConfig_t cfg = { + .cad = { + .symNum = 0xFF, // 0xFF -> radio-specific default (RADIOLIB_*_CAD_PARAM_DEFAULT) + .detPeak = (uint8_t)(peak_base + _cad_peak_offset), + .detMin = 0xFF, + .exitMode = 0xFF, + .timeout = 0, + .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, + .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK, + }, + }; + return _radio->scanChannel(cfg); + } return _radio->scanChannel(); } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 3091832f11..ec039f4d52 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -13,6 +13,10 @@ class RadioLibWrapper : public mesh::Radio { uint16_t _num_floor_samples; int32_t _floor_sample_sum; uint8_t _preamble_sf; + uint8_t _cad_peak_offset; + uint8_t _cad_probe_count, _cad_probe_hits; + uint8_t _cad_last_count, _cad_last_hits; + unsigned long _last_cad_probe; void idle(); void startRecv(); @@ -48,6 +52,18 @@ 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)); } virtual int16_t performChannelScan(); + virtual uint8_t getCADDetPeakBase() const { return 0; } // 0 = CAD detPeak tuning not supported + + bool getCADCalibState(uint8_t& peak_offset, uint8_t& hits, uint8_t& count) const override { + if (getCADDetPeakBase() == 0) return false; + peak_offset = _cad_peak_offset; + if (_cad_last_count > 0) { + hits = _cad_last_hits; count = _cad_last_count; + } else { // no probe window evaluated yet, report the one in progress + hits = _cad_probe_hits; count = _cad_probe_count; + } + return true; + } int getNoiseFloor() const override { return _noise_floor; } void triggerNoiseFloorCalibrate(int threshold) override;