Files
2026-09-01 22:05:29 +02:00

509 lines
22 KiB
C++

/*
* SPDX-License-Identifier: MIT
* LoRa radio base class — shared state and algorithms.
* Subclasses implement hw*() primitives only.
*/
#pragma once
#include <mesh/Radio.h>
#include <mesh/Board.h>
#include <NodePrefs.h>
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include "radio_common.h"
namespace mesh {
class LoRaRadioBase : public Radio {
public:
LoRaRadioBase(const struct device *lora_dev, MainBoard &board,
NodePrefs *prefs = nullptr);
void setPrefs(NodePrefs *prefs) { _prefs = prefs; }
/* Callbacks */
void setRxCallback(RadioRxCallback cb, void *user_data) {
_rx_cb = cb;
_rx_cb_user_data = user_data;
}
void setTxDoneCallback(RadioTxDoneCallback cb, void *user_data) {
_tx_done_cb = cb;
_tx_done_cb_user_data = user_data;
}
/* Radio interface (all implemented in base) */
void begin() override;
void reconfigure();
void reconfigureWithParams(float freq, float bw, uint8_t sf, uint8_t cr);
/* Temporary radio override — applies freq/bw/sf/cr without mutating
* _prefs. Used by tempradio so the saved prefs survive intact and
* concurrent savePrefs() calls don't poison flash. clearRadioOverride()
* reverts to whatever _prefs holds at that moment. */
void setRadioOverride(float freq, float bw, uint8_t sf, uint8_t cr);
void clearRadioOverride();
bool hasRadioOverride() const { return _has_radio_override; }
int recvRaw(uint8_t *bytes, int sz) override;
uint32_t getEstAirtimeFor(int len_bytes) override;
float packetScore(float snr, int packet_len) override;
bool startSendRaw(const uint8_t *bytes, int len) override;
bool isSendComplete() override;
void onSendFinished() override;
bool isInRecvMode() const override;
float getLastRSSI() const override;
float getLastSNR() const override;
bool isRadioReady() override;
/* Packet statistics */
uint32_t getPacketsRecv() const override { return (uint32_t)atomic_get(&_packets_recv); }
uint32_t getPacketsSent() const override { return (uint32_t)atomic_get(&_packets_sent); }
uint32_t getPacketsRecvErrors() const override { return (uint32_t)atomic_get(&_packets_recv_errors); }
/* Virtual so radios with extra accumulators can clear them on the same
* `clear stats`. Reached through a LoRaRadioBase& from
* RepeaterMesh::clearStats() via getRadioDriver(), so a shadowing
* non-virtual override would silently never run. */
virtual void resetStats() {
atomic_set(&_packets_recv, 0);
atomic_set(&_packets_sent, 0);
atomic_set(&_packets_recv_errors, 0);
}
/* Advanced radio features */
int getNoiseFloor() const override;
void triggerNoiseFloorCalibrate(int threshold) override;
bool isReceiving() override;
void recoverRxState() override;
/* Extended API */
bool isChannelActive(int threshold = 0);
/* Power saving */
void enableRxDutyCycle(bool enable);
bool isRxDutyCycleEnabled() const { return _rx_duty_cycle_enabled; }
/* Returns false when the chip has no RX boost feature (SX127x). */
virtual bool setRxBoost(bool enable);
bool isRxBoostEnabled() const { return _rx_boost_enabled; }
/* External FEM/LNA gain during RX. Boards whose front-end module has a
* software-selectable receive path wire that select line to the radio
* (lna-bypass-gpios); disabling this routes RX around the FEM's LNA,
* trading its gain for its supply current while leaving the antenna
* connected. The transmit path and the driver's idle gating are
* unaffected. Returns false on radios that do not implement the knob
* (everything but the native SX126x today) and on SX126x boards that
* wire no such line -- including boards whose only FEM control is the
* chip enable, where there is no gain to trade, only a path to cut. */
virtual bool setFemRxEnable(bool enable) { (void)enable; return false; }
/* Multi-SF receive via LoRa side detectors. Only the LR2021 has them;
* every other radio reports the feature as unsupported. `num` = 0
* disables. Returns false if the radio has no side detectors or the
* requested set violates a chip constraint (see
* lr20xx_configure_side_detectors). */
virtual bool configSideDetectors(const uint8_t *sfs, uint8_t num) {
(void)sfs; (void)num;
return false;
}
/* Read-only view of the modem config currently used by buildModemConfig().
* These honor temporary radio overrides for freq/bw/sf/cr and the same TX
* clamps as the actual lora_config() path. */
uint32_t getActiveFrequencyHz() const;
uint16_t getActiveBandwidthKHzX10() const;
uint8_t getActiveSpreadingFactor() const;
uint8_t getActiveCodingRate() const;
uint16_t getActivePreambleLength() const;
uint8_t getActiveSyncWord() const;
int8_t getConfiguredTxPower() const;
bool isTxActive() const { return atomic_get(&_tx_active) != 0; }
/* Duty-cycle preamble false-positive counter.
* Incremented by the driver whenever RX_TX_TIMEOUT fires in
* duty-cycle mode and the chip is silently re-armed. High
* values indicate a noisy RF environment or too-loose preamble
* detection — each event extends real RX time past the nominal
* duty cycle, inflating current draw.
* Default returns 0 on radios that don't support the stat. */
virtual uint32_t getDutyCycleTimeoutRestarts() const { return 0; }
virtual void resetDutyCycleTimeoutRestarts() {}
/* Adaptive CAD (LBT detPeak calibration) */
void setCadParams(bool auto_enabled, int8_t offset,
uint16_t probe_interval_s, uint8_t busycap_pct,
uint8_t stored_base = 0) override;
uint8_t cadBasePeak() override;
void cadMaintenance() override;
/** Deaf-aware AGC unstick + temperature-drift recalibration.
* Called from Dispatcher::maintenanceLoop(); never on the packet path. */
void radioMaintenance() override;
private:
/* The two unrelated jobs radioMaintenance() drives; see its comment. */
void agcIdleMaintenance(uint32_t now);
void imageCalMaintenance(uint32_t now);
public:
uint32_t msUntilNextMaintenance() override;
int8_t getCadOffset() const override { return _cad_offset; }
/* Offset bounds the controller may actually use: the static
* [CAD_LEVEL_MIN, CAD_LEVEL_MAX] window narrowed to whatever the
* hardware clamp leaves distinguishable at the current base. Every
* range decision goes through these; the raw constants stay in use only
* for indexing _cad_stats[], which is sized to the static window. */
int8_t cadLevelMinEff();
int8_t cadLevelMaxEff();
void resetCadStats() override;
int formatCadStatus(char *buf, int cap) override;
protected:
/* ── Hardware primitives — subclass MUST implement ─────────── */
virtual bool hwConfigure(const struct lora_modem_config &cfg) = 0;
virtual void hwCancelReceive() = 0;
virtual int hwSendAsync(uint8_t *buf, uint32_t len,
struct k_poll_signal *sig) = 0;
virtual int16_t hwGetCurrentRSSI() = 0;
/** Read `n` instantaneous RSSI samples spaced `spacing_us` apart.
*
* Exists so the whole burst can be bracketed ONCE by whatever a family
* needs to take a reading, instead of once per sample. On the LR
* families a single RSSI read stands the duty cycle down, enters
* continuous Rx, waits 1 ms to settle, reads, and re-arms the cycle --
* and that re-arm clears every IRQ and zeroes the RX-busy latch. Called
* eight times for one median that is eight cycle tear-downs, eight latch
* wipes and 8 ms of settle, every sampling interval, whether or not a
* CAD probe follows.
*
* Returns the number of valid samples written (< n means the read was
* refused partway and the caller should abandon the burst), or a NEGATIVE
* value when the burst must be abandoned for a reason that is not a read
* failure: -EAGAIN says the receiver detected a preamble or header inside
* the window, so the samples describe that signal and not the floor.
* Worth its own return because the caller's counters exist to tell a
* failing bus from a busy channel, and only a family that brackets its own
* Rx entry is in a position to notice the latter.
*
* The default implementation is the old per-sample loop, which is already
* correct for radios whose RSSI read has no such bracket (SX126x bails on
* BUSY and touches nothing; SX127x has no duty cycle at all) and which
* therefore never reports -EAGAIN. */
virtual int hwGetRssiBurst(int16_t *out, int n, uint32_t spacing_us);
/* Non-destructive read of the radio's "currently receiving" signal —
* latch + raw IRQ bits, never clears. Backs LoRaRadioBase::isReceiving(). */
virtual bool hwIsReceiving() = 0;
virtual void hwSetRxBoost(bool enable) = 0;
/** GPIO-only BUSY check (no SPI). Default false for chips without duty-cycle sleep. */
virtual bool hwIsChipBusy() { return false; }
/* ── Adaptive-CAD primitives — defaults suit chips without hardware
* CAD (SX127x): probing unsupported, offset ignored. ───────────── */
/** Blocking calibration CAD at (family base detPeak + level).
* Returns 0 = free (chip in STANDBY, caller restarts RX),
* 1 = busy, chip in STANDBY, caller restarts RX,
* 2 = busy, chip left in RX on the detected signal (CAD_RX
* exit mode) -- caller must NOT restart RX, and reads the
* outcome later via hwCadRxOutcome(),
* <0 = error / unsupported. */
virtual int hwCadProbe(int8_t level) { (void)level; return -ENOSYS; }
/** Outcome of the RX a hwCadProbe() == 2 left the chip in.
* 1 = a packet completed, so the detection was real;
* 2 = the chip's own CAD timeout expired with nothing decoded;
* 0 = not armed, or the terminal interrupt has not arrived yet.
* Reads driver state only -- no chip access, no polling loop. */
virtual int hwCadRxOutcome() { return 0; }
/** How long the chip may stay in a CAD_RX-entered RX before it raises
* its own timeout. Bounds the wait before hwCadRxOutcome() is read. */
virtual uint32_t hwCadRxTimeoutMs() { return 0; }
/** Apply the operating detPeak offset for all subsequent LBT CADs. */
virtual void hwCadSetPeakOffset(int8_t offset) { (void)offset; }
/** Per-SF base detPeak for the current config (0 = unsupported). */
virtual uint8_t hwCadBasePeak() { return 0; }
/** Absolute detPeak range the driver will actually program, inclusive.
* 0/0 means "no known limit" and the offset range stays as-is.
*
* This exists because the offset window and the hardware clamp are two
* different things, and when they disagree the controller explores
* levels that are physically identical: on the LR2021 at SF7 the base is
* 51 and the driver clamps to 48, so offsets -3 through -8 all programmed
* the same peak. The staircase then compared three rungs of the same
* configuration, found only sampling noise between them, and random-walked
* into the floor with nothing to climb back out on. */
virtual uint8_t hwCadPeakMin() { return 0; }
virtual uint8_t hwCadPeakMax() { return 0; }
/* ── Receiver hygiene — see LoRaRadioBase::radioMaintenance() ─── */
/** Unstick a jammed AGC: warm sleep to drop the analog front end, then
* recalibrate on the way back up. Semtech's stated remedy; the
* datasheets do not describe it, so do not "simplify" it away on
* datasheet grounds alone. Must leave the driver out of RX so the
* caller's startReceive() performs a real re-entry rather than hitting
* an idempotent fast path. Default: unsupported, no-op. */
virtual void hwResetAgc() {}
/** Redo the frequency-dependent calibrations (image / front end, and
* PLL+AAF where the part separates them) at the current operating
* frequency. Called on temperature drift, never on the packet path.
* Same RX-state contract as hwResetAgc(). Default: unsupported. */
virtual void hwRecalibrate() {}
/** Chip junction temperature in whole degrees C, or INT16_MIN when the
* backend cannot measure it (which disables drift recalibration). */
/* Does this family actually suffer the jammed-AGC fault the reset is a
* remedy for? Only the SX126x does. Semtech prescribe warm sleep plus
* recalibration for it, ZephCore inherited the idea from Arduino
* MeshCore's `agc_reset_interval`, and both are SX126x-era. Neither the
* LR11xx UM nor the LR2021 DS describes such a fault; those parts need
* image/front-end recalibration on temperature drift, which is a
* different operation on a different trigger (hwRecalibrate()).
*
* Running it anyway is not free. Measured on a T1000-E 2026-08-23: the
* 60 s after an AGC reset carried a 7.4% packet-miss rate against 0.6%
* elsewhere, and one deaf stretch began at one reset and ended at the
* next — the reset was recovering damage it had caused, on a part with
* no AGC fault to fix. */
virtual bool hwNeedsAgcReset() { return false; }
/* Does this family specify a temperature threshold for image/front-end
* recalibration? LR11xx and LR2021 do; the SX126x datasheet does not,
* and drift recalibration stays inactive there exactly as before. */
virtual bool hwHasDriftRecal() { return false; }
/** Radio deaf time per duty-cycle wake transition (context restore +
* PLL lock + TCXO startup where fitted), in microseconds. Counts
* against the duty-cycle preamble-catch budget: per SX126x DS rev 2.2
* §13.1.7 the TCXO startup delay is inserted between the sleep and RX
* periods, outside both.
*
* This default is a fallback for backends that expose no per-device
* figure; it suits XTAL parts only. SX126x, LR11xx and LR2021 all
* override it, because a board with a TCXO powers the regulator down
* during duty-cycle sleep and pays the oscillator restart on every
* wake — several milliseconds, dwarfing this number. Leaving a TCXO
* board on the default oversizes the sleep window and drops
* window-edge preambles regardless of signal strength. */
virtual uint32_t hwWakeupTimeUs() { return 1500; }
/* Set to true by subclasses using the loramac-node driver backend.
* Disables the direction-only fast path in configureTx()/configureRx():
* loramac-node calls Radio.SetTxConfig() and Radio.SetRxConfig() which
* configure completely disjoint internal state — skipping either leaves
* TxTimeout/RxConfig uninitialized in the loramac-node library. */
bool _loramac_node;
/* ── Shared helpers available to subclasses ────────────────── */
void buildModemConfig(struct lora_modem_config &cfg, bool tx);
/* Shared body for configureRx()/configureTx(): builds the modem config for
* the given direction, honours the params-unchanged and direction-only
* fast paths, then programs the radio via hwConfigure(). */
void configure(bool tx);
void configureRx();
void configureTx();
void startReceive();
void startTxThread(k_thread_stack_t *stack, size_t stack_size);
const struct device *_dev;
NodePrefs *_prefs;
MainBoard *_board;
atomic_t _in_recv_mode;
atomic_t _tx_active;
volatile float _last_rssi; /* word-aligned: atomic on ARM */
volatile float _last_snr; /* word-aligned: atomic on ARM */
/* RX ring buffer */
struct RxPacket {
uint8_t data[256];
uint16_t len;
int16_t rssi;
int8_t snr;
};
RxPacket _rx_ring[RX_RING_SIZE];
atomic_t _rx_head;
atomic_t _rx_tail;
/* TX buffer + signal */
uint8_t _tx_buf[256];
struct k_poll_signal _tx_signal;
/* Noise floor calibration state */
int _noise_floor;
int _calibration_threshold;
uint8_t _ema_unguarded; /* tick counter for warmup + periodic bypass */
/* Absolute uptime deadline of the next floor sample. The sampler used
* to run on every housekeeping tick, which pinned its cadence to the
* 5 s timer; owning its own deadline is what lets that timer go away.
* Advanced by NOISE_FLOOR_INTERVAL_MS after a sample lands, and by the
* shorter retry when an attempt is turned away because the radio was
* mid-packet / transmitting / in its duty-cycle sleep window. */
int64_t _noise_floor_next_ms;
uint8_t _noise_floor_retries; /* consecutive blocked attempts, capped */
/* Shared cadence for every periodic radio measurement (floor sample +
* CAD probe). Runtime, from the probe.interval pref. */
uint32_t _measure_interval_ms;
/* Latest floor sample, published for cadMaintenance() so the CAD probe
* shares this measurement instead of taking its own single RSSI read.
* _sample_fresh is true only within the pass that produced it. */
int16_t _sample_rssi;
bool _sample_channel_quiet;
bool _sample_fresh;
/* Cycle stamp of the last host-driven RX entry, used to skip a floor
* sample taken before GetRssiInst has settled (DS Table 13-82). */
uint32_t _rx_entry_cyc;
/* Median-of-N quality accounting, surfaced by `get cad` as sp:<mean>/<%>.
* The median only rejects outliers if the N reads are independent; if
* they land inside one RSSI averaging window they are the same sample
* N times over and the median is decorative. Spread (max-min of the
* burst) and the share of zero-spread bursts make that visible without
* a debug build. */
uint32_t _rssi_bursts;
uint32_t _rssi_spread_sum;
uint32_t _rssi_degenerate;
/* Adaptive CAD state */
struct CadLevelStats {
uint16_t probes; /* probes run at this level */
uint16_t busy; /* raw busy verdicts */
uint16_t fp; /* busy that passed the ground-truth filter (suspected false positive) */
uint16_t tp; /* busy confirmed by RX activity right after */
};
CadLevelStats _cad_stats[CAD_NUM_LEVELS];
bool _cad_auto; /* staircase acts on the stats */
int8_t _cad_offset; /* operating detPeak offset (levels) */
uint16_t _probe_interval_s; /* 0 = CAD probing disabled; drives _measure_interval_ms */
uint8_t _cad_busycap_pct; /* airtime cap: max % TX deferred (0 = off) */
/* A CAD_RX probe awaiting its terminal event. _cad_pending_level is the
* stats rung the result belongs to; _cad_pending_deadline_ms is when the
* chip guarantees it has resolved, so the answer is READ once at that
* point rather than polled for. Level INT8_MIN means nothing pending. */
int8_t _cad_pending_level;
int64_t _cad_pending_deadline_ms;
int64_t _cad_last_probe_ms;
int64_t _cad_last_decay_ms;
/* Earliest uptime at which a due-but-blocked probe may be retried. The
* interval check in cadMaintenance() is against _cad_last_probe_ms,
* which only advances on a probe that actually ran — without this a
* blocked probe would report "due now" forever and spin the wake. */
uint8_t _cad_probe_rr; /* round-robin index (sweep) / frontier mix counter */
int8_t pickCadProbeLevel();
void decayCadStats();
void cadStaircaseStep();
/* Power saving */
bool _rx_duty_cycle_enabled;
bool _rx_boost_enabled;
/* Last duty-cycle timing handed to the driver — used to log timing
* changes once at INF instead of on every RX restart. 0/0 = never
* computed; UINT32_MAX rx = continuous-RX fallback active. */
uint32_t _dc_last_rx_us;
uint32_t _dc_last_sleep_us;
/* agcIdleMaintenance() bookkeeping. RX activity is inferred by sampling the
* existing packet counters rather than timestamping in the RX callback,
* so nothing is added to the ISR path. */
uint32_t _agc_rx_count_shadow;
uint32_t _agc_last_activity_ms;
/* Stuck-AGC corroboration. The noise-floor sampler already produces an
* RSSI every interval; a desensitised front end reports a frozen one.
* Free evidence — no extra command, no extra wake. */
int16_t _agc_rssi_last;
uint8_t _agc_rssi_frozen;
/* Diagnostics only — no behaviour depends on these.
*
* The RSSI sampler discards a whole burst if any read comes back busy,
* and on a duty-cycled LR1110 it completes ~0.2% of its attempts
* (measured 5 bursts in 9 h against 1007 on an SX1262 beside it). That
* is either "a few reads land in the sleep phase" or "essentially every
* read is refused", and the two want opposite fixes. These counters say
* which, and are reported by `get cad`. */
uint32_t _rssi_reads_ok;
uint32_t _rssi_reads_busy;
uint32_t _rssi_bursts_abandoned;
/* Attempts turned away BEFORE a burst starts, by the isRadioReady()
* guard — i.e. the duty-cycle sleep window. The three counters above
* cannot see this: they only move once a burst is already running, so a
* sampler that is being refused outright reads as b0/a0, which is
* indistinguishable from one that is running perfectly. That ambiguity
* is what makes the "does the floor go stale under duty cycle?" question
* unanswerable today, and it is the missing denominator for the ~0.2%
* completion rate noted above. */
uint32_t _rssi_dc_blocked;
/* Silence tracking for deafness hunting. Reports only; the recovery
* decision lives in agcIdleMaintenance() and is family-gated. */
uint32_t _silence_last_report_ms;
int16_t _image_cal_last_temp_c;
/* Image-calibration temperature polling. Deliberately much slower than
* the maintenance cadence — see imageCalMaintenance(). */
uint32_t _image_cal_last_ms;
uint32_t _image_cal_wait_ms;
bool _image_cal_started;
bool _image_cal_confirming;
/* Start of the most recent transmit, for the post-TX quiet window the
* temperature poll waits out. Start-referenced rather than end-
* referenced so it can be stamped at the single startSendRaw() site
* instead of all five _tx_active clear paths; airtime is bounded by
* seconds and the quiet window by a minute, so the difference does not
* matter. */
uint32_t _last_tx_start_ms;
/* Config cache — skip redundant hwConfigure() */
struct lora_modem_config _last_cfg;
bool _config_cached;
/* Radio param override — when set, buildModemConfig() uses these
* for freq/bw/sf/cr instead of _prefs. Everything else (tx_power,
* preamble) still comes from _prefs. */
bool _has_radio_override;
float _override_freq;
float _override_bw;
uint8_t _override_sf;
uint8_t _override_cr;
/* ISR RX callback — passed to lora_recv_async() / lora_recv_duty_cycle() */
static void rxCallbackStatic(const struct device *dev, uint8_t *data,
uint16_t size, int16_t rssi, int8_t snr,
void *user_data);
private:
/* RX notification callback */
RadioRxCallback _rx_cb;
void *_rx_cb_user_data;
/* TX done callback */
RadioTxDoneCallback _tx_done_cb;
void *_tx_done_cb_user_data;
/* TX completion thread */
static void txWaitThreadFn(void *p1, void *p2, void *p3);
uint32_t txWaitBudgetMs() const;
struct k_thread _tx_wait_thread;
struct k_sem _tx_start_sem;
bool _tx_thread_running;
/* Length of the transmit in flight, for txWaitBudgetMs(). Written by
* startSendRaw() before it releases _tx_start_sem, read by the wait
* thread after it takes that sem — the sem is the handoff, so no
* additional synchronisation is needed. */
uint16_t _tx_len;
/* Packet statistics */
atomic_t _packets_recv;
atomic_t _packets_sent;
atomic_t _packets_recv_errors;
};
} /* namespace mesh */