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)
-
enumerator SX1261
-
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
-
enumerator SF5
-
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
-
enumerator BW_7_8_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
-
enumerator CR_4_5
-
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
-
enumerator RAMP_10_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)
-
enumerator RC
-
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.
-
enumerator IRQ_NONE
-
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
See also
See also
- Returns:
The verbosity level of the logger
-
inline void set_log_level(espp::Logger::Verbosity level)
Set the log level for the logger
See also
See also
- 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
See also
See also
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
See also
See also
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
See also
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
-
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.
-
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.
-
BasePeripheral::write_fn write
-
struct PacketStatus
Status of a received packet.
-
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)
-
uint32_t frequency_hz = 906875000
-
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.
-
std::vector<uint8_t> data = {}
-
enum class Variant : uint8_t