From c00642f7ca1e78fc992469efa7f57cdf73145449 Mon Sep 17 00:00:00 2001
From: Wessel Nieboer <wessel@weebl.me>
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;
