Skip to content

The LoRa Transceiver

SIX WRAPPERS · STD_INIT · TCXO FALLBACK · REGISTER PATCH · NOISE FLOOR

The radio is the only part that makes a node a node. In the firmware that is not a chip but a class: RADIO_CLASS, filled in per board with one of six wrappers around RadioLib. This chapter describes what that wrapper adds on top of RadioLib — initialisation with a fallback, the optional per-board flags, the register patch for three boards, and the fact that the radio measures its own noise floor.

[!NOTE] Source. This page has been verified against the firmware itself: MeshCore v1.16.0, commit 03b6ef4, 28 July 2026 — files src/helpers/radiolib/CustomSX1262.h, src/helpers/radiolib/RadioLibWrappers.cpp, the other wrappers in src/helpers/radiolib/ and the radio flags in variants/.

Block diagram of the radio chain: RADIO_CLASS as CustomSX1262 around
RadioLib, with SPI to the chip, DIO1 as interrupt, BUSY as status line and
DIO2 or separate RXEN/TXEN pins to the RF switch

Six chips, one pattern

src/helpers/radiolib/ holds six wrappers, each with a matching …Wrapper.h:

Wrapper Chip Notable
CustomSX1262.h SX1262 the most common one; 868 MHz
CustomSX1268.h SX1268 same family, 433 MHz variant
CustomLLCC68.h LLCC68 SX126x family, limited SF range
CustomSX1276.h SX1276 older SX127x family, different registers
CustomLR1110.h LR1110 with its own reset routine in LR11x0Reset.h
CustomSTM32WLx.h STM32WL radio sits inside the SoC, no SPI

Which board has which chip is in Node Matrix and is not repeated here. The choice is made in the build flags:

variants/lilygo_tbeam_1w/platformio.ini

  -D RADIO_CLASS=CustomSX1262
  -D WRAPPER_CLASS=CustomSX1262Wrapper

The rest of the firmware knows only RADIO_CLASS and WRAPPER_CLASS. That is why six chip families coexist without a single #ifdef on chip type anywhere outside this directory.

std_init(): initialisation with a fallback

Every wrapper has a std_init(). It first sets the SPI pins — differently per platform, see The SPI Bus — and then calls RadioLib's begin().

src/helpers/radiolib/CustomSX1262.h r.45-55

      int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo);
      // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f
      if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) {
        tcxo = 0.0f;
        status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo);
      }
      if (status != RADIOLIB_ERR_NONE) {
        Serial.print("ERROR: radio init failed: ");
        Serial.println(status);
        return false;  // fail
      }

The fallback is the interesting part. The TCXO voltage defaults to 1.6f and is overridden by SX126X_DIO3_TCXO_VOLTAGE. If initialisation fails with -706 (RADIOLIB_ERR_SPI_CMD_INVALID) or -707 (RADIOLIB_ERR_SPI_CMD_FAILED), the firmware tries once more with the TCXO voltage set to zero.

[!NOTE] That is exactly how a board with a plain crystal instead of a TCXO behaves: DIO3 drives nothing and the chip rejects the command. The firmware does not guess this from the build flags but infers it from the error code.

The three fixed numbers in that call come from the root platformio.ini and are the same for every board that does not override them: LORA_FREQ=869.618, LORA_BW=62.5, LORA_SF=8. The coding rate is LORA_CR or otherwise 5, the preamble length is 16, and the sync word is the SX126x family's private sync word.

[!NOTE] These are build values, not network settings. LORA_SF=8 is the default in platformio.ini; the Dutch network runs on SF7, which the node configuration applies on top after flashing. See Getting Started.

Four optional flags

After a successful begin() CRC is always on, followed by four blocks that are compiled only if the variant sets the matching flag.

Flag Does Variant directories
SX126X_CURRENT_LIMIT current limit on the power amplifier 66
SX126X_DIO2_AS_RF_SWITCH DIO2 drives the RF switch 60
SX126X_RX_BOOSTED_GAIN more sensitive reception, more current 64
SX126X_RXEN · SX126X_TXEN separate switch pins 24

The count is per variant directory and includes both -D flags in platformio.ini and #define lines in a header inside that directory; commented-out lines do not count. Of the 79 variant directories, 60 set the DIO2 flag: 45 in platformio.ini, 22 in a header, 7 in both places. What that switch does is in Antenna.

If one of the two separate switch pins is missing, the wrapper fills it in itself:

src/helpers/radiolib/CustomSX1262.h r.68-76

  #if defined(SX126X_RXEN) || defined(SX126X_TXEN)
    #ifndef SX126X_RXEN
      #define SX126X_RXEN RADIOLIB_NC
    #endif
    #ifndef SX126X_TXEN
      #define SX126X_TXEN RADIOLIB_NC
    #endif
      setRfSwitchPins(SX126X_RXEN, SX126X_TXEN);
  #endif 

RADIOLIB_NC means not connected. A board that sets only SX126X_RXEN therefore does get setRfSwitchPins(), with the TX pin as not connected — that is not a fault but the normal pattern on a board where DIO2 switches the TX path and a separate pin switches the RX path.

A register patch for three boards

The last step in std_init() is a patch compiled only behind SX126X_REGISTER_PATCH:

src/helpers/radiolib/CustomSX1262.h r.78-84

  // for improved RX with Heltec v4
  #ifdef SX126X_REGISTER_PATCH
    uint8_t r_data = 0;
    readRegister(0x8B5, &r_data, 1);
    r_data |= 0x01;
    writeRegister(0x8B5, &r_data, 1);
  #endif

Bit 0 of register 0x8B5 is set. The comment names only the Heltec V4, but three variant directories set the flag: heltec_v4, heltec_tracker_v2 and rak3401 — the last one with a note that it concerns the SKY66122 front-end module. The comment in the wrapper is therefore narrower than the usage. Patches like this belong to a board and not to a chip; they exist because the manufacturer did something other than the reference design.

The radio measures its own noise floor

The layer above the wrapper does more than pass things through. While receiving, RadioLibWrapper samples the RSSI to determine a noise floor.

src/helpers/radiolib/RadioLibWrappers.cpp r.87-99

  if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) {
      int rssi = getCurrentRSSI();
      if (rssi < _noise_floor + SAMPLING_THRESHOLD) {  // only consider samples below current floor + sampling THRESHOLD
        _num_floor_samples++;
        _floor_sample_sum += rssi;
      }
  } else if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES && _floor_sample_sum != 0) {
    _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES;
    if (_noise_floor < -120) {
      _noise_floor = -120;    // clamp to lower bound of -120dBi
    }

Three things stand out. Sixty-four samples are averaged (NUM_NOISE_FLOOR_SAMPLES). A sample counts only if it is below the current floor plus 14 dB (SAMPLING_THRESHOLD), so a passing packet cannot drag the measurement upwards. And the result is clamped at −120 dBm: the firmware never reports anything lower, even if the environment is quieter.

That floor is not a statistic for the logs. The channel counts as busy as soon as the current RSSI rises above floor plus threshold. What the floor means for achievable distance is in Link Budget.

Sources

Firmware, commit 03b6ef4 (v1.16.0, 28 July 2026):

Related in this documentation:

Translated from Dutch by Anthropic Claude