Files
ZephCore/zephcore/adapters/radio/LoRaRadioBase.cpp
T

1532 lines
54 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* SPDX-License-Identifier: MIT
* LoRa radio base class — shared algorithms for all radio adapters.
*/
#include "LoRaRadioBase.h"
#include "radio_common.h"
#include <mesh/LoRaConfig.h>
#include <zephyr/kernel.h>
#include <zephyr/random/random.h>
#include <string.h>
#include <stdio.h>
#include <math.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(lora_radio_base, CONFIG_ZEPHCORE_LORA_LOG_LEVEL);
namespace mesh {
static uint16_t preambleLengthForSF(uint8_t sf)
{
/* PR #1954 parity: longer preamble for lower SF. */
return (sf <= 8) ? 32 : 16;
}
/* Minimum preamble symbols that must land inside one open duty-cycle RX
* window for guaranteed detection. 8 is Semtech's own figure for sniff
* mode (AN1200.36 §4: "8 symbols in LoRa make up the time required to
* ensure that the SX1261/2 detects a valid incoming packet"); their
* time-synced LoRaWAN stacks budget 6, so 8 already carries margin.
* SF5/6 need more symbols to reach sensitivity (RadioLib/LBM use 12). */
static uint16_t rxDutyDetectSymbols(uint8_t sf)
{
uint16_t d = CONFIG_ZEPHCORE_LORA_DC_MIN_SYMBOLS;
return (sf >= 7) ? d : (uint16_t)(d + 4);
}
/* ── Constructor ─────────────────────────────────────────────── */
LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board,
NodePrefs *prefs)
: _loramac_node(false),
_dev(lora_dev), _prefs(prefs), _board(&board),
_in_recv_mode(0), _tx_active(0),
_last_rssi(0), _last_snr(0),
_rx_head(0), _rx_tail(0),
_noise_floor(DEFAULT_NOISE_FLOOR), _calibration_threshold(0), _ema_unguarded(0),
_noise_floor_next_ms(0), _noise_floor_retries(0),
_measure_interval_ms(CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS),
_sample_rssi(0), _sample_channel_quiet(false), _sample_fresh(false),
_rx_entry_cyc(0),
_rssi_bursts(0), _rssi_spread_sum(0), _rssi_degenerate(0),
_cad_auto(false), _cad_offset(0), _probe_interval_s(0),
_cad_busycap_pct(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),
_dc_last_rx_us(0), _dc_last_sleep_us(0),
_config_cached(false),
_has_radio_override(false),
_override_freq(0), _override_bw(0),
_override_sf(0), _override_cr(0),
_rx_cb(nullptr), _rx_cb_user_data(nullptr),
_tx_done_cb(nullptr), _tx_done_cb_user_data(nullptr),
_tx_thread_running(false),
_packets_recv(0), _packets_sent(0), _packets_recv_errors(0)
{
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 ──────────────────────────────────────────── */
void LoRaRadioBase::txWaitThreadFn(void *p1, void *p2, void *p3)
{
LoRaRadioBase *self = static_cast<LoRaRadioBase *>(p1);
ARG_UNUSED(p2);
ARG_UNUSED(p3);
LOG_INF("TX wait thread started");
for (;;) {
k_sem_take(&self->_tx_start_sem, K_FOREVER);
if (!atomic_get(&self->_tx_active)) {
continue;
}
LOG_DBG("TX wait: waiting for signal...");
struct k_poll_event events[1] = {
K_POLL_EVENT_INITIALIZER(K_POLL_TYPE_SIGNAL,
K_POLL_MODE_NOTIFY_ONLY,
&self->_tx_signal),
};
unsigned int signaled;
int result;
k_poll_signal_check(&self->_tx_signal, &signaled, &result);
if (signaled) {
LOG_DBG("TX wait: signal already raised (result=%d)", result);
k_poll_signal_reset(&self->_tx_signal);
self->_board->onAfterTransmit();
self->startReceive();
atomic_set(&self->_tx_active, 0);
atomic_inc(&self->_packets_sent);
if (self->_tx_done_cb) {
self->_tx_done_cb(self->_tx_done_cb_user_data);
}
continue;
}
int ret = k_poll(events, 1, K_MSEC(TX_TIMEOUT_MS));
if (ret == -EAGAIN) {
LOG_ERR("TX wait: TIMEOUT!");
self->_board->onAfterTransmit();
self->startReceive();
atomic_set(&self->_tx_active, 0);
if (self->_tx_done_cb) {
self->_tx_done_cb(self->_tx_done_cb_user_data);
}
continue;
}
if (ret == 0 && events[0].state == K_POLL_STATE_SIGNALED) {
k_poll_signal_reset(&self->_tx_signal);
self->_board->onAfterTransmit();
self->startReceive();
atomic_set(&self->_tx_active, 0);
atomic_inc(&self->_packets_sent);
LOG_INF("TX complete, RX restarted");
if (self->_tx_done_cb) {
self->_tx_done_cb(self->_tx_done_cb_user_data);
}
} else {
LOG_ERR("TX wait: k_poll returned %d, state=%d — recovering",
ret, events[0].state);
k_poll_signal_reset(&self->_tx_signal);
self->_board->onAfterTransmit();
self->startReceive();
atomic_set(&self->_tx_active, 0);
if (self->_tx_done_cb) {
self->_tx_done_cb(self->_tx_done_cb_user_data);
}
}
}
}
void LoRaRadioBase::startTxThread(k_thread_stack_t *stack, size_t stack_size)
{
if (_tx_thread_running) {
return;
}
k_thread_create(&_tx_wait_thread, stack, stack_size,
txWaitThreadFn, this, NULL, NULL,
TX_WAIT_THREAD_PRIORITY, 0, K_NO_WAIT);
k_thread_name_set(&_tx_wait_thread, "lora_tx_wait");
_tx_thread_running = true;
}
/* ── RX callback (static, ISR-safe) ──────────────────────────────────── */
void LoRaRadioBase::rxCallbackStatic(const struct device *dev, uint8_t *data,
uint16_t size, int16_t rssi, int8_t snr,
void *user_data)
{
LoRaRadioBase *self = static_cast<LoRaRadioBase *>(user_data);
/* NULL data = RX error (CRC/header error) */
if (data == NULL && size == 0) {
atomic_inc(&self->_packets_recv_errors);
LOG_DBG("RX error (CRC/header), total errors: %u",
(uint32_t)atomic_get(&self->_packets_recv_errors));
return;
}
LOG_DBG("RX callback: size=%u rssi=%d snr=%d", size, rssi, snr);
/* Ring buffer write — SPSC: only ISR writes _rx_head, only main
* thread writes _rx_tail. On overflow, drop the NEW packet to
* preserve this invariant (ISR must never touch _rx_tail). */
uint8_t head = (uint8_t)atomic_get(&self->_rx_head);
uint8_t next_head = (head + 1) % RX_RING_SIZE;
if (next_head == (uint8_t)atomic_get(&self->_rx_tail)) {
LOG_WRN("RX ring full, dropping new packet");
atomic_inc(&self->_packets_recv_errors);
if (self->_rx_cb) {
self->_rx_cb(self->_rx_cb_user_data);
}
return;
}
RxPacket *pkt = &self->_rx_ring[head];
uint16_t copy_len = (size > sizeof(pkt->data)) ? sizeof(pkt->data) : size;
memcpy(pkt->data, data, copy_len);
pkt->len = copy_len;
pkt->rssi = rssi;
pkt->snr = snr;
atomic_set(&self->_rx_head, next_head);
self->_last_rssi = (float)rssi;
self->_last_snr = (float)snr;
atomic_inc(&self->_packets_recv);
if (self->_rx_cb) {
self->_rx_cb(self->_rx_cb_user_data);
}
}
/* ── Config helpers ───────────────────────────────────────────────────── */
void LoRaRadioBase::buildModemConfig(struct lora_modem_config &cfg, bool tx)
{
memset(&cfg, 0, sizeof(cfg));
/* Override wins for freq/bw/sf/cr (tempradio). Power, preamble, and
* other fields still come from _prefs. */
float freq_mhz = _has_radio_override ? _override_freq
: (_prefs ? _prefs->freq : (LoRaConfig::FREQ_HZ / 1000000.0f));
float bw_khz = _has_radio_override ? _override_bw
: (_prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH);
uint8_t sf = _has_radio_override ? _override_sf
: (_prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR);
uint8_t cr = _has_radio_override ? _override_cr
: (_prefs ? _prefs->cr : LoRaConfig::CODING_RATE);
cfg.frequency = (uint32_t)(freq_mhz * 1000000.0f);
cfg.bandwidth = bw_khz_to_enum((uint16_t)bw_khz);
cfg.datarate = (enum lora_datarate)sf;
cfg.coding_rate = cr_to_enum(cr);
cfg.preamble_len = preambleLengthForSF(sf);
cfg.tx_power = _prefs ? (int8_t)_prefs->tx_power_dbm
: LoRaConfig::TX_POWER_DBM;
#ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM
if (cfg.tx_power > CONFIG_ZEPHCORE_MAX_TX_POWER_DBM) {
cfg.tx_power = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM;
}
#endif
if (cfg.tx_power < -9) cfg.tx_power = -9;
cfg.tx = tx;
cfg.iq_inverted = false;
cfg.public_network = false;
cfg.packet_crc_disable = false;
/* LBT: driver gates send_async on cad.mode == LBT.
* Set unconditionally so the value reaches the driver via the
* initial RX lora_config() call and survives configureTx()'s
* 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
{
float freq_mhz = _has_radio_override ? _override_freq
: (_prefs ? _prefs->freq : (LoRaConfig::FREQ_HZ / 1000000.0f));
return (uint32_t)(freq_mhz * 1000000.0f + 0.5f);
}
uint16_t LoRaRadioBase::getActiveBandwidthKHzX10() const
{
float bw_khz = _has_radio_override ? _override_bw
: (_prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH);
return (uint16_t)(bw_khz * 10.0f + 0.5f);
}
uint8_t LoRaRadioBase::getActiveSpreadingFactor() const
{
return _has_radio_override ? _override_sf
: (_prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR);
}
uint8_t LoRaRadioBase::getActiveCodingRate() const
{
return _has_radio_override ? _override_cr
: (_prefs ? _prefs->cr : LoRaConfig::CODING_RATE);
}
uint16_t LoRaRadioBase::getActivePreambleLength() const
{
return preambleLengthForSF(getActiveSpreadingFactor());
}
uint8_t LoRaRadioBase::getActiveSyncWord() const
{
/* buildModemConfig() currently sets public_network=false, which maps
* Zephyr's LoRa API to the Semtech private sync word. */
return 0x12;
}
int8_t LoRaRadioBase::getConfiguredTxPower() const
{
int power = _prefs ? _prefs->tx_power_dbm : LoRaConfig::TX_POWER_DBM;
#ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM
if (power > CONFIG_ZEPHCORE_MAX_TX_POWER_DBM) {
power = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM;
}
#endif
if (power < -9) {
power = -9;
}
return (int8_t)power;
}
/**
* Compare radio-relevant fields of two modem configs.
* Ignores the tx flag — that only selects TX vs RX mode, the actual
* modem parameters (freq, SF, BW, CR, power) are what the driver
* programs into registers.
*/
static bool configParamsEqual(const struct lora_modem_config &a,
const struct lora_modem_config &b)
{
/* CRITICAL: a.tx == b.tx MUST be compared — without it, switching
* RX→TX skips lora_config() for TX params, breaking transmit. */
return a.frequency == b.frequency &&
a.bandwidth == b.bandwidth &&
a.datarate == b.datarate &&
a.coding_rate == b.coding_rate &&
a.preamble_len == b.preamble_len &&
a.tx_power == b.tx_power &&
a.tx == b.tx &&
a.iq_inverted == b.iq_inverted &&
a.public_network == b.public_network &&
a.cad.mode == b.cad.mode;
}
/**
* Check if only the TX/RX direction changed (all radio params identical).
* Used to skip the full lora_config() call on TX↔RX transitions when
* the driver already has valid TX and RX configs from previous calls.
*/
static bool onlyDirectionDiffers(const struct lora_modem_config &a,
const struct lora_modem_config &b)
{
return a.frequency == b.frequency &&
a.bandwidth == b.bandwidth &&
a.datarate == b.datarate &&
a.coding_rate == b.coding_rate &&
a.preamble_len == b.preamble_len &&
a.tx_power == b.tx_power &&
a.iq_inverted == b.iq_inverted &&
a.public_network == b.public_network &&
a.cad.mode == b.cad.mode &&
a.tx != b.tx;
}
void LoRaRadioBase::configure(bool tx)
{
struct lora_modem_config cfg;
buildModemConfig(cfg, tx);
const char *who = tx ? "configureTx" : "configureRx";
if (_config_cached && configParamsEqual(cfg, _last_cfg)) {
LOG_DBG("%s: params unchanged, skipping hwConfigure", who);
return;
}
/* Fast path: if only the TX/RX direction changed, skip the full
* hwConfigure → lora_config() call. The driver already has a valid
* config for the target direction (RadioSetRxConfig / RadioSetTxConfig
* with TxTimeout=4000) from a previous cycle — Radio.Rx(0) / Radio.Send()
* will use those register values directly. This avoids the
* modem_acquire → modem_release → Radio.Sleep() round-trip that wastes
* ~5 ms on every TX↔RX transition.
*
* Not used for loramac-node: Radio.SetTxConfig() and Radio.SetRxConfig()
* configure completely disjoint internal state (including TxTimeout).
* Skipping either on a direction change leaves that state uninitialized. */
if (!_loramac_node && _config_cached && onlyDirectionDiffers(cfg, _last_cfg)) {
LOG_DBG("%s: direction-only change, skip hwConfigure", who);
_last_cfg = cfg;
return;
}
if (!tx) {
LOG_DBG("configureRx: freq=%u bw=%d sf=%d cr=%d pwr=%d",
cfg.frequency, (int)cfg.bandwidth, (int)cfg.datarate,
(int)cfg.coding_rate, cfg.tx_power);
}
if (hwConfigure(cfg)) {
_last_cfg = cfg;
_config_cached = true;
} else {
_config_cached = false;
}
}
void LoRaRadioBase::configureRx() { configure(false); }
void LoRaRadioBase::configureTx() { configure(true); }
/* ── Lifecycle ────────────────────────────────────────────────────────── */
void LoRaRadioBase::begin()
{
if (!device_is_ready(_dev)) {
LOG_ERR("LoRa device not ready");
return;
}
/* Subclass begin() calls startTxThread() before calling us.
*
* RX boost and duty cycle are set via constructor defaults:
* _rx_boost_enabled = true (boosted +3dB, overridable via setRxBoost())
* _rx_duty_cycle_enabled = CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE
* Callers can override after begin() via setRxBoost() / enableRxDutyCycle().
*/
startReceive();
/* Sync _rx_boost_enabled to the driver. The driver initialises its own
* rx_boost_enabled flag from DTS (rx-boosted property), which may differ
* from our constructor default (true). Push our intent now so the
* hardware state matches _rx_boost_enabled from the moment begin()
* returns, before the caller applies prefs via setRxBoost(). */
hwSetRxBoost(_rx_boost_enabled);
uint32_t freq = _prefs ? (uint32_t)(_prefs->freq * 1000000.0f)
: LoRaConfig::FREQ_HZ;
uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR;
uint16_t bw_khz = _prefs ? (uint16_t)(_prefs->bw)
: (uint16_t)LoRaConfig::BANDWIDTH;
uint8_t cr = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE;
int8_t tx_pwr = _prefs ? (int8_t)_prefs->tx_power_dbm
: LoRaConfig::TX_POWER_DBM;
LOG_INF("radio started: freq=%u bw=%u sf=%u cr=%u pwr=%d",
freq, bw_khz, sf, cr, tx_pwr);
}
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)
: LoRaConfig::FREQ_HZ;
uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR;
uint16_t bw_khz = _prefs ? (uint16_t)(_prefs->bw)
: (uint16_t)LoRaConfig::BANDWIDTH;
uint8_t cr = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE;
int8_t tx_pwr = _prefs ? (int8_t)_prefs->tx_power_dbm
: LoRaConfig::TX_POWER_DBM;
LOG_INF("radio reconfigured: freq=%u bw=%u sf=%u cr=%u pwr=%d",
freq, bw_khz, sf, cr, tx_pwr);
}
void LoRaRadioBase::reconfigureWithParams(float freq, float bw, uint8_t sf, uint8_t cr)
{
/* Callers (ObserverMesh CLI handlers) write to _prefs and call
* savePrefs() before invoking us — the radio just needs to pick up
* the new params. Tempradio uses setRadioOverride() instead so it
* never touches _prefs. */
(void)freq; (void)bw; (void)sf; (void)cr;
reconfigure();
}
void LoRaRadioBase::setRadioOverride(float freq, float bw, uint8_t sf, uint8_t cr)
{
_override_freq = freq;
_override_bw = bw;
_override_sf = sf;
_override_cr = cr;
_has_radio_override = true;
reconfigure();
}
void LoRaRadioBase::clearRadioOverride()
{
if (!_has_radio_override) {
return;
}
_has_radio_override = false;
reconfigure();
}
void LoRaRadioBase::startReceive()
{
configureRx();
int ret;
if (_rx_duty_cycle_enabled) {
/* Duty-cycle window sizing. All constraints primary-sourced
* (SX1261/2 DS rev 2.2 §13.1.7 + AN1200.36):
*
* 1. Catch — worst case is a preamble starting D−ε symbols
* before an RX window closes (detection aborts, must
* complete in the NEXT window), so the total deaf time per
* cycle (programmed sleep + wake transition) must satisfy
* sleep + trans ≤ (P 2D 1)·Tsym
* with 1 symbol margin for the chip's RC64k sleep timer.
* Stricter than AN1200.36's own "P ≥ sleep + D" model,
* which ignores the window-tail arrival case.
* 2. Complete — DS: "Tpreamble + Theader ≤ 2·rxPeriod +
* sleepPeriod". A preamble detected at its first symbols
* restarts the chip timer with 2R+S; that budget must cover
* the rest of the preamble + sync (4.25) + header (~8),
* rounded up to P + 14 symbols. The driver also sets
* StopTimerOnPreamble, but this sizing keeps packets safe
* under either documented timer behaviour.
* 3. Floor — rxPeriod ≥ (D+1)·Tsym so any single window can
* detect on its own.
*
* No viable sleep budget (short preamble, or the TCXO restart
* eats it) → honest fall-through to continuous RX. */
struct lora_modem_config cfg;
buildModemConfig(cfg, false);
const uint8_t sf = (uint8_t)cfg.datarate;
const uint32_t bw_hz = bandwidth_to_hz(cfg.bandwidth);
const uint16_t P = cfg.preamble_len;
const uint16_t D = rxDutyDetectSymbols(sf);
if (bw_hz > 0 && P > 2 * D + 1) {
const uint32_t sym_us = (uint32_t)
(((uint64_t)(1U << sf) * 1000000ULL) / bw_hz);
const uint32_t trans_us = hwWakeupTimeUs();
/* Theoretical per-cycle deaf budget, then derate by
* CONFIG_..._MARGIN_PCT. The budget assumes the sleep
* clock and wake transition are exact; in reality the
* chip sleep timer runs on an RC oscillator that drifts
* several % over temperature and the wake transition is a
* "may vary" datasheet figure. Either overshoot pushes
* real deaf time past the budget and drops phase-edge
* packets (strength-independent DC loss). Deraging the
* whole budget is slightly stricter than deraging sleep
* alone, which is the safe direction. */
const uint32_t deaf_budget_us =
(uint32_t)(P - 2 * D - 1) * sym_us;
const uint32_t deaf_us = deaf_budget_us -
(uint32_t)(((uint64_t)deaf_budget_us *
CONFIG_ZEPHCORE_LORA_DC_MARGIN_PCT) /
100U);
if (deaf_us > trans_us + 2000) {
const uint32_t sleep_us = deaf_us - trans_us;
const uint32_t complete_us =
(uint32_t)(P + 14) * sym_us;
uint32_t rx_us = (uint32_t)(D + 1) * sym_us;
if (complete_us > sleep_us &&
rx_us < (complete_us - sleep_us + 1) / 2) {
rx_us = (complete_us - sleep_us + 1) / 2;
}
if (rx_us != _dc_last_rx_us ||
sleep_us != _dc_last_sleep_us) {
_dc_last_rx_us = rx_us;
_dc_last_sleep_us = sleep_us;
LOG_INF("rxduty: rx=%ums sleep=%ums trans=%ums (P=%u D=%u, off=%u%%)",
rx_us / 1000, sleep_us / 1000,
trans_us / 1000, P, D,
(uint32_t)(((uint64_t)sleep_us * 100) /
(rx_us + sleep_us + trans_us)));
}
ret = lora_recv_duty_cycle(_dev,
K_USEC(rx_us),
K_USEC(sleep_us),
rxCallbackStatic, this);
if (ret == 0) {
_rx_entry_cyc = k_cycle_get_32();
atomic_set(&_in_recv_mode, 1);
return;
}
if (ret == -EBUSY) {
/* A concurrent TX owns the chip. Not the
* CAD-busy case: when the LBT branch of
* send_async restores RX in-driver it
* leaves the chip in RX, and the driver's
* idempotent fast-path (patch 0003)
* re-arms duty cycle from there — AGC
* reset included — rather than refusing.
* So -EBUSY here means the radio is
* genuinely mid-transmit; the fall-through
* to lora_recv_async will fail the same
* way and report it. */
LOG_DBG("rxduty: busy (TX in progress) — continuous RX");
} else if (ret != -ENOSYS) {
LOG_ERR("lora_recv_duty_cycle failed: %d", ret);
}
/* Fall through to continuous RX */
} else if (_dc_last_rx_us != UINT32_MAX) {
_dc_last_rx_us = UINT32_MAX;
LOG_INF("rxduty: wake transition %uus exceeds deaf budget %uus — continuous RX",
trans_us, deaf_us);
}
} else if (_dc_last_rx_us != UINT32_MAX) {
_dc_last_rx_us = UINT32_MAX;
LOG_INF("rxduty: preamble %u too short for guaranteed catch (need >%u syms) — continuous RX",
P, 2 * D + 1);
}
}
ret = lora_recv_async(_dev, rxCallbackStatic, this);
if (ret < 0) {
LOG_ERR("lora_recv_async failed: %d", ret);
atomic_set(&_in_recv_mode, 0);
return;
}
_rx_entry_cyc = k_cycle_get_32();
atomic_set(&_in_recv_mode, 1);
}
/* ── RX/TX ────────────────────────────────────────────────────────────── */
int LoRaRadioBase::recvRaw(uint8_t *bytes, int sz)
{
uint8_t tail = (uint8_t)atomic_get(&_rx_tail);
if (atomic_get(&_rx_head) == tail) {
return 0;
}
RxPacket *pkt = &_rx_ring[tail];
uint16_t len = pkt->len;
if (len > (uint16_t)sz) {
len = (uint16_t)sz;
}
memcpy(bytes, pkt->data, len);
_last_rssi = (float)pkt->rssi;
_last_snr = (float)pkt->snr;
atomic_set(&_rx_tail, (tail + 1) % RX_RING_SIZE);
return (int)len;
}
bool LoRaRadioBase::startSendRaw(const uint8_t *bytes, int len)
{
if (len > (int)sizeof(_tx_buf)) {
return false;
}
/* Defensive gate: callers should defer TX while radio is BUSY. */
if (!isRadioReady()) {
return false;
}
/* Last-moment software check before killing active RX. Uses the full
* isReceiving() (latch + non-destructive raw bits) so the final gate
* honors the same source of truth as the dispatcher's earlier gates.
* Closes the serialisation/logging gap between the dispatcher's check
* and the TX-state transition below. */
if (isReceiving()) {
return false;
}
_board->onBeforeTransmit();
atomic_set(&_tx_active, 1);
/* Phase 2: when LBT is enabled, skip the pre-emptive hwCancelReceive()
* and keep _in_recv_mode = 1 so the driver's send_async sees state == RX
* (the Phase-2 entry CAS path). On CAD-busy the driver restores RX
* internally; on success the chip transitions cleanly into TX without
* the redundant ~13 ms C++ cancel-then-restart round-trip.
* isReceiving() returns false during the CAD window because _tx_active
* is set above — no extra gating needed.
*
* cad.mode = LBT is set unconditionally in buildModemConfig() today;
* the `lbt` flag is a placeholder for any future Kconfig that toggles
* the behaviour. */
const bool lbt = true;
if (!lbt) {
atomic_set(&_in_recv_mode, 0);
hwCancelReceive();
}
configureTx();
memcpy(_tx_buf, bytes, len);
k_poll_signal_reset(&_tx_signal);
int ret = hwSendAsync(_tx_buf, (uint32_t)len, &_tx_signal);
if (ret < 0) {
if (ret == -EBUSY) {
/* LBT refused the transmit because the channel is busy
* — the designed outcome, not a fault. The dispatcher
* re-queues and retries. On a busy site this fires
* constantly, and at ERR it buries real faults and
* makes a healthy repeater look broken. */
LOG_DBG("hwSendAsync: channel busy (LBT), re-queuing");
} else {
LOG_ERR("hwSendAsync failed: %d", ret);
}
_board->onAfterTransmit();
atomic_set(&_tx_active, 0);
/* startReceive() is safe to call here regardless of failure
* cause: on SX126x, recv_async early-returns if the driver
* already restored RX on CAD-busy (Phase 2 idempotent fast
* path); on LR11xx/LR20xx, the LBT branch restores RX before
* returning -EBUSY (Phase 2 mirror), so start_rx is also a
* no-op there. On other failure modes the chip is in REST,
* recv_async transitions normally. */
startReceive();
return false;
}
/* TX has actually started — now we're no longer in RX. */
atomic_set(&_in_recv_mode, 0);
LOG_DBG("TX started async, len=%d", len);
k_sem_give(&_tx_start_sem);
return true;
}
bool LoRaRadioBase::isSendComplete()
{
return !atomic_get(&_tx_active);
}
void LoRaRadioBase::onSendFinished()
{
/* Nothing needed — TX state tracked via _tx_active */
}
bool LoRaRadioBase::isInRecvMode() const
{
return atomic_get(&_in_recv_mode) != 0;
}
float LoRaRadioBase::getLastRSSI() const
{
return _last_rssi;
}
float LoRaRadioBase::getLastSNR() const
{
return _last_snr;
}
bool LoRaRadioBase::isRadioReady()
{
/* BUSY high means the radio cannot accept SPI commands now
* (e.g. duty-cycle sleep phase on SX126x/LR11xx). */
return !hwIsChipBusy();
}
/* ── Airtime + scoring ────────────────────────────────────────────────── */
uint32_t LoRaRadioBase::getEstAirtimeFor(int len_bytes)
{
uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR;
float bw = _prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH;
uint8_t cr_val = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE;
if (sf < 6) sf = 6;
if (sf > 12) sf = 12;
if (bw < 7.0f) bw = 125.0f;
if (cr_val < 5) cr_val = 5;
if (cr_val > 8) cr_val = 8;
float t_sym = (float)(1 << sf) / (bw * 1000.0f);
float t_preamble = (preambleLengthForSF(sf) + 4.25f) * t_sym;
/* LDRO threshold must track the SX126x driver's should_enable_ldro()
* exactly (symbol time > 16.38 ms) so this estimate's DE matches the
* hardware's DE on every SF/BW pair. The old `sf >= 11` was only
* correct at BW 125 kHz and diverged on every other bandwidth. */
float de = (t_sym > 0.01638f) ? 1.0f : 0.0f;
float num = 8.0f * len_bytes - 4.0f * sf + 28.0f + 16.0f;
float den = 4.0f * (sf - 2.0f * de);
if (den < 1.0f) den = 4.0f;
float n_payload = 8.0f + fmaxf(ceilf(num / den) * (cr_val - 4 + 4), 0.0f);
float t_payload = n_payload * t_sym;
return (uint32_t)((t_preamble + t_payload) * 1000.0f);
}
float LoRaRadioBase::packetScore(float snr, int packet_len)
{
int sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR;
if (sf < 7 || sf > 12) return 0.0f;
if (snr < lora_snr_threshold[sf - 7]) return 0.0f;
float success_rate = (snr - lora_snr_threshold[sf - 7]) / 10.0f;
float collision_penalty = 1.0f - ((float)packet_len / 256.0f);
float score = success_rate * collision_penalty;
if (score < 0.0f) score = 0.0f;
if (score > 1.0f) score = 1.0f;
return score;
}
/* ── Advanced radio features ──────────────────────────────────────────── */
int LoRaRadioBase::getNoiseFloor() const
{
return _noise_floor;
}
void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold)
{
_calibration_threshold = threshold;
/* Own the sampling cadence rather than inheriting the caller's. Early
* calls are a no-op, so this is safe to invoke from any wake. */
int64_t now = k_uptime_get();
/* Invalidate first: "fresh" must mean a sample landed in THIS pass, not
* merely at some point in the past. cadMaintenance() runs immediately
* after us and treats the verdict as current-channel ground truth, so a
* carried-over sample would let it probe on a reading taken a full
* interval ago — on a different channel state entirely. */
_sample_fresh = false;
if (_noise_floor_next_ms != 0 && now < _noise_floor_next_ms) {
return;
}
/* Due. Any bail-out below is a blocked attempt, not a completed one —
* push the deadline out by the retry so msUntilNextMaintenance() cannot
* report "due now" on a loop. Bounded for the same reason as the CAD
* probe: an unbounded retry grid makes the retry period the de-facto
* wake period whenever the radio is persistently busy. */
if (_noise_floor_retries >= NOISE_FLOOR_MAX_RETRIES) {
_noise_floor_retries = 0;
_noise_floor_next_ms = now + _measure_interval_ms;
return;
}
_noise_floor_retries++;
_noise_floor_next_ms = now + NOISE_FLOOR_RETRY_MS;
if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) {
return;
}
/* Skip when the radio cannot accept commands right now
* (e.g. duty-cycle sleep BUSY window). */
if (!isRadioReady()) {
return;
}
/* Skip if mid-receive — don't want signal energy in the floor. */
if (isReceiving()) {
return;
}
/* GetRssiInst needs time after RX entry before the first value is
* valid (DS Table 13-82). isRadioReady() only clears BUSY, and the
* delay is measured *from* the BUSY falling edge, so BUSY alone does
* not prove the reading has settled. Only host-driven RX entries are
* stamped: under RX duty cycle the sleep->RX wakes are chip-internal
* and invisible to us. That is acceptable rather than ideal — the
* delay is ~0.25 ms at BW 62.5 against an RX window orders of
* magnitude longer, so the odds of a duty-cycle sample landing inside
* an unsettled window are small, and the median absorbs the odd one. */
uint16_t bw_khz = (uint16_t)(getActiveBandwidthKHzX10() / 10);
uint32_t since_rx_us =
k_cyc_to_us_floor32(k_cycle_get_32() - _rx_entry_cyc);
if (since_rx_us < rssi_settle_delay_us(bw_khz)) {
return;
}
/* Median of multiple RSSI reads: no downward bias of min, no spike
* sensitivity of average. Insertion sort is fine for N=8 (28
* comparisons worst case, all in registers).
*
* Scope, measured on-air 2026-07-29 (`get cad` sp field, BW 62.5):
* 84-90%% of bursts return N identical values, and the rest average
* ~6 dB of spread. The reads ARE independent -- the degenerate share
* falls and the spread rises when ambient comes up, exactly as it
* should. The burst is simply short: ~300 us against a ~200 ms
* SF8/BW62.5 packet, about 0.15%% of one transmission. So a
* neighbour's packet is either wholly inside the burst or wholly
* outside it, every read sees the same level, and the median returns
* it rather than rejecting it.
*
* What this median actually buys is rejection of sub-300 us glitches
* and single bad SPI reads. That is worth its ~300 us every 15 s, but
* it is NOT the defence against interference -- that is the
* isReceiving() guard above and the floor + SAMPLING_THRESHOLD filter
* below. An earlier comment here claimed "rejects up to N/2-1
* outliers", which credited the median with their work.
*
* Reads are spaced by the RSSI averaging window, without which they
* can all fall inside one window and return the same underlying
* sample N times — a median of N copies of one read. At BW 62.5 the
* spacing (~16 us) is already covered by the SPI transaction itself;
* it matters at the narrow presets, where the window grows past the
* whole burst. */
uint32_t window_us = rssi_avg_window_us(bw_khz);
int16_t samples[NOISE_FLOOR_SAMPLES_PER_TICK];
for (int i = 0; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) {
if (i) {
k_busy_wait(window_us);
}
samples[i] = hwGetCurrentRSSI();
if (samples[i] == -128) {
/* Chip busy or RSSI read contended — keep the short
* retry deadline set above and try again shortly. */
return;
}
}
/* A full sample landed: next one is a full interval away. */
_noise_floor_next_ms = now + _measure_interval_ms;
_noise_floor_retries = 0;
/* Insertion sort — tiny array, branch-friendly on Cortex-M */
for (int i = 1; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) {
int16_t key = samples[i];
int j = i - 1;
while (j >= 0 && samples[j] > key) {
samples[j + 1] = samples[j];
j--;
}
samples[j + 1] = key;
}
int16_t rssi = (samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2 - 1] +
samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2]) / 2;
/* Burst quality, reported by `get cad`. Sorted, so max-min is the
* spread. Kept as running totals rather than an EMA so the numbers
* stay readable and the degenerate share is a true proportion.
*
* Reading it: a high zero-spread share on its own is NOT a fault — a
* quiet or steadily-occupied channel genuinely reads the same value
* N times at integer-dB resolution. What would indict the sampler is
* a high share together with a mean of 0.0, i.e. no burst ever spans
* anything: that is reads landing inside one RSSI averaging window and
* returning one sample N times over. A non-zero mean proves the reads
* are independent however high the share climbs. */
_rssi_bursts++;
_rssi_spread_sum += (uint32_t)(samples[NOISE_FLOOR_SAMPLES_PER_TICK - 1] -
samples[0]);
if (samples[NOISE_FLOOR_SAMPLES_PER_TICK - 1] == samples[0]) {
_rssi_degenerate++;
}
/* Rescale together so both derived figures survive untouched, and the
* printed count stays four digits however long the node is up. */
if (_rssi_bursts >= RSSI_BURST_STATS_CAP) {
_rssi_bursts >>= 1;
_rssi_spread_sum >>= 1;
_rssi_degenerate >>= 1;
}
/* Publish this sample for cadMaintenance(). The CAD probe needs exactly
* the same fact we just established — "is the channel at its floor right
* now?" — and used to answer it with its own single hwGetCurrentRSSI() on
* its own deadline. That cost a second wake per interval (measured: two
* 15 s grids ~3 s apart) and made the worse decision, since one raw read
* is precisely what the median-of-8 exists to defend against.
*
* The verdict is taken against the floor BEFORE this sample is folded in,
* so it compares a new observation to the established floor rather than
* to one already dragged toward it. */
_sample_rssi = rssi;
_sample_channel_quiet = (_noise_floor == DEFAULT_NOISE_FLOOR) ||
(rssi <= _noise_floor + CAD_PROBE_RSSI_GUARD);
_sample_fresh = true;
/* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly.
* The lower clamp tracks the active bandwidth — thermal noise is
* 10*log10(BW) so a fixed rail pins narrow-BW presets several dB high
* (BW 31.25 kHz sits ~3 dB below BW 62.5) and never engages at all on
* wide ones. */
int16_t floor_min = noise_floor_min_dbm(getActiveBandwidthKHzX10() / 10);
if (_noise_floor == DEFAULT_NOISE_FLOOR) {
_noise_floor = rssi;
if (_noise_floor < floor_min) _noise_floor = floor_min;
if (_noise_floor > -50) _noise_floor = -50;
_ema_unguarded = 0;
LOG_DBG("noise_floor_cal: seed=%d", _noise_floor);
return;
}
/* Threshold filter with warmup and periodic bypass.
*
* _ema_unguarded counts up from 0 on every tick.
* Ticks 0..W-1 (warmup): all samples accepted for fast convergence
* after seed/reset — prevents a bad seed from locking out the
* real noise floor via a too-tight threshold.
* Ticks W+: threshold filter active. Every Pth tick one sample
* bypasses the filter so the floor can track sustained upward
* shifts (new interference, antenna change).
* The EMA's 1/8 weight naturally dampens isolated spikes. */
const int W = (1 << NOISE_FLOOR_EMA_SHIFT); /* 8 — warmup ticks */
const int P = NOISE_FLOOR_UNGUARDED_INTERVAL; /* 16 — periodic interval */
bool warmup = (_ema_unguarded < W);
bool periodic = (!warmup && (_ema_unguarded & (P - 1)) == 0);
_ema_unguarded++; /* wraps at 255 — harmless */
if (!warmup && !periodic &&
rssi >= _noise_floor + NOISE_FLOOR_SAMPLING_THRESHOLD) {
return;
}
/* EMA: floor += round_nearest((sample - floor) / W).
* Plain >> has downward bias (-1>>3 == -1 but +1>>3 == 0).
* Plain / has a ±7 dead zone (small drifts ignored).
* Round-to-nearest: add half the divisor before dividing,
* with sign-aware bias so both directions are symmetric. */
int diff = rssi - _noise_floor;
int half = W / 2; /* 4 */
int step = (diff + (diff > 0 ? half : -half)) / W;
_noise_floor += step;
if (_noise_floor < floor_min) _noise_floor = floor_min;
if (_noise_floor > -50) _noise_floor = -50;
LOG_DBG("noise_floor_cal: rssi=%d, floor=%d, tick=%u",
rssi, _noise_floor, _ema_unguarded - 1);
}
bool LoRaRadioBase::isReceiving()
{
if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) {
return false;
}
/* Driver-side latch + non-destructive IRQ read covers the full
* payload phase. hwIsReceiving() never clears IRQ bits; foreign
* preambles release via hardware (SymbNumTimeout on SX126x non-DC
* or chip-internal sync timer on DC / LR11xx / LR20xx). */
if (hwIsReceiving()) {
return true;
}
return isChannelActive();
}
void LoRaRadioBase::recoverRxState()
{
/* Called by the Dispatcher on CAD timeout when isReceiving() has been
* pinned true past the recovery threshold (4 s). We must escape a
* stuck driver state == RX — a bare startReceive() can't do this
* because the driver's lora_recv_async entry CAS is REST_STATE → RX,
* which fails when state is already RX and would set _in_recv_mode = 0
* on the -EBUSY return. Walk the chip back through REST first.
*
* The RX-restart sites in the driver (recv_async, recv_duty_cycle,
* restart_rx) all bulk-clear IRQ status and reset the rx_packet_active
* latch as part of their entry, so this sequence cleanly flushes a
* stuck PREAMBLE_DETECTED bit or a stale latch. */
hwCancelReceive();
atomic_set(&_in_recv_mode, 0);
_config_cached = false;
startReceive();
}
bool LoRaRadioBase::isChannelActive(int threshold)
{
if (threshold == 0) {
threshold = _calibration_threshold;
}
if (threshold == 0) {
return false;
}
int16_t rssi = hwGetCurrentRSSI();
return rssi > (_noise_floor + threshold);
}
/* ── Adaptive CAD (LBT detPeak calibration) ───────────────────────────── */
void LoRaRadioBase::setCadParams(bool auto_enabled, int8_t offset,
uint16_t probe_interval_s, uint8_t busycap_pct)
{
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;
_probe_interval_s = probe_interval_s;
_cad_busycap_pct = busycap_pct;
/* One interval governs every periodic radio measurement, because there
* is only one measurement: the noise-floor sampler takes a median-of-8
* and the CAD probe consumes that same reading (see cadMaintenance).
* Splitting them into two knobs could only ever express a rate the
* hardware does not actually run at.
*
* 0 means "CAD probing off" — the floor sampler still has to run, so it
* falls back to the build-time default. */
_measure_interval_ms = probe_interval_s
? (uint32_t)probe_interval_s * 1000U
: (uint32_t)CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: auto=%d offset=%d measure_interval=%ums busycap=%u%%",
(int)auto_enabled, (int)offset, (unsigned)_measure_interval_ms,
(unsigned)busycap_pct);
}
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: sample the operating level AND both neighbours so the staircase
* can read the local FP curvature (slope below vs. above) and seek the
* knee. op is the shared term of both slopes → weight it half; each
* neighbour a quarter. Out-of-range neighbours fall back to op. */
int8_t lvl;
switch (_cad_probe_rr & 3) {
case 1: lvl = (int8_t)(_cad_offset - 1); break; /* more sensitive */
case 3: lvl = (int8_t)(_cad_offset + 1); break; /* less sensitive */
default: lvl = _cad_offset; break; /* operating (0, 2) */
}
if (lvl < CAD_LEVEL_MIN || lvl > CAD_LEVEL_MAX) {
lvl = _cad_offset;
}
return lvl;
}
void LoRaRadioBase::cadStaircaseStep()
{
/* Knee-seeking controller — see the CAD_KNEE_SLOPE / CAD_PLATEAU_CLEAN
* notes in radio_common.h. Reads local curvature from three rungs and
* steps toward the knee (the most sensitive detPeak whose FP has already
* bottomed out), using slopes so the decision is site-floor-independent. */
int oi = _cad_offset - CAD_LEVEL_MIN;
auto warm = [&](int idx) -> bool {
return idx >= 0 && idx < CAD_NUM_LEVELS &&
_cad_stats[idx].probes >= CAD_STEP_MIN_PROBES;
};
/* Per-level FALSE-positive rate in permille, or -1 when too few samples. */
auto fp_rate = [&](int idx) -> int {
if (!warm(idx)) {
return -1;
}
return (int)(((uint32_t)_cad_stats[idx].fp * 1000U)
/ _cad_stats[idx].probes);
};
/* Per-level TOTAL busy (defer) rate in permille — false + real traffic. */
auto busy_rate = [&](int idx) -> int {
if (!warm(idx)) {
return -1;
}
return (int)(((uint32_t)_cad_stats[idx].busy * 1000U)
/ _cad_stats[idx].probes);
};
int r_op = fp_rate(oi);
if (r_op < 0) {
return; /* operating level not warm yet — no basis to step */
}
int b_op = busy_rate(oi);
int r_up = fp_rate(oi + 1); /* one step less sensitive */
int r_dn = fp_rate(oi - 1); /* frontier, one step more sensitive */
/* Airtime protection (highest priority): if the operating level defers
* too large a fraction of TX attempts — real traffic included — back off
* to a less sensitive detPeak. On a congested hilltop most of that busy
* is distant traffic we'd win on capture anyway; deferring for all of it
* just starves our own airtime. Cap is `set cad.busycap` percent (0 =
* off); only binds on genuinely busy channels. */
int cap_permille = (int)_cad_busycap_pct * 10;
if (cap_permille && _cad_offset < CAD_LEVEL_MAX && b_op > cap_permille) {
_cad_offset++;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: step up -> offset %d (airtime, busy %d cap %d)",
(int)_cad_offset, b_op, cap_permille);
return;
}
/* Step UP (less sensitive) when the level above is markedly cleaner —
* we're on the steep part of the curve, below the knee. */
if (_cad_offset < CAD_LEVEL_MAX && r_up >= 0 &&
r_op - r_up >= CAD_KNEE_SLOPE_PERMILLE) {
_cad_offset++;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: step up -> offset %d (op %d dn->up %d)",
(int)_cad_offset, r_op, r_up);
return;
}
/* Step DOWN (more sensitive) only on a flat plateau that is already
* clean: the frontier is no worse than operating (nothing to lose) AND
* FP here is low enough that reclaiming sensitivity is cheap. The clean
* guard keeps a flat-but-noisy curve from descending to the sensitive
* rail; the busy-hysteresis guard keeps us from descending into the
* airtime cap and bouncing straight back up. */
int b_dn = busy_rate(oi - 1);
bool busy_ok = (cap_permille == 0) ||
(b_dn <= cap_permille - CAD_BUSY_DEFER_HYST_PERMILLE);
if (_cad_offset > CAD_LEVEL_MIN && r_dn >= 0 &&
r_dn - r_op < CAD_KNEE_SLOPE_PERMILLE &&
r_op <= CAD_PLATEAU_CLEAN_PERMILLE && busy_ok) {
_cad_offset--;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: step down -> offset %d (op %d dn %d busy %d)",
(int)_cad_offset, r_op, r_dn, b_dn);
return;
}
/* Otherwise: at the knee (steep below, flat above) or a noisy flat
* plateau — hold. */
}
void LoRaRadioBase::cadMaintenance()
{
if (_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;
}
/* No separate probe-interval check: the probe interval IS the measurement
* interval (setCadParams derives _measure_interval_ms from it), so a
* fresh sample means a probe is due by construction. */
/* Ride on the noise-floor sampler rather than measuring independently.
*
* A fresh sample means the sampler ran THIS pass, which already proves
* everything the probe needs: the radio was idle in RX, not transmitting,
* not mid-packet, and out of its duty-cycle sleep window — the sampler
* applies exactly those guards before it reads. So there is nothing left
* to re-check, no separate deadline, and no retry budget: if no sample
* landed this pass, the probe simply waits for the next one.
*
* This is what makes the wake cost one per interval instead of two. It
* also upgrades the ground-truth prefilter from a single raw RSSI read to
* the sampler's median-of-8 — the probe is trying to establish that the
* channel is quiet, and a busy verdict taken over real traffic teaches
* nothing about false positives, so the outlier rejection matters here. */
if (!_sample_fresh) {
return;
}
_sample_fresh = false;
if (!_sample_channel_quiet) {
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: was the CAD hit a REAL signal or a
* correlator false positive? A real transmitter that tripped
* CAD keeps radiating, so over the next preamble+header window
* one of two things shows up:
* (a) the restarted RX syncs on it -> isReceiving(), or
* (b) instantaneous RSSI climbs above the noise floor.
* (b) is the important addition: the probe tears RX down to run
* CAD, and the STANDBY->RX restart routinely eats the preamble of
* a real packet, so RX never re-syncs — the old isReceiving()-only
* snapshot booked those strong-but-missed packets as false
* positives, a ~detPeak-independent floor that flattened the FP
* curve and drove the staircase to the ceiling. Channel energy
* doesn't depend on winning the preamble race, so it recovers
* them. Neither signal over the whole window => genuine FP. A
* below-floor packet we can neither sync nor see stays ambiguous
* and counts as FP — bias toward higher detPeak (the safe side).
* The prefilter above guaranteed RSSI <= floor+guard pre-probe,
* so a rise past that threshold now is a newly-arrived signal. */
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 step_ms = (3U * tsym_us) / 1000U; /* ~3 symbols/sample */
if (step_ms < 5) step_ms = 5;
if (step_ms > 100) step_ms = 100;
bool floor_valid = (_noise_floor != DEFAULT_NOISE_FLOOR);
int16_t rssi_thresh = _noise_floor + CAD_PROBE_RSSI_GUARD;
bool real = false;
for (int k = 0; k < 4 && !real; k++) { /* ~12 symbols total */
k_sleep(K_MSEC(step_ms));
if (isReceiving()) {
real = true;
} else if (floor_valid &&
hwGetCurrentRSSI() > rssi_thresh) {
real = true;
}
}
if (real) {
s.tp++;
} else {
s.fp++;
}
}
if (_cad_auto) {
cadStaircaseStep();
}
}
/* int64 uptime delta → the uint32 "ms from now" the maintenance contract wants.
* Already-passed deadlines saturate at 0 (due now), far-future ones at IDLE. */
static uint32_t clampDeadline(int64_t remaining_ms)
{
if (remaining_ms <= 0) {
return 0;
}
if (remaining_ms >= (int64_t)mesh::MAINTENANCE_IDLE) {
return mesh::MAINTENANCE_IDLE;
}
return (uint32_t)remaining_ms;
}
/* When does this radio next need a maintenance call? Two independent items:
* the noise floor sampler (always running) and the CAD calibrator (only when
* probing is enabled). Both hold absolute uptime deadlines, so this is a pure
* read — it must not touch the chip, since the event loop calls it on every
* wake to decide how long it may sleep. */
uint32_t LoRaRadioBase::msUntilNextMaintenance()
{
int64_t now = k_uptime_get();
uint32_t next = mesh::MAINTENANCE_IDLE;
/* Noise floor. A zero deadline means "never sampled yet" — due now. */
if (_noise_floor_next_ms == 0) {
return 0;
}
next = clampDeadline(_noise_floor_next_ms - now);
if (_probe_interval_s == 0) {
return next;
}
/* The CAD probe deliberately contributes NO deadline of its own. It runs
* off the noise-floor sampler's measurement (see cadMaintenance), so its
* wake is already accounted for above. Giving it a second deadline is
* what produced two independent 15 s grids ~3 s apart — one extra wake
* per interval, forever, on every repeater. */
/* Stats decay. _cad_last_decay_ms == 0 means the first call latches it
* rather than decaying, so treat that as due now. */
if (_cad_last_decay_ms == 0) {
return 0;
}
return mesh::maintenanceSooner(
next, clampDeadline(_cad_last_decay_ms + (int64_t)CAD_STATS_DECAY_MS - now));
}
int LoRaRadioBase::formatCadStatus(char *buf, int cap)
{
uint8_t base = hwCadBasePeak();
int n = 0;
if (base == 0) {
return snprintf(buf, cap, "cad n/a");
}
/* Terse on purpose — remote replies are capped at ~160 B over LoRa.
* Header: a:on o:1 pk:22(b21/4s) sp:0.9/84%(312) bc:25%
* a auto on/off o offset pk operating peak
* b family base 4s symbols bc busy cap
* sp RSSI burst quality: mean spread in dB across the median-of-N
* reads, the share of bursts whose spread was 0, and the burst
* count. The count is not decoration: a share without its
* denominator cannot be read, and the burst rate is not
* derivable from uptime because the sampler's guards (TX, mid-RX,
* duty-cycle sleep) block an unknown fraction of attempts.
* Level: *+1(22) 22p 18b 16f 2t 72%
* '*' = operating rung level(peak) probes busy fp tp fp-rate%%.
*
* sp replaced the probe interval here because the interval is a pref
* you already set and can read back with `get probe.interval`, whereas
* burst spread is only observable from inside the sampler.
*
* It answers one question: are the N reads independent? A non-zero
* mean proves they are, whatever the zero-spread share — a steady
* channel reads identically at integer-dB resolution, which is correct
* rather than broken. Only mean 0.0 with a high share indicts the
* sampler: that is N copies of one sample from inside a single RSSI
* averaging window (see rssi_avg_window_us() in radio_common.h).
* Measured on-air 2026-07-29 at BW 62.5: 0.6/90% quiet, 0.9/84% with
* the floor at -103 — independent, and responding the right way.
* Mean is tenths of a dB. Counters halve at RSSI_BURST_STATS_CAP, so
* the count is bounded to four digits and the figures describe a
* recent window rather than everything since boot. */
unsigned spread_mean10 = _rssi_bursts
? (unsigned)((_rssi_spread_sum * 10U + _rssi_bursts / 2U) /
_rssi_bursts)
: 0;
unsigned degen_pct = _rssi_bursts
? (unsigned)((_rssi_degenerate * 100U + _rssi_bursts / 2U) /
_rssi_bursts)
: 0;
/* The burst count is bench diagnostics, and the header competes with
* the three level rows for a 161 B remote reply — with wide level
* counters the full header pushes the last row into truncation. So
* print it only into the roomy local-console buffer; a remote reader
* still gets the mean and the share, which is the actual verdict. */
bool room_for_count = (cap >= 200);
n += snprintf(buf + n, cap > n ? cap - n : 0,
"a:%s o:%d pk:%d(b%u/4s) sp:%u.%u/%u%%",
_cad_auto ? "on" : "off", (int)_cad_offset,
(int)base + _cad_offset, base,
spread_mean10 / 10U, spread_mean10 % 10U, degen_pct);
if (room_for_count) {
n += snprintf(buf + n, cap > n ? cap - n : 0, "(%u)",
(unsigned)_rssi_bursts);
}
n += snprintf(buf + n, cap > n ? cap - n : 0, " bc:%u%%",
(unsigned)_cad_busycap_pct);
/* Only the 3 rungs around the operating offset — the far rungs are mildly
* irrelevant; what matters is where we sit on the ladder. The window is
* clamped to stay inside [CAD_LEVEL_MIN, CAD_LEVEL_MAX] while still showing
* 3 rungs, so at either end it slides inward rather than dropping a line. */
int cur = _cad_offset;
if (cur < CAD_LEVEL_MIN) cur = CAD_LEVEL_MIN;
if (cur > CAD_LEVEL_MAX) cur = CAD_LEVEL_MAX;
int lo = cur - 1, hi = cur + 1;
if (lo < CAD_LEVEL_MIN) { lo = CAD_LEVEL_MIN; hi = lo + 2; }
if (hi > CAD_LEVEL_MAX) { hi = CAD_LEVEL_MAX; lo = hi - 2; }
for (int lvl = lo; lvl <= hi; lvl++) {
CadLevelStats &s = _cad_stats[lvl - CAD_LEVEL_MIN];
/* Integer FP rate, rounded to nearest percent (0 when unprobed). */
unsigned fp_pct = s.probes
? (unsigned)(((uint32_t)s.fp * 100U + s.probes / 2) / s.probes)
: 0;
n += snprintf(buf + n, cap > n ? cap - n : 0,
"\n%c%+d(%d) %up %ub %uf %ut %u%%",
lvl == cur ? '*' : ' ',
lvl, (int)base + lvl,
s.probes, s.busy, s.fp, s.tp, fp_pct);
}
return n;
}
/* ── Power saving ─────────────────────────────────────────────────────── */
void LoRaRadioBase::enableRxDutyCycle(bool enable)
{
_rx_duty_cycle_enabled = enable;
LOG_INF("RX duty cycle %s", enable ? "enabled" : "disabled");
if (atomic_get(&_in_recv_mode)) {
/* Restart receive to apply new duty cycle state */
hwCancelReceive();
atomic_set(&_in_recv_mode, 0);
startReceive();
}
}
bool LoRaRadioBase::setRxBoost(bool enable)
{
_rx_boost_enabled = enable;
LOG_INF("RX boost %s (+3dB sensitivity, +2mA)",
enable ? "enabled" : "disabled");
if (atomic_get(&_in_recv_mode)) {
hwSetRxBoost(enable);
}
return true;
}
} /* namespace mesh */