mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-09-01 21:08:19 +00:00
1532 lines
54 KiB
C++
1532 lines
54 KiB
C++
/*
|
||
* 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 ~1–3 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 */
|