SmartCAD p1

This commit is contained in:
liquidraver committed 2026-07-12 10:48:37 +02:00
1 parent 0d70256d2e
commit dc1239537e
36 files changed
+1154 -24

No files matched your search

@@ -693,6 +693,31 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs)
} else {
prefs.v_battery_alert_mv = 0xFFFF;
}
/* Offset 155: cad_auto (ZephCore extension, default 0 = dry-run) */
if (off < len) {
prefs.cad_auto = buf[off++];
if (prefs.cad_auto > 1) {
prefs.cad_auto = 0;
}
}
/* Offset 156: cad_offset (ZephCore extension, signed -4..4, default 0) */
if (off < len) {
prefs.cad_offset = (int8_t)buf[off++];
if (prefs.cad_offset < -4 || prefs.cad_offset > 4) {
prefs.cad_offset = 0;
}
}
/* Offset 157: cad_probe_interval (ZephCore extension, seconds; 0 = off).
* Absent in pre-existing files → keep the in-RAM default (60). */
if (off < len) {
prefs.cad_probe_interval = buf[off++];
if (prefs.cad_probe_interval != 0 && prefs.cad_probe_interval < 10) {
prefs.cad_probe_interval = 10;
}
}
}
void ZephyrDataStore::savePrefs(const NodePrefs &prefs)
@@ -774,7 +799,13 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs)
/* Offset 153: v_battery_alert_mv (ZephCore extension, 2 bytes LE) */
buf[off++] = prefs.v_battery_alert_mv & 0xFF;
buf[off++] = (prefs.v_battery_alert_mv >> 8) & 0xFF;
/* Total: 155 bytes */
/* Offset 155: cad_auto (ZephCore extension) */
buf[off++] = prefs.cad_auto;
/* Offset 156: cad_offset (ZephCore extension, signed) */
buf[off++] = (uint8_t)prefs.cad_offset;
/* Offset 157: cad_probe_interval (ZephCore extension, seconds) */
buf[off++] = prefs.cad_probe_interval;
/* Total: 158 bytes */
bool ok = atomicReplaceFile(PREFS_FILE, buf, off);
LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'",
+15
View File
@@ -84,4 +84,19 @@ uint32_t LR1110Radio::hwWakeupTimeUs()
return lr11xx_get_wakeup_time_us(_dev);
}
int LR1110Radio::hwCadProbe(int8_t level)
{
return lr11xx_cad_probe(_dev, level);
}
void LR1110Radio::hwCadSetPeakOffset(int8_t offset)
{
lr11xx_cad_set_peak_offset(_dev, offset);
}
uint8_t LR1110Radio::hwCadBasePeak()
{
return lr11xx_cad_base_peak(_dev);
}
} /* namespace mesh */
+3
View File
@@ -29,6 +29,9 @@ protected:
void hwSetRxBoost(bool enable) override;
void hwResetAGC() override;
uint32_t hwWakeupTimeUs() override;
int hwCadProbe(int8_t level) override;
void hwCadSetPeakOffset(int8_t offset) override;
uint8_t hwCadBasePeak() override;
};
} /* namespace mesh */
+15
View File
@@ -78,4 +78,19 @@ void LR2021Radio::hwResetAGC()
lr20xx_reset_agc(_dev);
}
int LR2021Radio::hwCadProbe(int8_t level)
{
return lr20xx_cad_probe(_dev, level);
}
void LR2021Radio::hwCadSetPeakOffset(int8_t offset)
{
lr20xx_cad_set_peak_offset(_dev, offset);
}
uint8_t LR2021Radio::hwCadBasePeak()
{
return lr20xx_cad_base_peak(_dev);
}
} /* namespace mesh */
+3
View File
@@ -28,6 +28,9 @@ protected:
bool hwIsReceiving() override;
void hwSetRxBoost(bool enable) override;
void hwResetAGC() override;
int hwCadProbe(int8_t level) override;
void hwCadSetPeakOffset(int8_t offset) override;
uint8_t hwCadBasePeak() override;
};
} /* namespace mesh */
+238
View File
@@ -9,6 +9,7 @@
#include <zephyr/kernel.h>
#include <zephyr/random/random.h>
#include <string.h>
#include <stdio.h>
#include <math.h>
@@ -46,6 +47,8 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board,
_last_rssi(0), _last_snr(0),
_rx_head(0), _rx_tail(0),
_noise_floor(DEFAULT_NOISE_FLOOR), _calibration_threshold(0), _ema_unguarded(0),
_cad_auto(false), _cad_offset(0), _cad_probe_interval_s(0),
_cad_last_probe_ms(0), _cad_last_decay_ms(0), _cad_probe_rr(0),
_rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)),
_rx_boost_enabled(true),
_tx_power_reduction_db(0),
@@ -62,6 +65,7 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board,
k_poll_signal_init(&_tx_signal);
k_sem_init(&_tx_start_sem, 0, 1);
memset(_rx_ring, 0, sizeof(_rx_ring));
memset(_cad_stats, 0, sizeof(_cad_stats));
}
/* ── TX wait thread ──────────────────────────────────────────── */
@@ -246,6 +250,14 @@ void LoRaRadioBase::buildModemConfig(struct lora_modem_config &cfg, bool tx)
* direction-only fast path (which skips hwConfigure). RX paths
* never read cad.mode, so this is harmless during receive. */
cfg.cad.mode = LORA_CAD_MODE_LBT;
/* 4-symbol CAD at every SF (drivers default to 2 when this is 0).
* Our LBT runs against mesh packets that are mostly payload airtime;
* payload chirps correlate less reliably per symbol than preamble
* upchirps, so the extra looks matter — AN1200.48 itself recommends
* 4 symbols at SF9+. The drivers scale their blocking-CAD timeout
* from this value, so slow presets stay covered. */
cfg.cad.symbol_num = LORA_CAD_SYMB_4;
}
uint32_t LoRaRadioBase::getActiveFrequencyHz() const
@@ -446,6 +458,8 @@ void LoRaRadioBase::reconfigure()
hwCancelReceive();
atomic_set(&_in_recv_mode, 0);
_config_cached = false; /* Force full reconfigure */
/* CAD probe statistics are only valid for one freq/SF/BW config. */
resetCadStats();
startReceive();
uint32_t freq = _prefs ? (uint32_t)(_prefs->freq * 1000000.0f)
@@ -931,6 +945,230 @@ bool LoRaRadioBase::isChannelActive(int threshold)
return rssi > (_noise_floor + threshold);
}
/* ── Adaptive CAD (LBT detPeak calibration) ───────────────────────────── */
void LoRaRadioBase::setCadParams(bool auto_enabled, int8_t offset,
uint16_t probe_interval_s)
{
if (offset < CAD_LEVEL_MIN) offset = CAD_LEVEL_MIN;
if (offset > CAD_LEVEL_MAX) offset = CAD_LEVEL_MAX;
_cad_auto = auto_enabled;
_cad_offset = offset;
_cad_probe_interval_s = probe_interval_s;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: auto=%d offset=%d probe_interval=%us",
(int)auto_enabled, (int)offset, (unsigned)probe_interval_s);
}
void LoRaRadioBase::resetCadStats()
{
memset(_cad_stats, 0, sizeof(_cad_stats));
_cad_probe_rr = 0;
}
void LoRaRadioBase::decayCadStats()
{
for (int i = 0; i < CAD_NUM_LEVELS; i++) {
_cad_stats[i].probes >>= 1;
_cad_stats[i].busy >>= 1;
_cad_stats[i].fp >>= 1;
_cad_stats[i].tp >>= 1;
}
}
int8_t LoRaRadioBase::pickCadProbeLevel()
{
_cad_probe_rr++;
if (!_cad_auto) {
/* Dry-run: even sweep across the observation window so the
* user sees the whole FP-vs-detPeak curve in `get cad`. */
int span = CAD_SWEEP_MAX - CAD_SWEEP_MIN + 1;
return (int8_t)(CAD_SWEEP_MIN + (_cad_probe_rr % span));
}
/* Auto: concentrate samples on the frontier (one step more sensitive
* than the operating point); every 4th probe self-checks the
* operating level. */
int8_t frontier = _cad_offset > CAD_LEVEL_MIN ? (int8_t)(_cad_offset - 1)
: _cad_offset;
return ((_cad_probe_rr & 3) == 0) ? _cad_offset : frontier;
}
void LoRaRadioBase::cadStaircaseStep()
{
/* Step down (more sensitive) when the frontier level has enough
* samples and its suspected-FP rate is at or under target. */
if (_cad_offset > CAD_LEVEL_MIN) {
CadLevelStats &f = _cad_stats[(_cad_offset - 1) - CAD_LEVEL_MIN];
if (f.probes >= CAD_STEP_DOWN_MIN_PROBES &&
(uint32_t)f.fp * 1000U <=
(uint32_t)f.probes * CAD_FP_TARGET_PERMILLE) {
_cad_offset--;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: step down -> offset %d (frontier %up/%ufp)",
(int)_cad_offset, f.probes, f.fp);
return;
}
}
/* Step up (less sensitive) when the operating level itself shows
* FPs well above target. Lower sample bar: FPs at the operating
* point cost real TX opportunities, react quickly. */
if (_cad_offset < CAD_LEVEL_MAX) {
CadLevelStats &o = _cad_stats[_cad_offset - CAD_LEVEL_MIN];
if (o.probes >= CAD_STEP_UP_MIN_PROBES &&
(uint32_t)o.fp * 1000U >
(uint32_t)o.probes * 2U * CAD_FP_TARGET_PERMILLE) {
_cad_offset++;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: step up -> offset %d (operating %up/%ufp)",
(int)_cad_offset, o.probes, o.fp);
}
}
}
void LoRaRadioBase::cadMaintenance()
{
if (_cad_probe_interval_s == 0) {
return;
}
int64_t now = k_uptime_get();
/* Periodic decay keeps the stats fresh (and counters bounded). */
if (_cad_last_decay_ms == 0) {
_cad_last_decay_ms = now;
} else if (now - _cad_last_decay_ms > (int64_t)CAD_STATS_DECAY_MS) {
decayCadStats();
_cad_last_decay_ms = now;
}
if (now - _cad_last_probe_ms < (int64_t)_cad_probe_interval_s * 1000) {
return;
}
/* Same guards as the noise-floor calibrator: only probe from idle
* continuous/duty-cycle RX, never during TX or an active packet,
* never while the chip is in its duty-cycle sleep (BUSY) phase. */
if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) {
return;
}
if (!isRadioReady() || isReceiving()) {
return;
}
/* Ground-truth prefilter: skip when the channel is visibly busy —
* a busy verdict against strong traffic teaches us nothing about
* false positives. (Below-noise-floor LoRa can't be excluded here;
* the post-probe RX check below handles that side.) */
int16_t rssi = hwGetCurrentRSSI();
if (rssi == -128) {
return;
}
if (_noise_floor != DEFAULT_NOISE_FLOOR &&
rssi > _noise_floor + CAD_PROBE_RSSI_GUARD) {
return;
}
_cad_last_probe_ms = now;
int8_t level = pickCadProbeLevel();
int ret = hwCadProbe(level);
/* The probe leaves the chip in STANDBY (driver state REST) — re-arm
* RX immediately so an incoming packet isn't lost while we classify.
* In duty-cycle mode this re-enters the DC cycle (same path as the
* parked-RX watchdog re-arm). */
atomic_set(&_in_recv_mode, 0);
startReceive();
if (ret < 0) {
if (ret != -ENOSYS) {
LOG_WRN("cad: probe failed (%d)", ret);
}
return;
}
CadLevelStats &s = _cad_stats[level - CAD_LEVEL_MIN];
if (s.probes >= 0xFFF0) {
decayCadStats();
}
s.probes++;
if (ret > 0) {
s.busy++;
/* Ground-truth post-check: a real LoRa signal that tripped
* CAD keeps transmitting — after RX restart its preamble or
* header trips the receive path within a few symbols. Wait
* ~8 symbols, then classify. RX is already armed, so the
* packet itself is not at risk during this sleep. */
uint8_t sf = getActiveSpreadingFactor();
uint16_t bw_x10 = getActiveBandwidthKHzX10();
uint32_t tsym_us = bw_x10 ? (uint32_t)(((1UL << sf) * 10000UL)
/ bw_x10) : 1024;
uint32_t wait_ms = (8U * tsym_us) / 1000U;
if (wait_ms < 20) wait_ms = 20;
if (wait_ms > 400) wait_ms = 400;
k_sleep(K_MSEC(wait_ms));
if (isReceiving()) {
s.tp++;
} else {
s.fp++;
}
}
if (_cad_auto) {
cadStaircaseStep();
}
}
int LoRaRadioBase::formatCadStatus(char *buf, int cap)
{
uint8_t base = hwCadBasePeak();
int n = 0;
if (base == 0) {
return snprintf(buf, cap, "CAD: not supported by this radio");
}
int peak = (int)base + _cad_offset;
n += snprintf(buf + n, cap > n ? cap - n : 0,
"auto:%s offset:%d peak:%d (base %u, 4 sym) probe:%us",
_cad_auto ? "on" : "off", (int)_cad_offset, peak,
base, (unsigned)_cad_probe_interval_s);
for (int i = 0; i < CAD_NUM_LEVELS; i++) {
CadLevelStats &s = _cad_stats[i];
if (s.probes == 0) {
continue;
}
/* FP rate in tenths of a percent */
uint32_t fp_pm = ((uint32_t)s.fp * 1000U) / s.probes;
n += snprintf(buf + n, cap > n ? cap - n : 0,
"\nlvl %+d (peak %d): %u probes, %u busy, %u fp, %u tp (fp %u.%u%%)",
i + CAD_LEVEL_MIN, (int)base + i + CAD_LEVEL_MIN,
s.probes, s.busy, s.fp, s.tp,
(unsigned)(fp_pm / 10), (unsigned)(fp_pm % 10));
}
return n;
}
/* ── Power saving ─────────────────────────────────────────────────────── */
void LoRaRadioBase::enableRxDutyCycle(bool enable)
+39
View File
@@ -109,6 +109,14 @@ public:
void setTxPowerReduction(int8_t reduction_db) override { _tx_power_reduction_db = reduction_db; }
int8_t getTxPowerReduction() const override { return _tx_power_reduction_db; }
/* Adaptive CAD (LBT detPeak calibration) */
void setCadParams(bool auto_enabled, int8_t offset,
uint16_t probe_interval_s) override;
void cadMaintenance() override;
int8_t getCadOffset() const override { return _cad_offset; }
void resetCadStats() override;
int formatCadStatus(char *buf, int cap) override;
protected:
/* ── Hardware primitives — subclass MUST implement ─────────── */
@@ -126,6 +134,18 @@ protected:
/** GPIO-only BUSY check (no SPI). Default false for chips without duty-cycle sleep. */
virtual bool hwIsChipBusy() { return false; }
/* ── Adaptive-CAD primitives — defaults suit chips without hardware
* CAD (SX127x): probing unsupported, offset ignored. ───────────── */
/** Blocking calibration CAD at (family base detPeak + level).
* Leaves the chip in STANDBY; caller restarts RX.
* Returns 1 = busy, 0 = free, <0 = error / unsupported. */
virtual int hwCadProbe(int8_t level) { (void)level; return -ENOSYS; }
/** Apply the operating detPeak offset for all subsequent LBT CADs. */
virtual void hwCadSetPeakOffset(int8_t offset) { (void)offset; }
/** Per-SF base detPeak for the current config (0 = unsupported). */
virtual uint8_t hwCadBasePeak() { return 0; }
/** Radio deaf time per duty-cycle wake transition (context restore +
* PLL lock + TCXO startup where fitted), in microseconds. Counts
* against the duty-cycle preamble-catch budget: per SX126x DS rev 2.2
@@ -180,6 +200,25 @@ protected:
int _calibration_threshold;
uint8_t _ema_unguarded; /* tick counter for warmup + periodic bypass */
/* Adaptive CAD state */
struct CadLevelStats {
uint16_t probes; /* probes run at this level */
uint16_t busy; /* raw busy verdicts */
uint16_t fp; /* busy that passed the ground-truth filter (suspected false positive) */
uint16_t tp; /* busy confirmed by RX activity right after */
};
CadLevelStats _cad_stats[CAD_NUM_LEVELS];
bool _cad_auto; /* staircase acts on the stats */
int8_t _cad_offset; /* operating detPeak offset (levels) */
uint16_t _cad_probe_interval_s; /* 0 = probing disabled */
int64_t _cad_last_probe_ms;
int64_t _cad_last_decay_ms;
uint8_t _cad_probe_rr; /* round-robin index (sweep) / frontier mix counter */
int8_t pickCadProbeLevel();
void decayCadStats();
void cadStaircaseStep();
/* Power saving */
bool _rx_duty_cycle_enabled;
bool _rx_boost_enabled;
+15
View File
@@ -96,6 +96,21 @@ uint32_t SX126xRadio::hwWakeupTimeUs()
return sx126x_get_wakeup_time_us(_dev);
}
int SX126xRadio::hwCadProbe(int8_t level)
{
return sx126x_cad_probe(_dev, level);
}
void SX126xRadio::hwCadSetPeakOffset(int8_t offset)
{
sx126x_cad_set_peak_offset(_dev, offset);
}
uint8_t SX126xRadio::hwCadBasePeak()
{
return sx126x_cad_base_peak(_dev);
}
uint32_t SX126xRadio::getDutyCycleTimeoutRestarts() const
{
return sx126x_get_dc_timeout_restarts(_dev);
+3
View File
@@ -32,6 +32,9 @@ protected:
void hwResetAGC() override;
bool hwIsChipBusy() override;
uint32_t hwWakeupTimeUs() override;
int hwCadProbe(int8_t level) override;
void hwCadSetPeakOffset(int8_t offset) override;
uint8_t hwCadBasePeak() override;
};
} /* namespace mesh */
+17
View File
@@ -20,6 +20,23 @@
#define NOISE_FLOOR_SAMPLING_THRESHOLD 14 /* dB above floor to reject as interference */
#define DEFAULT_NOISE_FLOOR 0 /* sentinel: seed from first sample */
/* --- Adaptive CAD (LBT detPeak calibration) ---
* Housekeeping-tick CAD probes accumulate per-level busy/free statistics;
* a one-sided staircase converges on the lowest detPeak offset whose
* false-positive rate stays under target. Levels are signed offsets from
* the chip family's per-SF base detPeak (SX126x: SF+13; LR11xx/LR20xx:
* 56-68 table) so the C++ layer stays scale-independent. */
#define CAD_LEVEL_MIN (-4) /* most sensitive probe level */
#define CAD_LEVEL_MAX 4 /* least sensitive probe level */
#define CAD_NUM_LEVELS (CAD_LEVEL_MAX - CAD_LEVEL_MIN + 1)
#define CAD_SWEEP_MIN (-3) /* dry-run sweep window */
#define CAD_SWEEP_MAX 2
#define CAD_FP_TARGET_PERMILLE 10 /* step-down needs FP rate <= 1% */
#define CAD_STEP_DOWN_MIN_PROBES 300 /* samples before a down-step call */
#define CAD_STEP_UP_MIN_PROBES 50 /* samples before an up-step call */
#define CAD_PROBE_RSSI_GUARD 7 /* dB above floor = channel visibly busy, skip probe */
#define CAD_STATS_DECAY_MS (6UL * 3600UL * 1000UL) /* halve counters every 6 h */
/* --- RX ring buffer --- */
#define RX_RING_SIZE 8 /* ~2 KB; buffers burst arrivals at SF7/BW500 */