SX126x LoRa Radio

The Sx126x component provides a driver for the Semtech SX126x series of sub-GHz LoRa radio transceivers (SX1261, SX1262) and compatible chips such as the LLCC68. It supports LoRa modem configuration, interrupt-driven or polled operation, blocking and non-blocking transmit, continuous receive with RSSI / SNR packet status, channel activity detection, and TCXO / RF-switch control.

The radio is a command-based SPI peripheral and can share an SPI bus with other devices (as on the LilyGo T-Deck, where it shares the bus with the display and uSD card).

API Reference

Header File

Classes

class Sx126x : public espp::BasePeripheral<uint8_t, false>

Driver for the Semtech SX126x series of sub-GHz LoRa radio transceivers (SX1261, SX1262) and compatible chips (LLCC68).

The SX126x is a command-based SPI peripheral: each operation is an opcode followed by parameters, and read operations return data in the same SPI transaction (with the chip select held asserted for the entire command). Because of this, the driver requires the `write_then_read` function provided in the configuration to perform the write phase and the read phase within a single chip-select assertion - e.g. by performing one full-duplex transfer of the concatenated write + read lengths. See the example for how to do this with the espp::Spi component.

The chip signals command processing via its BUSY pin, which must be provided via the `is_busy` function in the configuration. The `reset` function (driving the active-low NRESET pin) is optional but recommended.

Interrupt-driven operation is supported by connecting the radio’s DIO1 pin to a GPIO interrupt (e.g. with the espp::Interrupt component) and calling handle_dio1_interrupt() from the interrupt handler / task. Fully-polled operation (no DIO1 wiring) is also supported via the blocking transmit() and by periodically calling service_interrupts().

This driver only implements the LoRa modem (not FSK), and always uses explicit (variable-length) headers.

Example

  // create the (shared) SPI bus and add the radio as a device on it
  espp::Spi spi({.host = SPI2_HOST,
                 .sclk_io_num = spi_sclk,
                 .mosi_io_num = spi_mosi,
                 .miso_io_num = spi_miso});
  std::error_code ec;
  auto radio_device = spi.add_device(
      {.mode = 0, .clock_speed_hz = 8 * 1000 * 1000, .cs_io_num = radio_cs, .queue_size = 1}, ec);
  if (!radio_device) {
    logger.error("Failed to add radio SPI device: {}", ec.message());
    return;
  }

  // configure the radio's control pins
  gpio_set_direction(radio_busy, GPIO_MODE_INPUT);
  gpio_set_direction(radio_reset, GPIO_MODE_OUTPUT);

  // The SX126x requires chip select to be held asserted across the write and
  // read phases of a command, so implement write_then_read as a single
  // full-duplex transfer of the concatenated length.
  auto write_fn = [&](const uint8_t *data, size_t length) -> bool {
    std::error_code ec;
    return radio_device->write(std::span{data, length}, {}, ec);
  };
  auto write_then_read_fn = [&](const uint8_t *write_data, size_t write_length, uint8_t *read_data,
                                size_t read_length) -> bool {
    // This is a hot path (called for every SX126x command / IRQ read), so use
    // fixed, DMA-aligned stack buffers instead of allocating a vector pair on
    // every call. The largest SX126x transfer is a 255-byte FIFO read plus a
    // few opcode/param bytes, so kMaxTransfer comfortably covers any command.
    static constexpr size_t kMaxTransfer = 264;
    const size_t total = write_length + read_length;
    if (total > kMaxTransfer) {
      return false;
    }
    alignas(4) uint8_t tx[kMaxTransfer] = {0};
    alignas(4) uint8_t rx[kMaxTransfer] = {0};
    std::memcpy(tx, write_data, write_length);
    std::error_code ec;
    if (!radio_device->transfer(std::span{tx, total}, std::span{rx, total}, {}, ec)) {
      return false;
    }
    std::memcpy(read_data, rx + write_length, read_length);
    return true;
  };

  // make the radio
  espp::Sx126x radio({
    .variant = espp::Sx126x::Variant::SX1262, .write = write_fn,
    .write_then_read = write_then_read_fn,
    .is_busy = [&]() -> bool { return gpio_get_level(radio_busy); },
    .reset = [&](bool level) { gpio_set_level(radio_reset, level); },
#if CONFIG_EXAMPLE_RADIO_HAS_TCXO
    .tcxo_voltage = 1.8f,
#endif
    .radio_config =
        {
            .frequency_hz = CONFIG_EXAMPLE_RADIO_FREQUENCY_HZ,
            .tx_power_dbm = 22,
            .spreading_factor = espp::Sx126x::SpreadingFactor::SF11,
            .bandwidth = espp::Sx126x::Bandwidth::BW_250_KHZ,
            .coding_rate = espp::Sx126x::CodingRate::CR_4_5,
            .preamble_length = 16,
            .sync_word = 0x2B,
        },
    .on_receive =
        [&](const espp::Sx126x::RxPacket &packet) {
          std::string text(packet.data.begin(), packet.data.end());
          logger.info("Received {} bytes (RSSI {:.1f} dBm, SNR {:.2f} dB): '{}'",
                      packet.data.size(), packet.status.rssi, packet.status.snr, text);
        },
    .on_transmit_done = [&]() { logger.info("Transmit complete"); }, .auto_init = false,
    .log_level = espp::Logger::Verbosity::INFO
  });
  if (!radio.initialize(ec)) {
    logger.error("Failed to initialize radio: {}", ec.message());
    return;
  }

  // service the radio's IRQs (from a task context) whenever DIO1 goes high
  espp::Interrupt interrupts(
      {.interrupts = {{.gpio_num = radio_dio1,
                       .callback =
                           [&](const espp::Interrupt::Event &event) {
                             std::error_code ec;
                             radio.handle_dio1_interrupt(ec);
                           },
                       .active_level = espp::Interrupt::ActiveLevel::HIGH,
                       .interrupt_type = espp::Interrupt::Type::RISING_EDGE}},
       .task_config = {.name = "radio_interrupts", .stack_size_bytes = 6 * 1024}});

  // listen for packets, and send a ping every transmit interval
  if (!radio.start_receive(ec)) {
    logger.error("Failed to start receiving: {}", ec.message());
    return;
  }
  logger.info("Radio listening on {:.3f} MHz", CONFIG_EXAMPLE_RADIO_FREQUENCY_HZ / 1e6f);

  int packet_count = 0;
  while (true) {
    std::this_thread::sleep_for(std::chrono::milliseconds(CONFIG_EXAMPLE_TRANSMIT_INTERVAL_MS));
    std::string message = fmt::format("ping {} from sx126x example", packet_count++);
    logger.info("Transmitting: '{}'", message);
    std::span<const uint8_t> payload{reinterpret_cast<const uint8_t *>(message.data()),
                                     message.size()};
    if (!radio.transmit(payload, 5s, ec)) {
      logger.error("Failed to transmit: {}", ec.message());
    }
  }

Public Types

enum class Variant : uint8_t

The chip variant controlled by this driver. The variants differ in their power amplifier configuration (SX1261: low power, up to +15 dBm; SX1262 / LLCC68: high power, up to +22 dBm) and supported spreading factors (LLCC68 supports only SF5-SF11).

Values:

enumerator SX1261

Semtech SX1261 (up to +15 dBm)

enumerator SX1262

Semtech SX1262 (up to +22 dBm)

enumerator LLCC68

Semtech LLCC68 (up to +22 dBm, SF5-SF11 only)

enum class SpreadingFactor : uint8_t

LoRa spreading factor (SF). Higher spreading factors increase range and time-on-air, and decrease data rate.

Values:

enumerator SF5

5 chips / symbol

enumerator SF6

6 chips / symbol

enumerator SF7

7 chips / symbol

enumerator SF8

8 chips / symbol

enumerator SF9

9 chips / symbol

enumerator SF10

10 chips / symbol

enumerator SF11

11 chips / symbol

enumerator SF12

12 chips / symbol

enum class Bandwidth : uint8_t

LoRa signal bandwidth.

Values:

enumerator BW_7_8_KHZ

7.81 kHz

enumerator BW_10_4_KHZ

10.42 kHz

enumerator BW_15_6_KHZ

15.63 kHz

enumerator BW_20_8_KHZ

20.83 kHz

enumerator BW_31_25_KHZ

31.25 kHz

enumerator BW_41_7_KHZ

41.67 kHz

enumerator BW_62_5_KHZ

62.50 kHz

enumerator BW_125_KHZ

125 kHz

enumerator BW_250_KHZ

250 kHz

enumerator BW_500_KHZ

500 kHz

enum class CodingRate : uint8_t

LoRa forward error correction coding rate.

Values:

enumerator CR_4_5

4/5

enumerator CR_4_6

4/6

enumerator CR_4_7

4/7

enumerator CR_4_8

4/8

enum class RampTime : uint8_t

Power amplifier ramp time.

Values:

enumerator RAMP_10_US

10 us

enumerator RAMP_20_US

20 us

enumerator RAMP_40_US

40 us

enumerator RAMP_80_US

80 us

enumerator RAMP_200_US

200 us

enumerator RAMP_800_US

800 us

enumerator RAMP_1700_US

1700 us

enumerator RAMP_3400_US

3400 us

enum class StandbyMode : uint8_t

Standby oscillator configuration.

Values:

enumerator RC

Standby with 13 MHz RC oscillator (lowest power)

enumerator XOSC

Standby with crystal oscillator running (faster TX/RX entry)

enum Irq

Interrupt flags reported by the radio (bitfield). These match the chip’s IRQ register bit positions.

Values:

enumerator IRQ_NONE

No interrupts.

enumerator IRQ_TX_DONE

Packet transmission completed.

enumerator IRQ_RX_DONE

Packet reception completed.

enumerator IRQ_PREAMBLE_DETECTED

Preamble detected.

enumerator IRQ_SYNC_WORD_VALID

Sync word valid (FSK only)

enumerator IRQ_HEADER_VALID

LoRa header received & valid.

enumerator IRQ_HEADER_ERR

LoRa header CRC error.

enumerator IRQ_CRC_ERR

Payload CRC error.

enumerator IRQ_CAD_DONE

Channel activity detection finished.

enumerator IRQ_CAD_DETECTED

Channel activity detected.

enumerator IRQ_TIMEOUT

RX or TX timeout.

enumerator IRQ_ALL

All interrupts.

typedef std::function<void(const RxPacket &packet)> receive_callback_fn

Callback invoked (from the caller of handle_dio1_interrupt / service_interrupts) when a packet has been received.

typedef std::function<void(void)> transmit_done_callback_fn

Callback invoked when a packet transmission has completed.

typedef std::function<void(bool detected)> cad_callback_fn

Callback invoked when channel activity detection completes. The parameter is true if activity was detected.

typedef std::function<bool(void)> is_busy_fn

Function which returns the state of the radio’s BUSY pin (true = busy).

typedef std::function<void(bool level)> reset_fn

Function which drives the radio’s active-low NRESET pin. The parameter is the logic level to drive (false = held in reset).

typedef std::function<bool(uint8_t)> probe_fn

Function to probe the peripheral

Param address:

The address to probe

Return:

True if the peripheral is found at the given address

Public Functions

explicit Sx126x(const Config &config)

Constructor

Parameters:

config – The configuration for the driver

bool initialize(std::error_code &ec)

Initialize the radio: reset (if a reset function was provided), verify SPI communications, configure the regulator / TCXO / RF switch, calibrate, and apply the radio configuration.

Parameters:

ec – The error code to set if there is an error

Returns:

True if initialization succeeded

inline bool is_initialized() const

Whether the radio has been successfully initialized

Returns:

True if the radio has been initialized

bool set_radio_config(const RadioConfig &config, std::error_code &ec)

Apply a new radio (modem) configuration. The radio is placed in standby.

Parameters:
  • config – The radio configuration to apply

  • ec – The error code to set if there is an error

Returns:

True if the configuration was applied

inline const RadioConfig &radio_config() const

Get the current radio (modem) configuration

Returns:

The current radio configuration

void set_frequency(uint32_t frequency_hz, std::error_code &ec)

Set the RF center frequency. Also performs image calibration for the new frequency band.

Parameters:
  • frequency_hz – The frequency in Hz

  • ec – The error code to set if there is an error

void set_tx_power(int8_t power_dbm, std::error_code &ec)

Set the TX output power.

Parameters:
  • power_dbm – The power in dBm; clamped to the variant’s range (SX1261: -17 to +15, SX1262/LLCC68: -9 to +22)

  • ec – The error code to set if there is an error

void set_sync_word(uint8_t sync_word, std::error_code &ec)

Set the LoRa sync word.

Parameters:
  • sync_word – The sync word (0x12 private, 0x34 public, 0x2B Meshtastic)

  • ec – The error code to set if there is an error

void set_receive_callback(const receive_callback_fn &callback)

Set the callback invoked when a packet is received

Parameters:

callback – The callback to invoke

void set_transmit_done_callback(const transmit_done_callback_fn &callback)

Set the callback invoked when a transmission completes

Parameters:

callback – The callback to invoke

void set_cad_callback(const cad_callback_fn &callback)

Set the callback invoked when channel activity detection completes

Parameters:

callback – The callback to invoke

bool transmit(std::span<const uint8_t> data, std::chrono::milliseconds timeout, std::error_code &ec)

Transmit a packet, blocking until transmission completes or the timeout elapses. Polls the radio’s IRQ status, so this works without DIO1 wired. The radio is returned to standby (or receive, if it was receiving) afterwards.

Parameters:
  • data – The payload to transmit (1-255 bytes)

  • timeout – The maximum time to wait for transmission to complete

  • ec – The error code to set if there is an error

Returns:

True if the packet was transmitted

bool start_transmit(std::span<const uint8_t> data, std::error_code &ec)

Start transmitting a packet without blocking. Completion is signaled via the transmit-done callback when handle_dio1_interrupt() / service_interrupts() is called.

Parameters:
  • data – The payload to transmit (1-255 bytes)

  • ec – The error code to set if there is an error

Returns:

True if the transmission was started

bool start_receive(std::error_code &ec)

Enter continuous receive mode. Received packets are delivered via the receive callback when handle_dio1_interrupt() / service_interrupts() is called.

Parameters:

ec – The error code to set if there is an error

Returns:

True if receive mode was entered

bool start_cad(std::error_code &ec)

Start a channel activity detection. The result is delivered via the CAD callback when handle_dio1_interrupt() / service_interrupts() is called.

Parameters:

ec – The error code to set if there is an error

Returns:

True if CAD was started

void standby(std::error_code &ec, StandbyMode mode = StandbyMode::RC)

Put the radio into standby mode.

Parameters:
  • mode – The standby oscillator mode

  • ec – The error code to set if there is an error

void sleep(std::error_code &ec)

Put the radio into its lowest-power sleep mode (cold start, configuration retained). Waking requires any SPI transaction (e.g. standby()).

Parameters:

ec – The error code to set if there is an error

inline bool is_receiving() const

Whether the radio is currently in receive mode

Returns:

True if the radio was last commanded into receive mode

bool is_reception_in_progress(std::error_code &ec)

Whether a preamble/header has been detected and reception is likely in progress. Useful for channel-activity checks before transmitting.

Parameters:

ec – The error code to set if there is an error

Returns:

True if the radio has detected preamble/valid header since the last packet completed

float get_rssi_inst(std::error_code &ec)

Get the instantaneous RSSI measured by the radio (only meaningful in receive mode).

Parameters:

ec – The error code to set if there is an error

Returns:

The instantaneous RSSI in dBm

uint16_t handle_dio1_interrupt(std::error_code &ec)

Handle a DIO1 interrupt: read & clear the radio’s IRQ flags and invoke the appropriate callbacks (receive / transmit-done / CAD). Call this from a task context (e.g. an espp::Interrupt callback) - it performs SPI transactions and must not be called from an ISR.

Parameters:

ec – The error code to set if there is an error

Returns:

The IRQ flags which were set (bitfield of Irq values)

inline uint16_t service_interrupts(std::error_code &ec)

Poll the radio’s IRQ status and dispatch callbacks - identical to handle_dio1_interrupt(), provided for readability in polled operation.

Parameters:

ec – The error code to set if there is an error

Returns:

The IRQ flags which were set (bitfield of Irq values)

std::chrono::milliseconds time_on_air(size_t payload_length) const

Get the time-on-air for a payload with the current radio configuration.

Parameters:

payload_length – The payload length in bytes

Returns:

The approximate time-on-air

uint8_t read_radio_register(uint16_t address, std::error_code &ec)

Read a radio register (for advanced use).

Parameters:
  • address – The register address

  • ec – The error code to set if there is an error

Returns:

The register value

void write_radio_register(uint16_t address, uint8_t value, std::error_code &ec)

Write a radio register (for advanced use).

Parameters:
  • address – The register address

  • value – The value to write

  • ec – The error code to set if there is an error

inline bool probe(std::error_code &ec) const

Probe the peripheral

Note

This function is thread safe

Note

If the probe function is not set, this function will return false and set the error code to operation_not_supported

Note

This function is only available if UseAddress is true

Parameters:

ec – The error code to set if there is an error

Returns:

True if the peripheral is found

inline void set_address(uint8_t address)

Set the address of the peripheral

Note

This function is thread safe

Note

This function is only available if UseAddress is true

Parameters:

address – The address of the peripheral

inline void set_probe(const probe_fn &probe)

Set the probe function

Note

This function is thread safe

Note

This should rarely be used, as the probe function is usually set in the constructor. If you need to change the probe function, consider using the set_config function instead.

Note

This function is only available if UseAddress is true

Parameters:

probe – The probe function

inline void set_write(const write_fn &write)

Set the write function

Note

This function is thread safe

Note

This should rarely be used, as the write function is usually set in the constructor. If you need to change the write function, consider using the set_config function instead.

Parameters:

write – The write function

inline void set_read(const read_fn &read)

Set the read function

Note

This function is thread safe

Note

This should rarely be used, as the read function is usually set in the constructor. If you need to change the read function, consider using the set_config function instead.

Parameters:

read – The read function

inline void set_read_register(const read_register_fn &read_register)

Set the read register function

Note

This function is thread safe

Note

This should rarely be used, as the read register function is usually set in the constructor. If you need to change the read register function, consider using the set_config function instead.

Parameters:

read_register – The read register function

inline void set_write_then_read(const write_then_read_fn &write_then_read)

Set the write then read function

Note

This function is thread safe

Note

This should rarely be used, as the write then read function is usually set in the constructor. If you need to change the write then

Parameters:

write_then_read – The write then read function

inline void set_separate_write_then_read_delay(const std::chrono::milliseconds &delay)

Set the delay between the write and read operations in write_then_read

Note

This function is thread safe

Note

This should rarely be used, as the delay is usually set in the constructor. If you need to change the delay, consider using the set_config function instead.

Note

This delay is only used if the write_then_read function is not set to a custom function and the write and read functions are separate functions.

Parameters:

delay – The delay between the write and read operations in write_then_read

inline void set_config(const Config &config)

Set the configuration for the peripheral

Note

This function is thread safe

Note

The configuration should normally be set in the constructor, but this function can be used to change the configuration after the peripheral has been created - for instance if the peripheral could be found on different communications buses.

Parameters:

config – The configuration for the peripheral

inline void set_config(Config &&config)

Set the configuration for the peripheral

Note

This function is thread safe

Note

The configuration should normally be set in the constructor, but this function can be used to change the configuration after the peripheral has been created - for instance if the peripheral could be found on different communications buses.

Parameters:

config – The configuration for the peripheral

inline const Config &config() const

Get the configuration for the peripheral

Returns:

The configuration for the peripheral

inline uint8_t address() const

Get the address of the peripheral

Returns:

The address of the peripheral

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

struct Config

Configuration for the Sx126x driver.

Public Members

Variant variant = Variant::SX1262

Which chip this is.

BasePeripheral::write_fn write

Function to write bytes to the radio (one full chip-select assertion per call)

BasePeripheral::write_then_read_fn write_then_read

Function to write then read bytes from the radio, holding chip-select asserted across both phases

is_busy_fn is_busy

Function returning the BUSY pin state.

reset_fn reset = nullptr

Optional function driving the NRESET pin.

float tcxo_voltage = 0.0f

If > 0, DIO3 is used to power a TCXO at this voltage (1.6-3.3V). 0 disables TCXO control (crystal used).

std::chrono::microseconds tcxo_delay = std::chrono::microseconds(5000)

TCXO stabilization delay.

bool use_dio2_as_rf_switch = true

Whether DIO2 controls the RF switch (true on most modules, including the T-Deck and M5Stack LoRa modules)

bool use_dcdc_regulator = true

Use the DC-DC regulator (true on most modules) instead of only the LDO

RadioConfig radio_config = {}

Initial radio (modem) configuration.

receive_callback_fn on_receive = nullptr

Called when a packet is received.

transmit_done_callback_fn on_transmit_done = nullptr

Called when transmission completes.

cad_callback_fn on_cad_done = nullptr

Called when CAD completes.

bool auto_init = true

Whether to initialize the radio on construction.

Logger::Verbosity log_level = {Logger::Verbosity::WARN}

Log verbosity for the driver.

struct PacketStatus

Status of a received packet.

Public Members

float rssi = {0}

Average RSSI over the packet, in dBm.

float snr = {0}

Estimated SNR of the packet, in dB.

float signal_rssi = {0}

RSSI of the despread LoRa signal, in dBm.

struct RadioConfig

LoRa modem configuration. The defaults match the Meshtastic “LongFast” preset modulation (US frequency shown; set the frequency for your region / channel).

Public Members

uint32_t frequency_hz = 906875000

RF center frequency, in Hz.

int8_t tx_power_dbm = 22

TX power in dBm. Clamped to the variant’s supported range.

RampTime ramp_time = RampTime::RAMP_200_US

PA ramp time.

SpreadingFactor spreading_factor = SpreadingFactor::SF11

Spreading factor.

Bandwidth bandwidth = Bandwidth::BW_250_KHZ

Signal bandwidth.

CodingRate coding_rate = CodingRate::CR_4_5

Coding rate.

uint16_t preamble_length = 16

Preamble length in symbols.

bool crc_enabled = true

Whether to append/check payload CRC.

bool invert_iq = false

Whether to invert the IQ signals.

uint8_t sync_word = 0x2B

LoRa sync word. 0x12 = private networks, 0x34 = public (LoRaWAN), 0x2B = Meshtastic.

bool rx_boosted_gain = true

Use the boosted-gain RX mode (~2 dB better sensitivity for ~0.7 mA more current)

struct RxPacket

A received packet, as provided to the receive callback.

Public Members

std::vector<uint8_t> data = {}

The packet payload.

PacketStatus status = {}

RSSI / SNR of the packet.