Files
firmware/src/mesh/LR11x0Interface.cpp
T
Jonathan BennettandClaude Opus 5.5 a10c96f790 T-Beam 1W: support the LR1121 and LR2021 radio modules (#12204)
* T-Beam 1W: support the LR2021 radio module

LilyGo ships the T-Beam 1W with either an SX1262 or an LR2021 module on
the same pins. Build both drivers into the t-beam-1w env; initLoRa()
probes the SX1262 first and keeps whichever radio answers, so SX1262
boards are unchanged.

LR2021 settings follow LilyGo's t_beam_1w_lr2021 documentation: IRQ on
radio DIO10 (GPIO1), TCXO at 3.0 V, and PA/LNA switching on radio
DIO5-DIO8 (rfswitch.h). The 2.4 GHz PA must not see more than 8 dBm, so
LR2021_MAX_POWER_HF is 8. GPIO21 is radio DIO11 on this module; the
SX1262 probe only claims it as RXEN after a successful begin(), so it is
never driven on an LR2021 board.

TX_GAIN_LORA stays at the SX1262 module's 10 dB. The LR2021 module's
gain is about 8 dB, so it is driven slightly below its maximum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* T-Beam 1W: release SX126X_RXEN before the LR2021 probe

SX126xInterface::init() calls setRfSwitchPins() before setCurrentLimit(),
setCRC() and setOutputPower(), so a begin() that succeeds before one of
those fails leaves RXEN configured as an output driven low. initLoRa()
then drops the interface and probes the LR2021, which drives the same
pin itself - GPIO21 is radio DIO11 on the T-Beam 1W's LR2021 module.

Release the pin on that path, for the boards that can carry either
radio and wire an RXEN.

* T-Beam 1W: also support the LR1121 radio module

LilyGo also ships the T-Beam 1W with an LR1121 module on the same pins.
Add it to the t-beam-1w env; initLoRa() probes SX1262, LR1121, LR2021
in that order and keeps whichever answers.

LR1121 settings follow LilyGo's t_beam_1w_lr1121 documentation and
factory example: TCXO at 3.0 V, PA/LNA switching on radio DIO6-DIO8
(sub-GHz PA on DIO8 in TX_HP, 2.4 GHz PA on DIO7, 2.4 GHz LNA on DIO6).
LilyGo warns that more than 1 dBm into the 2.4 GHz PA can damage it, so
LR1120_MAX_POWER (the LR11x0 LORA_24 clamp) is 1.

On an LR2021 board the LR1121 probe returns RADIOLIB_ERR_CHIP_NOT_FOUND
after about 3.2 s and the LR2021 then initialises normally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* T-Beam 1W LR1121: IRQ on GPIO21, force the high-power PA

The LR1121 module's IRQ is radio DIO11 on GPIO21 (LilyGO utilities.h:
RADIO_DIO_IRQ_PIN = RADIO_DIO11_PIN), not GPIO1. With GPIO1 the first
listen-before-talk CAD waited forever for CAD_DONE and froze the node.

The module's sub-GHz antenna is only reachable through the high-power PA
path (external PA enabled on DIO8 in MODE_TX_HP), but RadioLib selects the
low-power PA at 14 dBm and below, where every switch is off. Add an opt-in
LR11X0_FORCE_HP_PA that calls setOutputPower(power, true), clamped to the
HP PA's -9 dBm floor, at init and in programModemParams(), and enable it
for this board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* T-Beam 1W LR1121: list RF switch DIOs from DIO5

RadioLib's LR11x0::setRfSwitchTable() sets each mode's bits by the pin's
position in the array, counting from DIO5, while the enable mask uses the
DIO number. A table starting at DIO6 therefore drove every line one DIO
low: sub-GHz TX raised DIO7 (the 2.4 GHz PA) instead of DIO8, so the
sub-GHz PA and TX/RX switch never left RX and the signal only leaked out.

List DIO5 (unused, always low) first, as LilyGo's documentation and
factory firmware do. Measured at 10 dBm against an SX1262 node: peer RSSI
-100 dBm before, -29 dBm after; 20/20 each way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* T-Beam 1W: also release SX126X_RXEN before the LR1121 probe

GPIO21 is the LR1121 module's IRQ (radio DIO11), so an RXEN left driven
low by a partly failed SX1262 init would mask its interrupts and hang the
first channel scan. Extend the release to boards that build the LR1121.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:35:12 +00:00

808 lines
35 KiB
C++

#if RADIOLIB_EXCLUDE_LR11X0 != 1
#include "LR11x0Interface.h"
#include "Throttle.h"
#include "configuration.h"
#include "error.h"
#include "mesh/NodeDB.h"
// A variant may define LR11X0_UPDATE_FIRMWARE_TO to a Semtech transceiver firmware version (e.g. 0x0402) to
// bake that image in and update the radio on first boot. Every supported image is 61320 words, so this costs
// ~240 kB of flash regardless of the version chosen - only enable it on a variant with the headroom, and
// only for as long as it takes to update the affected units.
#ifdef LR11X0_UPDATE_FIRMWARE_TO
#if LR11X0_UPDATE_FIRMWARE_TO == 0x0402
#define RADIOLIB_LR1110_FIRMWARE_0402
#elif LR11X0_UPDATE_FIRMWARE_TO == 0x0401
#define RADIOLIB_LR1110_FIRMWARE_0401
#elif LR11X0_UPDATE_FIRMWARE_TO == 0x0307
#define RADIOLIB_LR1110_FIRMWARE_0307
#else
// Note: RadioLib ships lr1110_transceiver_0308.h but has no selector for it in LR11x0_firmware.h.
#error "LR11X0_UPDATE_FIRMWARE_TO must be one of 0x0307, 0x0401, 0x0402"
#endif
#include <modules/LR11x0/LR11x0_firmware.h>
#endif
#ifdef LR11X0_DIO_AS_RF_SWITCH
#include "rfswitch.h"
#elif ARCH_PORTDUINO
#include "PortduinoGlue.h"
// Switch-capable DIOs in slot order with this part's constants; no DIO9, so slot 4 is DIO10.
static const int8_t lr11x0_switch_dio_nums[] = {5, 6, 7, 8, 10};
static const uint32_t lr11x0_switch_dio_consts[] = {RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11X0_DIO6, RADIOLIB_LR11X0_DIO7,
RADIOLIB_LR11X0_DIO8, RADIOLIB_LR11X0_DIO10};
static_assert(sizeof(lr11x0_switch_dio_nums) / sizeof(lr11x0_switch_dio_nums[0]) ==
sizeof(lr11x0_switch_dio_consts) / sizeof(lr11x0_switch_dio_consts[0]),
"LR11x0 switch DIO numbers and constants must describe the same slots");
// This part has MODE_TX_HP/MODE_GNSS/MODE_WIFI and no MODE_RX_HF.
static const int32_t lr11x0_rfswitch_mode_map[RFSW_MODE_COUNT] = {
LR11x0::MODE_STBY, LR11x0::MODE_RX, LR11x0::MODE_TX, LR11x0::MODE_TX_HP,
LR11x0::MODE_TX_HF, RFSW_MODE_UNSUPPORTED, LR11x0::MODE_GNSS, LR11x0::MODE_WIFI,
};
static uint32_t rfswitch_dio_pins[Module::RFSWITCH_MAX_PINS];
static Module::RfSwitchMode_t rfswitch_table[RFSW_MODE_COUNT + 1];
#else
static const uint32_t rfswitch_dio_pins[] = {RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC};
static const Module::RfSwitchMode_t rfswitch_table[] = {
{LR11x0::MODE_STBY, {}}, {LR11x0::MODE_RX, {}}, {LR11x0::MODE_TX, {}}, {LR11x0::MODE_TX_HP, {}},
{LR11x0::MODE_TX_HF, {}}, {LR11x0::MODE_GNSS, {}}, {LR11x0::MODE_WIFI, {}}, END_OF_MODE_TABLE,
};
#endif
// Particular boards might define a different max power based on what their hardware can do, default to max power output if not
// specified (may be dangerous if using external PA and LR11x0 power config forgotten)
#if ARCH_PORTDUINO
#define LR1110_MAX_POWER portduino_config.lr1110_max_power
#endif
#ifndef LR1110_MAX_POWER
#define LR1110_MAX_POWER 22
#endif
// the 2.4G part maxes at 13dBm
#if ARCH_PORTDUINO
#define LR1120_MAX_POWER portduino_config.lr1120_max_power
#endif
#ifndef LR1120_MAX_POWER
#define LR1120_MAX_POWER 13
#endif
// Vref to assume for a board that declares a TCXO may be fitted without saying at what voltage.
// "TCXO reference voltage to be set on DIO3. Defaults to 1.6 V, set to 0 to skip." per
// https://github.com/jgromes/RadioLib/blob/690a050ebb46e6097c5d00c371e961c1caa3b52e/src/modules/LR11x0/LR11x0.h#L471C26-L471C104
static inline float lr11x0TcxoDefaultVoltage()
{
if (TCXO_OPTIONAL_ENABLED)
return TCXO_OPTIONAL_DEFAULT_VOLTAGE;
return 0;
}
// A chip that never answers can surface either way depending on where RadioLib gave up: a bounded
// per-command BUSY wait in Module::SPItransferStream() reports SPI_CMD_TIMEOUT rather than
// SPI_CMD_FAILED, so both have to count as "the chip did not talk to us"
static inline bool lr11x0SpiFailed(int res)
{
return res == RADIOLIB_ERR_SPI_CMD_FAILED || res == RADIOLIB_ERR_SPI_CMD_TIMEOUT;
}
template <typename T>
LR11x0Interface<T>::LR11x0Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst,
RADIOLIB_PIN_TYPE busy)
: RadioLibInterface(hal, cs, irq, rst, busy, &lora), lora(&module)
{
LOG_DEBUG_RADIO("LR11x0Interface(cs=%d, irq=%d, rst=%d, busy=%d)", cs, irq, rst, busy);
}
/// Initialise the Driver transport hardware and software.
/// Make sure the Driver is properly configured before calling init().
/// \return true if initialisation succeeded.
template <typename T> bool LR11x0Interface<T>::init()
{
#ifdef LR11X0_POWER_EN
pinMode(LR11X0_POWER_EN, OUTPUT);
digitalWrite(LR11X0_POWER_EN, HIGH);
#endif
// An explicit Vref always wins; TCXO_OPTIONAL only supplies a default for boards that declare a
// TCXO may be fitted without saying at what voltage. Both may appear in the same variant file.
#if ARCH_PORTDUINO
// Portduino leaves dio3_tcxo_voltage at 0 whenever the YAML omits DIO3_TCXO_VOLTAGE, which is the
// "no explicit Vref" case, so the TCXO_OPTIONAL default still has to apply there
float tcxoVoltage =
portduino_config.dio3_tcxo_voltage > 0 ? (float)portduino_config.dio3_tcxo_voltage / 1000 : lr11x0TcxoDefaultVoltage();
#elif defined(LR11X0_DIO3_TCXO_VOLTAGE)
float tcxoVoltage = LR11X0_DIO3_TCXO_VOLTAGE;
#else
float tcxoVoltage = lr11x0TcxoDefaultVoltage();
#endif
// DIO3 is free to be used as an IRQ only while no TCXO Vref is driven on it
if (tcxoVoltage > 0)
LOG_DEBUG_RADIO("LR11x0 TCXO Vref %f V on DIO3 (DIO3 unavailable as IRQ)", tcxoVoltage);
else
LOG_DEBUG_RADIO("LR11x0 no TCXO Vref, XTAL only (DIO3 free as IRQ)");
if (TCXO_OPTIONAL_ENABLED)
LOG_DEBUG_RADIO("TCXO_OPTIONAL: osc type unknown, probe XTAL first, TCXO Vref as fallback");
RadioLibInterface::init();
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range
limitPower(LR1120_MAX_POWER);
} else {
limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range
}
#ifdef LR11X0_RF_SWITCH_SUBGHZ
pinMode(LR11X0_RF_SWITCH_SUBGHZ, OUTPUT);
digitalWrite(LR11X0_RF_SWITCH_SUBGHZ, getFreq() < 1e9 ? HIGH : LOW);
LOG_DEBUG_RADIO("Set RF0 switch to %s", getFreq() < 1e9 ? "SubGHz" : "2.4GHz");
#endif
#ifdef LR11X0_RF_SWITCH_2_4GHZ
pinMode(LR11X0_RF_SWITCH_2_4GHZ, OUTPUT);
digitalWrite(LR11X0_RF_SWITCH_2_4GHZ, getFreq() < 1e9 ? LOW : HIGH);
LOG_DEBUG_RADIO("Set RF1 switch to %s", getFreq() < 1e9 ? "SubGHz" : "2.4GHz");
#endif
// Allow extra time for TCXO to stabilize after power-on
delay(10);
// Timestamped brackets so a hang inside RadioLib leaves a dangling "attempt" line in the boot log
auto tryBegin = [&](int attempt, float vref) {
uint32_t attemptStart = millis();
LOG_INFO("LR11x0 begin() attempt %d: tcxoVoltage=%.3fV at t=%ums", attempt, vref, attemptStart);
int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, vref);
LOG_INFO("LR11x0 begin() attempt %d returned %d after %ums", attempt, res, millis() - attemptStart);
return res;
};
// 1. XTAL first when probing (see TCXO_OPTIONAL_ENABLED), else the configured Vref. Not a
// ternary: cppcheck sees both branches as 0 when tcxoVoltage above already folded to it.
float attemptVoltage = tcxoVoltage;
if (TCXO_OPTIONAL_ENABLED)
attemptVoltage = 0;
int res = tryBegin(1, attemptVoltage);
// 2. XTAL failed with the chip present, so fall back to the TCXO if one was configured
if (TCXO_OPTIONAL_ENABLED && res != RADIOLIB_ERR_NONE && res != RADIOLIB_ERR_CHIP_NOT_FOUND && tcxoVoltage > 0) {
LOG_WARN("LR11x0 XTAL init failed (err %d), retry with TCXO Vref %f V", res, tcxoVoltage);
attemptVoltage = tcxoVoltage;
res = tryBegin(2, attemptVoltage);
if (res == RADIOLIB_ERR_NONE)
LOG_INFO("LR11x0 init success with TCXO Vref %f V", tcxoVoltage);
}
// 3. Some units need extra settling time, so give whichever oscillator we settled on one retry.
// After a step 2 fallback that is a second TCXO attempt, which is where settling actually matters.
if (lr11x0SpiFailed(res)) {
LOG_WARN("LR11x0 init failed with %d (SPI cmd failure), retry after delay", res);
delay(100);
res = tryBegin(3, attemptVoltage);
}
resolvedTcxoVoltage = attemptVoltage;
// \todo Display actual typename of the adapter, not just `LR11x0`
LOG_INFO("LR11x0 init result %d", res);
if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || lr11x0SpiFailed(res)) {
#ifdef LR11X0_UPDATE_FIRMWARE_TO
// An interrupted update leaves the radio sitting in bootloader mode, where begin() fails. Retry the
// flash from here rather than giving up, otherwise the device could never recover on its own.
LOG_WARN("LR11x0 did not start; firmware recovery in case update was interrupted");
if (lora.updateFirmware(lr11xx_firmware_image, LR11XX_FIRMWARE_IMAGE_SIZE, true) == RADIOLIB_ERR_NONE) {
LOG_INFO("LR1110 firmware recovery OK, re-init radio");
res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage);
if (res == RADIOLIB_ERR_NONE)
resolvedTcxoVoltage = tcxoVoltage;
}
#endif
if (res != RADIOLIB_ERR_NONE)
return false;
}
LR11x0VersionInfo_t version;
res = lora.getVersionInfo(&version);
if (res == RADIOLIB_ERR_NONE) {
LOG_DEBUG_RADIO("LR11x0 Device %d, HW %d, FW %d.%d, WiFi %d.%d, GNSS %d.%d", version.device, version.hardware,
version.fwMajor, version.fwMinor, version.fwMajorWiFi, version.fwMinorWiFi, version.fwGNSS,
version.almanacGNSS);
transceiverFw = ((uint16_t)version.fwMajor << 8) | version.fwMinor;
transceiverDevice = version.device;
}
#ifdef LR11X0_UPDATE_FIRMWARE_TO
// One-shot transceiver firmware update, opt-in per variant. Only runs when the part is an LR1110 running
// older firmware than the baked-in image, so once it has succeeded it is a no-op on subsequent boots.
if (transceiverDevice == RADIOLIB_LR11X0_DEVICE_LR1110 && transceiverFw != 0 && transceiverFw < LR11X0_UPDATE_FIRMWARE_TO) {
LOG_WARN("LR1110 transceiver FW %d.%d older than %d.%d - updating. DO NOT POWER OFF: "
"rewrites radio's own flash",
transceiverFw >> 8, transceiverFw & 0xFF, LR11X0_UPDATE_FIRMWARE_TO >> 8, LR11X0_UPDATE_FIRMWARE_TO & 0xFF);
int upd = lora.updateFirmware(lr11xx_firmware_image, LR11XX_FIRMWARE_IMAGE_SIZE, true);
if (upd != RADIOLIB_ERR_NONE) {
// The radio is likely sitting in bootloader mode. It is not bricked - the update is retried on
// the next boot because the version check above will still see old (or unreadable) firmware.
LOG_ERROR("LR1110 firmware update FAILED %s%d - power-cycle to retry", radioLibErr, upd);
return false;
}
LOG_INFO("LR1110 firmware update complete, re-init radio");
res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage);
if (res != RADIOLIB_ERR_NONE) {
LOG_ERROR("LR11x0 re-init after firmware update failed %s%d", radioLibErr, res);
return false;
}
resolvedTcxoVoltage = tcxoVoltage;
if (lora.getVersionInfo(&version) == RADIOLIB_ERR_NONE) {
transceiverFw = ((uint16_t)version.fwMajor << 8) | version.fwMinor;
transceiverDevice = version.device;
LOG_INFO("LR1110 now running transceiver FW %d.%d", version.fwMajor, version.fwMinor);
}
}
#endif
LOG_INFO("Frequency set to %f", getFreq());
LOG_INFO("Bandwidth set to %f", bw);
LOG_INFO("Power output set to %d", power);
if (res == RADIOLIB_ERR_NONE) {
// Every begin() above reset the delay to RadioLib's default
applyTcxoStartupDelay(lora, resolvedTcxoVoltage);
res = lora.setCRC(2);
}
// FIXME: May want to set depending on a definition, currently all LR1110 variant files use the DC-DC regulator option
if (res == RADIOLIB_ERR_NONE)
res = lora.setRegulatorDCDC();
#ifdef LR11X0_DIO_AS_RF_SWITCH
bool dioAsRfSwitch = true;
#elif defined(ARCH_PORTDUINO)
bool dioAsRfSwitch = portduino_config.has_rfswitch_table;
if (dioAsRfSwitch)
buildRfSwitchTable(rfswitch_dio_pins, rfswitch_table, RFSW_MODE_COUNT + 1, lr11x0_switch_dio_nums,
lr11x0_switch_dio_consts, sizeof(lr11x0_switch_dio_nums) / sizeof(lr11x0_switch_dio_nums[0]),
lr11x0_rfswitch_mode_map);
#else
bool dioAsRfSwitch = false;
#endif
if (dioAsRfSwitch) {
lora.setRfSwitchTable(rfswitch_dio_pins, rfswitch_table);
LOG_DEBUG_RADIO("Set DIO RF switch");
}
#ifdef LR11X0_FORCE_HP_PA
// begin() picked the PA from the power level; re-apply it on the high-power PA (see applyOutputPower())
if (res == RADIOLIB_ERR_NONE)
res = applyOutputPower();
#endif
if (res == RADIOLIB_ERR_NONE) {
if (config.lora.sx126x_rx_boosted_gain) { // the name is unfortunate but historically accurate
res = lora.setRxBoostedGainMode(true);
LOG_INFO("Set RX gain to boosted mode; result: %d", res);
} else {
res = lora.setRxBoostedGainMode(false);
LOG_INFO("Set RX gain to power saving mode; result: %d", res);
}
}
if (res == RADIOLIB_ERR_NONE) {
boundBusyWait();
startReceive(); // start receiving
}
return res == RADIOLIB_ERR_NONE;
}
template <typename T> int16_t LR11x0Interface<T>::applyOutputPower()
{
#ifdef LR11X0_FORCE_HP_PA
// The board reaches its sub-GHz antenna only through the high-power PA, but RadioLib picks the
// low-power PA at 14 dBm and below. The high-power PA's lower limit is -9 dBm.
return lora.setOutputPower(power < -9 ? (int8_t)-9 : power, true);
#else
return lora.setOutputPower(power);
#endif
}
template <typename T> int16_t LR11x0Interface<T>::programModemParams()
{
// configure publicly accessible settings
int16_t err = lora.setSpreadingFactor(sf);
if (err != RADIOLIB_ERR_NONE) {
LOG_ERROR("LR11x0 setSpreadingFactor(%u) %s%d", sf, radioLibErr, err);
return err;
}
err = lora.setBandwidth(bw, wideLora() && (getFreq() > 1000.0f));
if (err != RADIOLIB_ERR_NONE) {
LOG_ERROR("LR11x0 setBandwidth(%.1f) %s%d", bw, radioLibErr, err);
return err;
}
err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it
if (err != RADIOLIB_ERR_NONE) {
LOG_ERROR("LR11x0 setCodingRate(%u) %s%d", cr, radioLibErr, err);
return err;
}
err = lora.setSyncWord(syncWord);
if (err != RADIOLIB_ERR_NONE) {
LOG_ERROR("LR11x0 setSyncWord %s%d", radioLibErr, err);
return err;
}
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range
limitPower(LR1120_MAX_POWER);
} else {
limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range
}
err = lora.setPreambleLength(preambleLength);
if (err != RADIOLIB_ERR_NONE) {
LOG_ERROR("LR11x0 setPreambleLength(%u) %s%d", preambleLength, radioLibErr, err);
return err;
}
err = lora.setFrequency(getFreq());
if (err != RADIOLIB_ERR_NONE) {
LOG_ERROR("LR11x0 setFrequency(%.3f) %s%d", getFreq(), radioLibErr, err);
return err;
}
err = applyOutputPower();
if (err != RADIOLIB_ERR_NONE) {
LOG_ERROR("LR11x0 setOutputPower(%d) %s%d", power, radioLibErr, err);
return err;
}
// Apply RX gain mode - valid in STDBY, matches resetAGC() pattern
err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
if (err != RADIOLIB_ERR_NONE)
LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err);
return RADIOLIB_ERR_NONE;
}
template <typename T> bool LR11x0Interface<T>::reinitChip()
{
// Clamp here, not just in programModemParams(): applyModemConfig() resets `power` to the raw
// config value, and the recovery path reaches begin() without passing through the params clamp
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range
limitPower(LR1120_MAX_POWER);
} else {
limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range
}
rxArmedContinuous = false; // begin() resets the chip
int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, resolvedTcxoVoltage);
if (res == RADIOLIB_ERR_NONE) {
// begin() reset the delay to RadioLib's default
applyTcxoStartupDelay(lora, resolvedTcxoVoltage);
res = lora.setCRC(2);
}
if (res == RADIOLIB_ERR_NONE)
res = lora.setRegulatorDCDC();
#ifdef LR11X0_DIO_AS_RF_SWITCH
bool dioAsRfSwitch = true;
#elif defined(ARCH_PORTDUINO)
bool dioAsRfSwitch = portduino_config.has_rfswitch_table;
#else
bool dioAsRfSwitch = false;
#endif
// setRfSwitchTable() pushed the DIO switch config to the chip when init() called it; a reset chip has
// lost it and begin() does not restore it
if (res == RADIOLIB_ERR_NONE && dioAsRfSwitch)
lora.setRfSwitchTable(rfswitch_dio_pins, rfswitch_table);
if (res != RADIOLIB_ERR_NONE)
LOG_ERROR("LR11x0 re-init failed %s%d", radioLibErr, res);
return res == RADIOLIB_ERR_NONE;
}
template <typename T> bool LR11x0Interface<T>::reconfigure()
{
// A readout between these calls would clear the flags they set up, or move the chip out from under them
RadioSequence seq(this);
RadioLibInterface::reconfigure();
#ifdef MESHTASTIC_RX_READOUT_TASK
// A reconfigure standbys the chip and owns the re-arm that follows, so an RX the readout task armed at TX_DONE is
// void from here. Left set, adoptReceiveArmedFromIsr() would report armed for an RX that no longer exists and, via
// the base startReceive() it calls, clear rxOffline and chipRecoveryFailures for a chip that never took one -
// zeroing the ladder and the rxOffline that periodicRadioMaintenance() gates its retry on.
rearmState = REARM_NONE;
#endif
// set mode to standby - a chip that lost its state to a reset/brownout can time out here (-707),
// so don't let setStandby()'s assert fire before the recovery below gets a chance
int16_t err = trySetStandby();
if (err == RADIOLIB_ERR_NONE)
err = programModemParams();
if (err != RADIOLIB_ERR_NONE) {
// A chip that fails standby or rejects parameter programming (typically WRONG_MODEM, -20) has
// lost its runtime configuration - packet type included - to a chip-internal reset or brownout.
// Recover in place: begin() hardware-resets the chip and restores the LoRa packet type. Crashing
// here instead would reboot before MeshService persists the config change that triggered us.
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
LOG_ERROR("LR11x0 rejected modem params, chip state lost? Full re-init");
if (!reinitChip() || (err = programModemParams()) != RADIOLIB_ERR_NONE) {
LOG_ERROR("LR11x0 unrecoverable %s%d, radio down until reboot", radioLibErr, err);
return false;
}
LOG_INFO("LR11x0 recovered after re-init");
}
startReceive(); // restart receiving
return true;
}
template <typename T> void LR11x0Interface<T>::clearRadioIsr()
{
lora.clearIrqAction();
}
template <typename T> int16_t LR11x0Interface<T>::trySetStandby()
{
// Handle any pending interrupts before we force standby, but not a TX whose backoff timer is pending: it would start
// here, before its delay is up, and the standby below would cut it off with its payload still in the radio
checkNotificationExcept(TRANSMIT_DELAY_COMPLETED);
int16_t err = lora.standby();
if (err != RADIOLIB_ERR_NONE) {
LOG_DEBUG_RADIO("LR11x0 standby failed, err %d", err);
}
isReceiving = false; // If we were receiving, not any more
rxArmedContinuous = false;
activeReceiveStart = 0;
disableInterrupt();
completeSending(); // If we were sending, not anymore
RadioLibInterface::setStandby();
return err;
}
template <typename T> void LR11x0Interface<T>::setStandby()
{
int16_t err = trySetStandby();
assert(err == RADIOLIB_ERR_NONE);
}
/**
* Add SNR data to received messages
*/
template <typename T> void LR11x0Interface<T>::addReceiveMetadata(meshtastic_MeshPacket *mp)
{
// LOG_DEBUG("PacketStatus %x", lora.getPacketStatus());
mp->rx_snr = lora.getSNR();
mp->rx_rssi = lround(lora.getRSSI());
mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding
LOG_DEBUG_RADIO("Corrected frequency offset: %f", lora.getFrequencyError());
}
/** We override to turn on transmitter power as needed.
*/
template <typename T> void LR11x0Interface<T>::configHardwareForSend()
{
rxArmedContinuous = false; // the transmission takes the chip out of RX
#ifdef MESHTASTIC_RX_READOUT_TASK
rearmState = REARM_NONE; // only this TX's TX_DONE may re-arm, never a stale one from a TX the poll completed
rxArmedBeforeTxDone = false;
#endif
RadioLibInterface::configHardwareForSend();
}
// For power draw measurements, helpful to force radio to stay sleeping
// #define SLEEP_ONLY
template <typename T> void LR11x0Interface<T>::startReceive()
{
#ifdef SLEEP_ONLY
sleep();
#else
int16_t err = trySetStandby();
if (err == RADIOLIB_ERR_NONE) {
lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed.
// We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly.
err =
lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0);
}
if (err != RADIOLIB_ERR_NONE) {
LOG_ERROR("StartReceive error: %d", err);
if (maybeRecoverChipStateLoss()) {
lora.setPreambleLength(preambleLength);
err = lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS,
RADIOLIB_IRQ_RX_DEFAULT_MASK, 0);
}
}
if (err != RADIOLIB_ERR_NONE) {
// No assert: leave RX off rather than reboot; periodicRadioMaintenance() re-arms it, throttled
LOG_ERROR("LR11x0 RX offline %s%d", radioLibErr, err);
rxOffline = true;
return;
}
RadioLibInterface::startReceive();
rxArmedContinuous = true; // RADIOLIB_LR11X0_RX_TIMEOUT_INF: continuous
// Must be done AFTER, starting transmit, because startTransmit clears (possibly stale) interrupt pending register bits
enableInterrupt(isrRxLevel0);
checkRxDoneIrqFlag();
#endif
}
template <typename T> uint8_t LR11x0Interface<T>::readChipMode()
{
// GetStatus is protected in RadioLib, so read it the way LRxxxx::getStatus() does: any NOP transfer returns stat1,
// stat2 and the IRQ word. SPItransferStream() drops the status width (8 bits on LR11x0) from the front of what it
// returns, so stat2 lands in buff[0]. FS is the chip on its way to TX or RX: look again for up to 1 ms.
const uint8_t skipped = module.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_STATUS] / 8;
if (skipped > 1)
return 0xFF;
uint8_t buff[6] = {0};
uint8_t mode = 0xFF;
for (int tries = 0; tries < 10; tries++) {
if (module.SPItransferStream(NULL, 0, false, NULL, buff, sizeof(buff), true) != RADIOLIB_ERR_NONE)
return 0xFF;
mode = buff[1 - skipped] & 0x0E;
if (mode != RADIOLIB_LR11X0_STAT_2_MODE_FS)
break;
delayMicroseconds(100);
}
return mode;
}
template <typename T> bool LR11x0Interface<T>::resumeRunningReceive()
{
// Continuous RX survives RX_DONE and CRC or header errors: the chip is still listening. A restart is a standby and
// the whole RX setup again, deaf throughout, so pick the RX back up instead.
if (!rxArmedContinuous)
return false;
// Only a chip that reports RX is resumed: one that left it unseen (a chip reset, or RX not surviving the frame) would
// otherwise stay deaf with nothing left to re-arm it. The restart also runs the chip-state recovery.
const uint8_t mode = readChipMode();
if (mode != RADIOLIB_LR11X0_STAT_2_MODE_RX) {
LOG_WARN("LR11x0 RX not running after a frame (stat2 mode 0x%02x), restarting it", mode);
rxArmedContinuous = false;
return false;
}
// No flag clearing here: whoever gave up on a frame unread dropped its flags (clearReadIrqs()), and readData()
// drops them for a frame it read, so a latched RX_DONE now is the next frame's.
activeReceiveStart = 0; // the frame it timed is done; a preamble now is the next one
RadioLibInterface::startReceive();
enableInterrupt(isrRxLevel0);
checkRxDoneIrqFlag(); // an RX_DONE that beat the resume
return true;
}
#ifdef MESHTASTIC_RX_READOUT_TASK
template <typename T> bool INTERRUPT_ATTR LR11x0Interface<T>::rearmReceiveFromIsr()
{
// The interrupt cannot call RadioLib, so the readout task, above the main loop, re-arms as soon as it returns.
rearmState = REARM_PENDING;
if (requestRearmFromIsr())
return true;
rearmState = REARM_NONE;
return false;
}
template <typename T> void LR11x0Interface<T>::rearmReceiveFromTask()
{
// Outside any radio-thread sequence. If the thread got there first, it took the re-arm over and left nothing to do.
RadioSequence seq(this);
if (rearmState != REARM_PENDING)
return;
// What startReceive() sends, less the standby: after TX_DONE the chip has already fallen back to standby.
int16_t err = lora.setPreambleLength(preambleLength);
if (err == RADIOLIB_ERR_NONE)
err =
lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0);
rearmErr = err;
if (err != RADIOLIB_ERR_NONE) {
rearmState = REARM_FAILED;
return;
}
rearmState = REARM_ARMED;
// The TX_DONE interrupt detached the radio's interrupt. Attach the RX one now, so a frame that ends before the radio
// thread runs wakes this task to read it, rather than waiting in the chip for the next frame to overwrite it.
rxArmedBeforeTxDone = true;
enableInterrupt(isrRxLevel0);
}
template <typename T> bool LR11x0Interface<T>::adoptReceiveArmedFromIsr()
{
// Called inside the post-TX sequence, so the task cannot be mid re-arm: it has either finished or not started, and
// clearing the state here stops it starting.
const uint8_t state = rearmState;
rearmState = REARM_NONE;
rxArmedBeforeTxDone = false; // this is the TX_DONE; frames the task read meanwhile were delivered ahead of it
if (state == REARM_FAILED)
LOG_WARN("LR11x0 RX re-arm at TX_DONE failed %s%d, restarting RX", radioLibErr, rearmErr);
if (state != REARM_ARMED)
return false;
RadioLibInterface::startReceive();
rxArmedContinuous = true; // the task armed a continuous RX
enableInterrupt(isrRxLevel0);
checkRxDoneIrqFlag(); // an RX_DONE that completed before the interrupt was attached
return true;
}
#endif
/** Is the channel currently active? */
template <typename T> bool LR11x0Interface<T>::isChannelActive()
{
// check if we can detect a LoRa preamble on the current channel.
// symNum is SetCadParams SymbolNum - a plain count - so take it straight from getCadSymbolCount(),
// which follows the band (8 on 2.4 GHz, as SX1280 scans) and is what sizes the CW slot.
const uint8_t symNum = getCadSymbolCount();
// detPeak: Semtech SWSD003 lr11xx/apps/cad/main_cad.c optimized_parameters[symbols][BW][SF5..SF12],
// its measured best CAD detection rates. RadioLib's default is this table's [2 symbols][BW250] row.
// 50 = SWSD003's CAD_DETECT_PEAK fallback, used where it has no measured value.
static constexpr uint8_t CAD_DET_PEAK[4][4][8] = {
// Each block is one symbol count. Within a block the 4 rows are BW 62.5 / 125 / 250 / 500 kHz,
// and the 8 columns are SF5..SF12.
// 2 symbols:
{{39, 45, 47, 53, 59, 61, 64, 63},
{44, 51, 49, 55, 56, 60, 62, 68},
{48, 48, 50, 55, 55, 59, 61, 65},
{76, 80, 71, 77, 69, 50, 50, 50}}, // SF10-12: SWSD003 has no measurement, 50 is its fallback
// 4 symbols:
{{43, 45, 45, 50, 53, 57, 59, 63},
{44, 46, 49, 53, 53, 55, 57, 62},
{45, 47, 47, 51, 51, 56, 59, 62},
{58, 66, 58, 65, 62, 55, 60, 57}},
// 8 symbols:
{{43, 44, 46, 48, 51, 53, 56, 59},
{45, 43, 44, 50, 52, 55, 56, 61},
{42, 44, 45, 48, 50, 53, 55, 60},
{49, 52, 50, 59, 56, 57, 57, 60}},
// 16 symbols:
{{41, 44, 43, 46, 49, 52, 54, 60},
{42, 42, 43, 48, 49, 53, 55, 59},
{41, 42, 43, 48, 48, 53, 54, 58},
{44, 47, 45, 53, 52, 53, 57, 62}}};
// SWSD003 characterises symbol counts 2/4/8/16 against the sub-GHz LoRa modem's four bandwidths.
// Use exact matches to avoid mixing widelora (406.25/812.5/1625 kHz)
// Anything unmatched uses RadioLib default.
// Whole sub-GHz set today; a narrower BW or a symbol count outside 2/4/8/16 needs a row adding.
static constexpr float TABLE_BW_KHZ[4] = {62.5f, 125.0f, 250.0f, 500.0f};
const int symIdx = symNum == 2 ? 0 : symNum == 4 ? 1 : symNum == 8 ? 2 : symNum == 16 ? 3 : -1;
int bwIdx = -1;
for (int i = 0; i < 4; i++) {
if (bw > TABLE_BW_KHZ[i] - 1.0f && bw < TABLE_BW_KHZ[i] + 1.0f)
bwIdx = i;
}
const uint8_t detPeak = (symIdx < 0 || bwIdx < 0) ? (uint8_t)RADIOLIB_LR11X0_CAD_PARAM_DEFAULT
: CAD_DET_PEAK[symIdx][bwIdx][(sf >= 5 && sf <= 12) ? sf - 5 : 6];
// Keep preamble/header off the pin - they would fire the ISR mid-frame. Same set the normal RX path
// already programs (stageMode sends irqFlags & irqMask), so isActivelyReceiving() sees no change.
const uint32_t cadIrqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_RX_DONE) | (1UL << RADIOLIB_IRQ_TIMEOUT) |
(1UL << RADIOLIB_IRQ_CRC_ERR) | (1UL << RADIOLIB_IRQ_HEADER_ERR);
// UM Table 8-8: the timeout bounds the RX that follows a detection, so use one max-length airtime.
// RadioLib scales it by 30.52 us against a real 31.25, landing ~2% long - not worth pre-compensating.
const RadioLibTime_t cadRxTimeoutUsec =
(RadioLibTime_t)getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader), false) * 1000;
ChannelScanConfig_t cfg = {.cad = {.symNum = symNum,
.detPeak = detPeak,
// PARAM_DEFAULT lands on 10 in RadioLib, which is SWSD003's CAD_DETECT_MIN
.detMin = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT,
.exitMode = RADIOLIB_LR11X0_CAD_EXIT_MODE_RX,
.timeout = cadRxTimeoutUsec,
.irqFlags = cadIrqFlags,
.irqMask = cadIrqFlags}}; // ignored: startChannelScan() sends irqFlags twice
int16_t result = trySetStandby();
if (result == RADIOLIB_ERR_NONE) {
result = lora.scanChannel(cfg);
if (result == RADIOLIB_LORA_DETECTED) {
// The chip auto-entered RX. Drop the latched CAD verdict so the pin releases and the coming
// RX_DONE is a clean edge.
lora.clearIrqFlags(RADIOLIB_LR11X0_IRQ_CAD_DONE | RADIOLIB_LR11X0_IRQ_CAD_DETECTED);
noteCadHandoffToRx(); // nothing below arms the radio; the caller's rearmReceive() adopts it
return true;
}
if (result != RADIOLIB_ERR_WRONG_MODEM)
return false;
}
// standby failed or the LoRa modem type is gone - the chip lost its runtime state
maybeRecoverChipStateLoss();
return false; // report the channel free: a recovered chip can TX, a dead one fails startSend safely
}
/** Could we send right now (i.e. either not actively receiving or transmitting)? */
template <typename T> bool LR11x0Interface<T>::isActivelyReceiving()
{
// The IRQ status will be cleared when we start our read operation. Check if we've started a header, but haven't yet
// received and handled the interrupt for reading the packet/handling errors.
return receiveDetected(lora.getIrqStatus(), RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID,
RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED);
}
#ifdef LR11X0_AGC_RESET
template <typename T> bool LR11x0Interface<T>::resetAGC()
{
// Safety: don't reset mid-packet
if (sendingPacket != NULL || (isReceiving && isActivelyReceiving()))
return false;
LOG_DEBUG_RADIO("LR11x0 AGC reset: warm sleep + Calibrate(0x3F)");
rxArmedContinuous = false; // the warm sleep below stops RX
// 1. Warm sleep - powers down the analog frontend, resetting AGC state
lora.sleep(true, 0);
// 2. Wake to RC standby for stable calibration
lora.standby(RADIOLIB_LR11X0_STANDBY_RC, true);
// 3. Calibrate all blocks (PLL, ADC, image, RC oscillators)
// calibrate() is protected on LR11x0, so use raw SPI (same as internal implementation)
uint8_t calData = RADIOLIB_LR11X0_CALIBRATE_ALL;
module.SPIwriteStream(RADIOLIB_LR11X0_CMD_CALIBRATE, &calData, 1, true, true);
// 4. Re-calibrate image rejection for actual operating frequency
// Calibrate(0x3F) defaults to 902-928 MHz which is wrong for other regions.
lora.calibrateImageRejection(getFreq() - 4.0f, getFreq() + 4.0f);
// 5. Re-apply RX boosted gain mode
lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
// 6. Resume receiving
startReceive();
return true;
}
#endif
template <typename T> bool LR11x0Interface<T>::sleep()
{
// \todo Display actual typename of the adapter, not just `LR11x0`
LOG_DEBUG_RADIO("LR11x0 entering sleep mode");
rxArmedContinuous = false;
// trySetStandby() and the sleep command below are one sequence: a readout landing between them talks to a chip
// that is on its way down, and the standby would be undone by the arm a readout's caller can follow it with.
RadioSequence seq(this);
(void)trySetStandby(); // Stop any pending operations - the chip is being put to sleep, a failure must not crash
// turn off TCXO if it was powered
lora.setTCXO(0);
// put chipset into sleep mode. Interrupts are not necessarily detached here - disable() and NodeDB's
// config-reload park both reach this with the ISR still attached - so the lock above is what keeps
// the readout task out of it rather than the absence of interrupts.
bool keepConfig = false;
lora.sleep(keepConfig, 0); // Note: we do not keep the config, full reinit will be needed
#ifdef LR11X0_POWER_EN
digitalWrite(LR11X0_POWER_EN, LOW);
#endif
return true;
}
template <typename T> int16_t LR11x0Interface<T>::getCurrentRSSI()
{
#ifdef ARCH_PORTDUINO_WASM
float rssi = lora.getRSSI(); // installed RadioLib's LR11x0 getRSSI() is 0-arg
#else
float rssi = lora.getRSSI(false, true);
#endif
return (int16_t)round(rssi);
}
#endif