LoRa is a chirp-spread-spectrum (CSS) PHY: each symbol is one linear frequency sweep carrying SF bits across 2^SF chips. LoRaWAN is the AES-secured star-network MAC layer built on top of that PHY. Everything on this page is register-level, bare C -- no RadioLib, no arduino-LoRa -- because the point is to see exactly what goes over SPI.
The SX127x (RFM95 module; SX1276/77/78 silicon, Semtech datasheet Rev. 0.0 May 2013) exposes a flat register-poke SPI interface -- one address byte with the R/W bit in bit 7, then data. The SX126x (SX1262 silicon, RA-01SC module; datasheet Rev. 1.1 Dec 2017 / Rev. 1.2 Jun 2019) replaces that with a command/opcode interface gated by a BUSY pin, adds a mandatory image-calibration step, cuts typical RX current from ~11 mA to ~4.6 mA, and pushes TX to +22 dBm versus +20 dBm. Register/opcode detail lives in SX127x Regs and SX126x Cmds; the head-to-head is in 127x vs 126x.
a lora node, signal path ┌──────────┐ SPI bus ┌───────────────────────┐ ┌───────────────────┐ ┌─────────┐ │ ESP32 │ MOSI · MISO · SCK · CS │ SX127x / SX126x │ │ matching net / │ │ antenna │ │ host MCU ├───────────────────────▶│ radio transceiver ├────▶│ RF frontend ├────▶│ 50Ω │ └──────────┘ + RESET · DIO0/1 · BUSY └───────────────────────┘ └───────────────────┘ └─────────┘ chirp modulation happens entirely inside this chip -- the MCU only ever sees register/command bytes and raw payload bytes
LoRa modulates data onto chirps: signals whose instantaneous frequency ramps linearly across the channel bandwidth B. A base up-chirp sweeps from −B/2 to +B/2; a down-chirp does the reverse. The spreading factor SF ∈ {7…12} (SX127x adds SF6 in implicit-header mode; SX126x adds SF5) sets the number of chips per symbol as N = 2^SF, and each symbol carries SF raw bits.
A symbol of value s ∈ {0,…,N−1} is a cyclically-shifted up-chirp: the sweep starts at frequency f0 = (s·B/N − B/2), rises at B/N per chip until it hits the Nyquist edge +B/2, then folds back to −B/2 at chip n_fold = N − s and continues to the end of the symbol. Where the fold-back lands is where the symbol value is encoded.
base up-chirp, symbol s=0 +B/2 ┤ ╱ │ ╱ │ ╱ │ ╱ │ ╱ 0 ┤ ╱ │ ╱ │ ╱ -B/2 ┤╱ └──────────────────────────────────────────────▶ time, 0 → Tsym (N chips) shifted symbol s, fold-back encodes the value +B/2 ┤ ╱│ ╱ │ ╱ │ ╱ │ ╱ │ ╱ 0 ┤ │ ╱ │ │ ╱ -B/2 ┤╱ │╱ └────────────────▲───────────────────────────────▶ time n_fold = N − s -- chip index where frequency wraps +B/2 → −B/2
The discrete baseband symbol below is referenced to a base up-chirp. Demodulation is de-chirping: multiply the received signal by the conjugate of the base down-chirp, take an FFT, and the bin with the peak magnitude is the symbol value.
symbol / reference chirp // symbol s, discrete baseband, n = 0..N-1 x_s[n] = exp( j·2π · (((s+n) mod N) · n) / N ) // base up-chirp used for de-chirping (conjugate, multiply, then FFT) x_ref[n] = exp( j·2π · (n²/(2N) − n/2) )
| Quantity | Formula | Notes |
|---|---|---|
| Symbol duration | Tsym = 2^SF / B | SF7/125kHz: 128/125000 = 1.024 ms · SF12/125kHz: 4096/125000 = 32.768 ms |
| Chip rate | = B | one chip per 1/B seconds; 2^SF chips per symbol |
| Processing gain | ≈ 10·log10(2^SF) dB | each SF step doubles Tsym, adds ~2.5 dB link budget, halves bitrate |
| Bandwidth | 125 / 250 / 500 kHz | doubling BW halves Tsym (doubles bitrate) for the same SF |
Spreading factors are quasi-orthogonal, so different SFs coexist on one channel with limited mutual interference -- the basis of LoRaWAN's adaptive data rate in LoRaWAN MAC.
The LoRa TX chain was reverse-engineered by Knight/Seeber and the EPFL/GNU Radio teams, since Semtech never fully documented it: whitening → Hamming FEC → interleaving → Gray mapping → chirp modulation.
tx bit-processing pipeline payload bytes │ ▼ ┌───────────────────┐ XOR with a pseudo-random LFSR sequence, removes DC bias / long runs │ whitening │ (sequence experimentally derived, not published by Semtech) └─────────┬─────────┘ ▼ ┌───────────────────┐ CR 4/5..4/8 -- true (4,7)/(4,8) Hamming @ 4/7,4/8 (correct 1 bit) │ Hamming FEC │ 4/6 punctured (detect only), 4/5 single parity bit (detect only) └─────────┬─────────┘ ▼ ┌───────────────────┐ diagonal interleaver: spreads each block of SF codewords │ interleaving │ (CR+4 bits each) across CR+4 symbols -- a noise burst hitting └─────────┬─────────┘ one symbol is scattered across many codewords ▼ ┌───────────────────┐ │ Gray mapping │ └─────────┬─────────┘ ▼ ┌───────────────────┐ │ chirp modulation │ → on air, see CSS Waveform └───────────────────┘
| Register value | CR | Type | Behavior |
|---|---|---|---|
| 1 | 4/5 | parity | single even-parity bit -- detect only |
| 2 | 4/6 | punctured | punctured (4,6) code -- detect only |
| 3 | 4/7 | Hamming | true (4,7) Hamming -- corrects 1 bit-error per codeword |
| 4 | 4/8 | Hamming | true (4,8) Hamming -- corrects 1 bit-error per codeword; used for the PHY header |
An optional 16-bit payload CRC is enabled via RxPayloadCrcOn; on RX, a PayloadCrcError IRQ flags failures. See PHY Packet for where CRC sits in the frame.
Over-the-air order: Preamble → Sync word → SFD → [Header + Header CRC] → Payload → [Payload CRC].
over-the-air frame, explicit header ┌───────────────┬───────────┬───────────┬────────────────────┬───────────────┬─────────────┐ │ preamble │ sync word │ SFD │ header + hdr CRC │ payload │ payload CRC │ │ n_preamble │ 2 symbols │ 2.25 sym │ 4/8 coded, opt. │ up to 255 B │ optional │ │ up-chirps │ │ down-chirp│ │ │ │ └───────────────┴───────────┴───────────┴────────────────────┴───────────────┴─────────────┘ Tpreamble = (n_preamble + 4.25) · Tsym -- the +4.25 covers sync word + SFD
The preamble is a programmable number of unmodulated up-chirps (default 8 in LoRaWAN, register n_preamble). The radio hardware appends the 2 sync-word symbols and the 2.25-symbol SFD (start-of-frame delimiter, a down-chirp) automatically. Longer preambles let RX duty-cycle sleep -- Meshtastic uses 16.
The last preamble up-chirps encode a network identifier, the sync word. On SX127x it's RegSyncWord (0x39), default 0x12 (private); 0x34 is reserved for LoRaWAN. On SX126x it's two registers at 0x0740/0x0741 (0x1424 private, 0x3444 public). It's a "soft" filter -- packets with a mismatched sync word can still leak through, unlike FSK.
| Mode | Behavior |
|---|---|
| Explicit | header carries payload length, CR, and CRC-presence, protected by its own CRC; receiver auto-detects payload length/CR. Adds ~SF−2 codewords of overhead. |
| Implicit | header omitted entirely; both ends must pre-agree length/CR/CRC out of band. Mandatory for SF6. |
Max payload is 255 bytes; each on-air symbol maps to SF bits after the coding chain in Coding Chain.
Two different frames get called "the LoRa packet" and conflating them is the root of most confusion. The PHY frame in PHY Packet is what the radio puts on air: chirps, sync word, an optional header, a payload of opaque bytes. LoRa the modulation neither knows nor cares what those bytes mean.
LoRaWAN is one thing you can put in that payload. Meshtastic is another. A raw P2P link is a third. Everything below is the LoRaWAN structure, verified against the LoRaWAN L2 1.0.4 Specification; the PHY layer above it is unchanged regardless.
Everything LoRaWAN sends is this. Hover any field.
| FType | Frame | Notes |
|---|---|---|
| 000 | Join-Request | starts OTAA, unencrypted, MIC with AppKey |
| 001 | Join-Accept | encrypted with AppKey using an AES decrypt, see below |
| 010 | Unconfirmed data uplink | the common case |
| 011 | Unconfirmed data downlink | |
| 100 | Confirmed data uplink | receiver must ACK |
| 101 | Confirmed data downlink | |
| 110 | RFU | Rejoin-Request in 1.1 |
| 111 | Proprietary | non-interoperable by design |
Same octet, different meaning depending on which way the frame is travelling. Bit 6 and bit 4 are the ones that change.
aes128_decrypt() over the Join-Accept so the device can recover it with a single aes128_encrypt(). A device only needs the encrypt primitive. Reaching for decrypt on the device side produces garbage that looks like a wrong key.
Semtech AN1200.13 gives the exact time-on-air formula used to size duty cycles and airtime budgets.
an1200.13 time-on-air Tsym = 2^SF / BW // seconds; BW in Hz Tpreamble = (n_preamble + 4.25) · Tsym payloadSymbNb = 8 + max( ceil( (8·PL − 4·SF + 28 + 16·CRC − 20·IH) / (4·(SF − 2·DE)) ) · (CR + 4), 0 ) Tpayload = payloadSymbNb · Tsym ToA = Tpreamble + Tpayload // PL = payload bytes, CRC = 1 if payload CRC on else 0, IH = 1 if implicit header else 0 // DE = 1 if Low-Data-Rate-Optimization on else 0, CR = 1..4 for 4/5..4/8
SF7 / 125 kHz / CR 4/5, 20-byte payload: payloadSymbNb = 8 + ceil(176/28)·5 = 43 symbols = 44.0 ms, plus 12.54 ms of preamble ≈ 56.6 ms total time on air. LoRaWAN adds ~13 bytes of MAC overhead (MHDR + FHDR + FPort + MIC) on top of the application payload, and the SX127x adds ~2 ms of TX ramp-up on top of the formula's result.
EU868 duty cycle ranges from 0.1% to 10% depending on the ETSI sub-band; the join channels sit in the 1% band, i.e. ~36 s of TX per hour. Divide 36000 ms by ToA to get the maximum packets/hour for a given configuration -- at SF12/125 kHz with a full 51-byte payload, ToA can exceed 2 seconds, so duty cycle rather than raw airtime often becomes the binding constraint at high SF.
SPI transaction: one address byte, then data. MSB of the address byte is the R/W bit -- set bit 7 = 1 for write, 0 for read. Holding NSS low and streaming further bytes gives burst/sequential access (address auto-increments; the FIFO at 0x00 does not auto-increment, enabling block FIFO reads/writes). SPI Mode 0 (CPOL=0, CPHA=0), up to 10 MHz.
| Reg | Addr | Purpose |
|---|---|---|
| RegFifo | 0x00 | FIFO read/write |
| RegOpMode | 0x01 | bit7 LongRangeMode (1=LoRa), bit3 LowFreqModeOn, bits2:0 Mode (000 SLEEP, 001 STDBY, 011 TX, 101 RXCONT, 110 RXSINGLE, 111 CAD) |
| RegFrfMsb/Mid/Lsb | 0x06–0x08 | 24-bit carrier frequency |
| RegPaConfig | 0x09 | PaSelect(bit7), MaxPower(6:4), OutputPower(3:0) |
| RegOcp | 0x0B | overcurrent trim |
| RegLna | 0x0C | LnaGain(7:5), LnaBoostHf(1:0)=0x03 to boost |
| RegFifoAddrPtr | 0x0D | FIFO pointer |
| RegFifoTxBaseAddr | 0x0E | default 0x80 |
| RegFifoRxBaseAddr | 0x0F | default 0x00 |
| RegFifoRxCurrentAddr | 0x10 | start of last packet |
| RegIrqFlags | 0x12 | see bits below |
| RegRxNbBytes | 0x13 | received byte count |
| RegPktSnrValue | 0x19 | SNR·4 (signed) |
| RegPktRssiValue | 0x1A | packet RSSI |
| RegRssiValue | 0x1B | current RSSI |
| RegModemConfig1 | 0x1D | Bw(7:4), CodingRate(3:1), ImplicitHeaderOn(0) |
| RegModemConfig2 | 0x1E | SpreadingFactor(7:4), TxContinuous(3), RxPayloadCrcOn(2), SymbTimeout MSB(1:0) |
| RegModemConfig3 | 0x26 | LowDataRateOptimize(3), AgcAutoOn(2) |
| RegSymbTimeout | 0x1F | RX single timeout LSB |
| RegPreambleMsb/Lsb | 0x20/0x21 | preamble length |
| RegPayloadLength | 0x22 | payload length (implicit/TX) |
| RegSyncWord | 0x39 | 0x12 private / 0x34 LoRaWAN |
| RegDioMapping1/2 | 0x40/0x41 | DIO0–5 function select |
| RegVersion | 0x42 | 0x12 for SX1276 |
| RegPaDac | 0x4D | 0x87 enables +20 dBm on PA_BOOST |
bit7 RxTimeout · bit6 RxDone · bit5 PayloadCrcError · bit4 ValidHeader · bit3 TxDone · bit2 CadDone · bit1 FhssChangeChannel · bit0 CadDetected. Flags are cleared by writing a 1 to the bit.
Every read/write is one SPI transaction: address byte (with R/W bit) followed by the data byte, both in the same spi_transaction_t so CS stays low across both. .length is in bits, not bytes -- see ESP32 Wire.
sx127x_reg.c -- single-byte access + fifo burst // write: bit7=1 selects write, addr in bits6:0 static void sx127x_write_reg(spi_device_handle_t dev, uint8_t addr, uint8_t val) { uint8_t tx[2] = { (uint8_t)(addr | 0x80), val }; spi_transaction_t t = { .length = 16, .tx_buffer = tx }; // .length is in BITS spi_device_transmit(dev, &t); } // read: bit7=0 selects read, clock a dummy byte to receive the reply static uint8_t sx127x_read_reg(spi_device_handle_t dev, uint8_t addr) { uint8_t tx[2] = { (uint8_t)(addr & 0x7F), 0x00 }; uint8_t rx[2] = { 0 }; spi_transaction_t t = { .length = 16, .tx_buffer = tx, .rx_buffer = rx }; spi_device_transmit(dev, &t); return rx[1]; } // RegFifo (0x00) does not auto-increment -- streaming N payload bytes in // one CS-low window is the standard fast path for burst fifo writes static void sx127x_write_fifo(spi_device_handle_t dev, const uint8_t *payload, size_t len) { uint8_t tx[256]; tx[0] = 0x00 | 0x80; // RegFifo, write memcpy(&tx[1], payload, len); spi_transaction_t t = { .length = (len + 1) * 8, .tx_buffer = tx }; spi_device_transmit(dev, &t); }
LoRa mode can only be toggled from SLEEP -- see the Gotchas entry on why writing LongRangeMode from STANDBY fails silently.
sx127x_init.c -- register pokes, commented per step void sx127x_init(spi_device_handle_t dev, uint32_t freq_hz) { // (1) RESET pulse done externally via gpio_set_level; (2) confirm silicon uint8_t ver = sx127x_read_reg(dev, 0x42); // RegVersion, expect 0x12 on SX1276 // (3) LoRa bit can only be set while in SLEEP sx127x_write_reg(dev, 0x01, 0x80); // RegOpMode: LongRangeMode=1, MODE=SLEEP sx127x_write_reg(dev, 0x01, 0x81); // RegOpMode: LoRa + STANDBY // (4) carrier frequency: Frf = freq_hz * 2^19 / 32e6, FSTEP ~= 61.035 Hz uint32_t frf = (uint32_t)(((uint64_t)freq_hz << 19) / 32000000ULL); sx127x_write_reg(dev, 0x06, (frf >> 16) & 0xFF); // RegFrfMsb sx127x_write_reg(dev, 0x07, (frf >> 8) & 0xFF); // RegFrfMid sx127x_write_reg(dev, 0x08, frf & 0xFF); // RegFrfLsb // (5) fifo base addresses -- shared 256-byte fifo, tx/rx halves sx127x_write_reg(dev, 0x0E, 0x80); // RegFifoTxBaseAddr sx127x_write_reg(dev, 0x0F, 0x00); // RegFifoRxBaseAddr // (6) LNA: max gain, boost the HF path sx127x_write_reg(dev, 0x0C, 0x23); // RegLna: MaxGain | LnaBoostHf=11 // (7)(8) modem config: BW=125kHz, CR=4/5, explicit header, SF7, CRC on sx127x_write_reg(dev, 0x1D, 0x72); // RegModemConfig1 sx127x_write_reg(dev, 0x1E, 0x74); // RegModemConfig2 // (9) LDRO off -- BW125/SF7 Tsym is well under the 16ms mandatory threshold sx127x_write_reg(dev, 0x26, 0x04); // RegModemConfig3: AgcAutoOn=1, LDRO=0 // (10) preamble length = 8 symbols sx127x_write_reg(dev, 0x20, 0x00); // RegPreambleMsb sx127x_write_reg(dev, 0x21, 0x08); // RegPreambleLsb // (11) tx power: PA_BOOST pin, +17dBm base, +20dBm dac boost enabled sx127x_write_reg(dev, 0x09, 0x8F); // RegPaConfig: PaSelect=1, OutputPower=15 sx127x_write_reg(dev, 0x4D, 0x87); // RegPaDac: enable +20dBm on PA_BOOST // (12) private network sync word sx127x_write_reg(dev, 0x39, 0x12); // RegSyncWord }
sx127x_tx.c void sx127x_tx(spi_device_handle_t dev, const uint8_t *payload, size_t len) { sx127x_write_reg(dev, 0x01, 0x81); // STANDBY sx127x_write_reg(dev, 0x40, 0x40); // RegDioMapping1: DIO0 = TxDone sx127x_write_reg(dev, 0x0D, 0x80); // RegFifoAddrPtr = RegFifoTxBaseAddr sx127x_write_fifo(dev, payload, len); sx127x_write_reg(dev, 0x22, (uint8_t)len); // RegPayloadLength sx127x_write_reg(dev, 0x01, 0x83); // RegOpMode: TX while (!(sx127x_read_reg(dev, 0x12) & 0x08)) { // poll RegIrqFlags.TxDone (bit3) vTaskDelay(pdMS_TO_TICKS(1)); // production code: block on a DIO0 rising-edge ISR/semaphore instead } sx127x_write_reg(dev, 0x12, 0x08); // clear TxDone: write 1 to the bit }
sx127x_rx.c size_t sx127x_rx_continuous(spi_device_handle_t dev, uint8_t *out, size_t max_len) { sx127x_write_reg(dev, 0x01, 0x81); // STANDBY sx127x_write_reg(dev, 0x40, 0x00); // RegDioMapping1: DIO0 = RxDone sx127x_write_reg(dev, 0x0D, 0x00); // RegFifoAddrPtr = RegFifoRxBaseAddr sx127x_write_reg(dev, 0x01, 0x85); // RegOpMode: RXCONTINUOUS for (;;) { uint8_t irq = sx127x_read_reg(dev, 0x12); if (!(irq & 0x40)) continue; // wait for RxDone (bit6) sx127x_write_reg(dev, 0x12, 0xFF); // clear all irq flags if (irq & 0x20) continue; // PayloadCrcError -- drop, keep listening uint8_t n = sx127x_read_reg(dev, 0x13); // RegRxNbBytes uint8_t cur = sx127x_read_reg(dev, 0x10); // RegFifoRxCurrentAddr sx127x_write_reg(dev, 0x0D, cur); // RegFifoAddrPtr = start of last packet size_t n_read = n < max_len ? n : max_len; for (size_t i = 0; i < n_read; i++) { out[i] = sx127x_read_reg(dev, 0x00); // RegFifo, sequential reads } return n_read; } }
The SX126x is command-based: every SPI transaction is opcode + parameter bytes, and the chip returns a status byte on MISO simultaneously with every byte. A BUSY pin must be polled low before each new command (the chip is processing). Register reads (opcode 0x1D) and buffer reads (0x1E) require a NOP/dummy byte after the address before data appears. SPI Mode 0, up to 16 MHz.
| Opcode | Command |
|---|---|
| 0x80 | SetStandby |
| 0x84 | SetSleep |
| 0xC1 | SetFs |
| 0x83 | SetTx |
| 0x82 | SetRx |
| 0xC5 | SetCad |
| 0x88 | SetCadParams |
| 0x96 | SetRegulatorMode |
| 0x89 | Calibrate |
| 0x98 | CalibrateImage |
| 0x95 | SetPaConfig |
| 0x0D | WriteRegister |
| 0x1D | ReadRegister |
| 0x0E | WriteBuffer |
| 0x1E | ReadBuffer |
| 0x8F | SetBufferBaseAddress |
| 0x08 | SetDioIrqParams |
| 0x12 | GetIrqStatus |
| 0x02 | ClearIrqStatus |
| 0x9D | SetDIO2AsRfSwitchCtrl |
| 0x97 | SetDIO3AsTcxoCtrl |
| 0x86 | SetRfFrequency |
| 0x8A | SetPacketType |
| 0x8E | SetTxParams |
| 0x8B | SetModulationParams |
| 0x8C | SetPacketParams |
| 0x13 | GetRxBufferStatus |
| 0x14 | GetPacketStatus |
| 0xC0 | GetStatus |
Confirmed against Semtech mbed SX126xLib, RadioLib, tinygo, and RadioHead.
| Command | Params |
|---|---|
| SetRfFrequency | RF = f_Hz · 2^25 / 32e6, FSTEP ≈ 0.95367 Hz. frf = freq_Hz * 33554432 / 32000000 |
| SetModulationParams | ModParam1 = SF byte (SF5=0x05…SF12=0x0C) · ModParam2 = BW (125k=0x04, 250k=0x05, 500k=0x06, 62.5k=0x03, 41.7k=0x0A, 31.25k=0x02, 20.8k=0x09, 15.6k=0x01, 10.4k=0x08, 7.8k=0x00) · ModParam3 = CR (4/5=0x01…4/8=0x04) · ModParam4 = LDRO (0x00 off / 0x01 on) |
| SetPacketParams | preamble length (16-bit) · header type (0x00 explicit / 0x01 implicit) · payload length · CRC type (on/off) · InvertIQ (0x00 standard / 0x01 inverted -- LoRaWAN downlinks use inverted IQ) |
| SetPaConfig @ +22dBm | paDutyCycle=0x04, hpMax=0x07, deviceSel=0x00 (0x01=SX1261), paLut=0x01 |
| SetTxParams | power=0x16 (22 dBm), rampTime (0x04 = 200µs, 0x07 = 3400µs) |
| SetStandby | STDBY_RC=0x00, STDBY_XOSC=0x01 |
| SetRegulatorMode | LDO=0x00, DC-DC/SMPS=0x01 (SMPS roughly halves RX/TX current) |
bits[6:4] chip mode (0x2 STBY_RC, 0x3 STBY_XOSC, 0x4 FS, 0x5 RX, 0x6 TX) · bits[3:1] command status (0x2 data available, 0x3 command timeout, 0x4 processing error/invalid opcode, 0x5 fail to execute, 0x6 command TX done). Example: 0x2C = 0b0010_1100 → STBY_RC + command-TX-done.
bit0 TxDone · bit1 RxDone · bit2 PreambleDetected · bit3 SyncWordValid · bit4 HeaderValid · bit5 HeaderErr · bit6 CrcErr · bit7 CadDone · bit8 CadDetected · bit9 Timeout. IRQs route to DIO1/DIO2/DIO3 via SetDioIrqParams and clear with ClearIrqStatus.
sx126x_cmd.c -- opcode + params, BUSY-gated, status echoed on MISO static void sx126x_wait_busy(gpio_num_t busy_pin) { while (gpio_get_level(busy_pin)) { // chip is still processing the previous command } } static uint8_t sx126x_cmd(spi_device_handle_t dev, gpio_num_t busy_pin, uint8_t opcode, const uint8_t *params, size_t plen, uint8_t *resp, size_t rlen) { sx126x_wait_busy(busy_pin); uint8_t tx[260] = { opcode }; memcpy(&tx[1], params, plen); uint8_t rx[260] = { 0 }; size_t total = (plen > rlen ? plen : rlen) + 1; // some commands read more than they write spi_transaction_t t = { .length = total * 8, .tx_buffer = tx, .rx_buffer = rx }; spi_device_transmit(dev, &t); if (resp && rlen) memcpy(resp, &rx[1], rlen); return rx[0]; // status byte, shadowed back on the first MISO byte }
sx126x_init.c void sx126x_init(spi_device_handle_t dev, gpio_num_t busy, uint32_t freq_hz) { sx126x_cmd(dev, busy, 0x80, (uint8_t[]){ 0x00 }, 1, NULL, 0); // SetStandby(STDBY_RC) // packet type must be set before modulation/packet params -- mandatory order sx126x_cmd(dev, busy, 0x8A, (uint8_t[]){ 0x01 }, 1, NULL, 0); // SetPacketType(LoRa) sx126x_cmd(dev, busy, 0x96, (uint8_t[]){ 0x01 }, 1, NULL, 0); // SetRegulatorMode(DC-DC) // on a TCXO board: SetDIO3AsTcxoCtrl(0x97,...) here first; XTAL boards skip it sx126x_cmd(dev, busy, 0x89, (uint8_t[]){ 0x7F }, 1, NULL, 0); // Calibrate(all blocks) sx126x_wait_busy(busy); sx126x_cmd(dev, busy, 0x98, (uint8_t[]){ 0xD7,0xDB }, 2, NULL, 0); // CalibrateImage, 863-870MHz band // SetRfFrequency: frf = freq_hz * 2^25 / 32e6 uint32_t frf = (uint32_t)(((uint64_t)freq_hz << 25) / 32000000ULL); uint8_t frf_b[4] = { (frf>>24)&0xFF, (frf>>16)&0xFF, (frf>>8)&0xFF, frf&0xFF }; sx126x_cmd(dev, busy, 0x86, frf_b, 4, NULL, 0); // SetRfFrequency sx126x_cmd(dev, busy, 0x95, (uint8_t[]){ 0x04,0x07,0x00,0x01 }, 4, NULL, 0); // SetPaConfig, +22dBm sx126x_cmd(dev, busy, 0x8E, (uint8_t[]){ 0x16,0x04 }, 2, NULL, 0); // SetTxParams: 22dBm, 200us ramp sx126x_cmd(dev, busy, 0x8F, (uint8_t[]){ 0x00,0x00 }, 2, NULL, 0); // SetBufferBaseAddress: tx=0, rx=0 // SF7, BW125, CR4/5, LDRO off sx126x_cmd(dev, busy, 0x8B, (uint8_t[]){ 0x07,0x04,0x01,0x00 }, 4, NULL, 0); // SetModulationParams // preamble=8, explicit header, length set per-tx, CRC on, standard IQ sx126x_cmd(dev, busy, 0x8C, (uint8_t[]){ 0x00,0x08, 0x00, 0x00, 0x01, 0x00 }, 6, NULL, 0); // SetPacketParams // route TxDone(bit0) + RxDone(bit1) + Timeout(bit9) to DIO1 sx126x_cmd(dev, busy, 0x08, (uint8_t[]){ 0x02,0x03, 0x02,0x03, 0x00,0x00, 0x00,0x00 }, 8, NULL, 0); // SetDioIrqParams }
sx126x_tx.c void sx126x_tx(spi_device_handle_t dev, gpio_num_t busy, const uint8_t *payload, size_t len) { uint8_t buf[257]; buf[0] = 0x00; // WriteBuffer offset = 0 memcpy(&buf[1], payload, len); sx126x_cmd(dev, busy, 0x0E, buf, len + 1, NULL, 0); // WriteBuffer uint8_t pkt[6] = { 0x00,0x08, 0x00, (uint8_t)len, 0x01, 0x00 }; sx126x_cmd(dev, busy, 0x8C, pkt, 6, NULL, 0); // SetPacketParams, length updated sx126x_cmd(dev, busy, 0x83, (uint8_t[]){ 0x00,0x00,0x00 }, 3, NULL, 0); // SetTx, timeout=0 (single-shot) // wait on DIO1 rising edge (TxDone) in production code, then ClearIrqStatus }
sx126x_rx.c size_t sx126x_rx(spi_device_handle_t dev, gpio_num_t busy, uint8_t *out, size_t max_len) { sx126x_cmd(dev, busy, 0x82, (uint8_t[]){ 0xFF,0xFF,0xFF }, 3, NULL, 0); // SetRx, continuous // wait on DIO1 rising edge in production code -- polling shown for clarity // note: Get*/Read* responses are preceded by status byte(s) on MISO -- data starts after them uint8_t irq[3]; do { sx126x_cmd(dev, busy, 0x12, (uint8_t[]){ 0x00,0x00 }, 2, irq, 3); // GetIrqStatus: status, MSB, LSB } while (!(irq[2] & 0x02)); // bit1 RxDone (in LSB) sx126x_cmd(dev, busy, 0x02, (uint8_t[]){ 0xFF,0xFF }, 2, NULL, 0); // ClearIrqStatus, all bits uint8_t status[3]; sx126x_cmd(dev, busy, 0x13, (uint8_t[]){ 0x00,0x00 }, 2, status, 3); // GetRxBufferStatus: status, len, ptr uint8_t len = status[1], offset = status[2]; uint8_t req[2] = { offset, 0x00 }; // offset, then required NOP byte uint8_t buf[258]; sx126x_cmd(dev, busy, 0x1E, req, 2, buf, len + 2); // ReadBuffer: data starts at byte 3 size_t n = len < max_len ? len : max_len; memcpy(out, buf + 2, n); return n; }
| Attribute | SX127x (SX1276/78, RFM95) | SX126x (SX1262, RA-01SC) |
|---|---|---|
| Interface | Flat register poke (addr+R/W bit) | Command/opcode + BUSY handshake |
| Datasheet/launch | Rev. 0.0, May 2013 | Rev. 1.1 Dec 2017 / 1.2 Jun 2019 (recommended for new designs) |
| Max TX power | +20 dBm (PA_BOOST) / +14 (RFO) | +22 dBm (SX1262/68), +14/15 (SX1261) |
| RX current | ~11 mA (Semtech selection guide) | ~4.6 mA (DC-DC) |
| TX current | ~120 mA @ 20 dBm | ~118 mA @ 22 dBm (DC-DC) |
| Sleep | ~0.2 µA (200 nA register retention) | 160 nA cold (all off) / 600 nA warm (config retained) / 1.2 µA with RC64k |
| Sensitivity SF12/125k | ~ −137 dBm | ~ −137 dBm (Rx boosted; −148 dBm only at BW 10.4 kHz) |
| SF range | SF6–SF12 | SF5–SF12 |
| Freq range | 137–1020 MHz (band-specific RF pins) | 150–960 MHz (single RF port) |
| Freq divisor | 2^19 / 32 MHz (FSTEP 61.035 Hz) | 2^25 / 32 MHz (FSTEP 0.95 Hz) |
| Image reject | no explicit cal step | requires Calibrate + CalibrateImage |
| TCXO control | RegTcxo (0x4B) | DIO3 (SetDIO3AsTcxoCtrl) |
| RF switch | host GPIO / external | DIO2 (SetDIO2AsRfSwitchCtrl) |
| IRQ pins | DIO0/DIO1/DIO2 | BUSY + DIO1 (DIO2/3 repurposed) |
| Package | 6×6 mm QFN | 4×4 mm QFN |
| Modules | RFM95/96/98, Ra-02, HopeRF | Ra-01SC/SH, E22, Wio-E5, Heltec V3 |
Both chips are SPI Mode 0 (CPOL=0, CPHA=0), 3.3V only. SX127x max SCLK ~10 MHz; SX126x ~16 MHz. Use SPI2_HOST/SPI3_HOST (the old HSPI/VSPI).
esp-idf spi setup spi_bus_config_t buscfg = { .mosi_io_num = PIN_MOSI, .miso_io_num = PIN_MISO, .sclk_io_num = PIN_SCLK, .quadwp_io_num = -1, .quadhd_io_num = -1, .max_transfer_sz = 256 }; spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO); spi_device_interface_config_t devcfg = { .clock_speed_hz = 8*1000*1000, // 8 MHz, under the 10 MHz SX127x limit .mode = 0, // CPOL=0 CPHA=0 .spics_io_num = PIN_CS, // driver toggles CS automatically .queue_size = 1 }; spi_device_handle_t dev; spi_bus_add_device(SPI2_HOST, &devcfg, &dev); // sx127x register write: addr|0x80 then value uint8_t tx[2] = { reg | 0x80, val }, rx[2]; spi_transaction_t t = { .length = 16, .tx_buffer = tx, .rx_buffer = rx }; spi_device_transmit(dev, &t); // .length is in BITS, not bytes
| Chip | Minimum wiring beyond SPI | Notes |
|---|---|---|
| SX127x | RESET, DIO0 (RxDone/TxDone) | DIO1 optional; wire DIO0/DIO1 to GPIOs as rising-edge interrupts |
| SX126x | RESET, BUSY, DIO1 | BUSY must be polled low before every command; DIO1 is the main IRQ line |
Many boards let the driver control NSS manually (spics_io_num = -1) when NSS sits behind an I/O expander. Keep CS and clock lines short -- the SX126x's mandatory BUSY poll makes bit-banging awkward but possible.
Architecture: end devices ↔ gateways (transparent PHY bridges) ↔ Network Server (dedup, MAC, ADR, routing) ↔ Application Server (payload decrypt); in 1.1 a Join Server holds root keys.
| Class | Behavior |
|---|---|
| A | mandatory, lowest power. Every uplink is followed by two short RX windows: RX1 (same freq as uplink, after RX1 Delay = 1s default) and RX2 (fixed freq/DR, +1s). Downlinks only in these windows. OTAA join uses JOIN_ACCEPT_DELAY1 = 5s and DELAY2 = 6s. |
| B | adds gateway-beacon-synchronized ping slots for scheduled downlinks at bounded latency. |
| C | RX2 window kept open continuously except while transmitting -- lowest downlink latency, highest power (mains devices). |
| Mode | Provisioning | Notes |
|---|---|---|
| OTAA | DevEUI (64-bit) + JoinEUI/AppEUI (64-bit) + AppKey (AES-128 root, +NwkKey in 1.1) | Join-Request = JoinEUI | DevEUI | DevNonce (must be non-repeating/persistent -- replay protection). Server replies with encrypted Join-Accept = AppNonce/JoinNonce | NetID | DevAddr | DLSettings | RxDelay | [CFList], MIC'd with AppKey. AppKey is never transmitted. |
| ABP | DevAddr + NwkSKey + AppSKey burned in | No join. Simpler but weaker: static keys, frame-counter reset risk. |
otaa session key derivation, 1.0.x NwkSKey = aes128_encrypt(AppKey, 0x01 | AppNonce | NetID | DevNonce | pad16) AppSKey = aes128_encrypt(AppKey, 0x02 | AppNonce | NetID | DevNonce | pad16) // 1.1 splits further into FNwkSIntKey / SNwkSIntKey / NwkSEncKey / AppSKey, // derived from NwkKey / AppKey and keyed on JoinEUI / JoinNonce
phypayload -> macpayload -> fhdr ┌─────────┬───────────────────────────────────────────┬─────────┐ │ MHDR │ MACPayload │ MIC │ │ 1 byte │ 7…M bytes │ 4 bytes │ └─────────┴───────────────────────────────────────────┴─────────┘ │ ▼ ┌────────────────────────┬──────────┬──────────────┐ │ FHDR │ FPort │ FRMPayload │ │ DevAddr|FCtrl|FCnt|FOpts│ 0-1 byte │ encrypted │ └────────────────────────┴──────────┴──────────────┘ │ ▼ ┌──────────────┬────────┬────────┬──────────────┐ │ DevAddr │ FCtrl │ FCnt │ FOpts │ │ 4B, little-e │ 1 byte │ 2 byte │ 0-15 bytes │ └──────────────┴────────┴────────┴──────────────┘
MHDR: MType(7:5), RFU(4:2), Major(1:0). FCtrl holds ADR, ADRACKReq, ACK, FPending (downlink), FOptsLen. FPort: 0 = FRMPayload carries MAC commands (encrypted with NwkSKey); 1–223 = application data (encrypted with AppSKey). MIC: 4 bytes, AES-CMAC over the frame using NwkSKey (network-integrity); FRMPayload itself is AES-128-CTR encrypted. TTN DevAddrs start 0x26/0x27.
| MType | Bits |
|---|---|
| Join-Request | 000 |
| Join-Accept | 001 |
| Unconfirmed Data Up | 010 |
| Unconfirmed Data Down | 011 |
| Confirmed Data Up | 100 |
| Confirmed Data Down | 101 |
Shared by Req/Ans in opposite directions.
| CID | Command |
|---|---|
| 0x02 | LinkCheckReq(up) / Ans(down) |
| 0x03 | LinkADRReq(down) / Ans(up) |
| 0x04 | DutyCycleReq/Ans |
| 0x05 | RXParamSetupReq/Ans |
| 0x06 | DevStatusReq/Ans (battery, margin) |
| 0x07 | NewChannelReq/Ans |
| 0x08 | RXTimingSetupReq/Ans |
| 0x09 | TXParamSetupReq/Ans |
| 0x0A | DlChannelReq/Ans |
| 0x0D | DeviceTimeReq/Ans |
The set above (0x02–0x0A plus DeviceTimeReq 0x0D) is LoRaWAN 1.0.4; 1.1 adds RekeyInd 0x0B, ADRParamSetupReq 0x0C, ForceRejoinReq 0x0E, RejoinParamSetupReq 0x0F.
When the device sets the ADR bit, the Network Server uses the max SNR over recent uplinks (ChirpStack/TTS use ~20 frames) minus the required SNR for the current DR minus an installation margin (~10 dB) to compute how many DR steps up or TX-power steps down are possible, then issues LinkADRReq (DataRate_TXPower, ChMask, Redundancy/NbTrans). The device confirms with LinkADRAns. Device-side: after ADR_ACK_LIMIT uplinks with no downlink it sets ADRACKReq; if no answer within ADR_ACK_DELAY it steps down the DR / increases power to regain connectivity.
| Region | Channels | Data rates | Duty / dwell | Power |
|---|---|---|---|---|
| EU868 | 3 mandatory join channels 868.1/868.3/868.5 MHz, 125 kHz | DR0=SF12…DR5=SF7 (DR6=SF7/250kHz, DR7=FSK) | 0.1–10% duty cycle depending on sub-band (1% / ~36 s/hr on the join channels) | max EIRP +16 dBm; RX2 = 869.525 MHz/DR0(SF12) |
| US915 | 64×125kHz uplink channels (902.3–914.9 MHz, 200kHz spacing) + 8×500kHz | DR0=SF10/125kHz…DR3=SF7, DR4=SF8/500kHz | no duty cycle, 400ms max dwell (FCC) | +30 dBm EIRP allowed |
US915 devices hop channels and use sub-bands (FSB); ADR/channel-mask must agree between 8-channel gateways and nodes or uplinks silently fail to be heard.
For a raw P2P link, both ends must match carrier frequency, SF, BW, CR, preamble length, sync word, CRC and IQ polarity exactly. Use sync word 0x12 (private) to stay off LoRaWAN; SX126x uses 0x1424 for the same. Sync-word filtering is imperfect -- 0x12 and 0x34 are the two recommended values that maximize cross-network rejection; other values behave unpredictably (on SX126x the surrounding nibbles must stay 0x_4_4).
Two typical schemes: fixed-length implicit-header ping-pong, or a small app header [dstAddr][srcAddr][seq][len] inside the payload with software address filtering and ACKs. Explicit header + payload CRC is the robust default; implicit header + CR4/8 + CRC-off trims airtime when both ends are fixed and pre-agreed.
CAD is a low-power scan that detects a LoRa preamble without full RX. On SX127x, set RegOpMode = CAD (0b111); the radio locks the PLL, captures ~one preamble symbol (~10 mA), then correlates. On CadDone IRQ, check CadDetected -- if set, switch to RX; else sleep. Per AN1200.85 the SX127x detects only the preamble; SX126x/SX128x can detect the full packet. On SX126x, configure SetCadParams (cadSymbolNum, cadDetPeak, cadDetMin, cadExitMode, timeout) then issue SetCad (0xC5); results come back via CadDone/CadDetected IRQs. CAD costs a few mA-ms versus continuous RX at ~5–11 mA, extending battery life dramatically for listen-mostly nodes.
sx127x_cad.c -- cheap listen-before-talk bool sx127x_cad(spi_device_handle_t dev) { sx127x_write_reg(dev, 0x40, 0x80); // RegDioMapping1: DIO0 = CadDone sx127x_write_reg(dev, 0x01, 0x87); // RegOpMode: MODE=CAD (0b111) while (!(sx127x_read_reg(dev, 0x12) & 0x04)) { // wait CadDone (bit2) vTaskDelay(pdMS_TO_TICKS(1)); } uint8_t irq = sx127x_read_reg(dev, 0x12); sx127x_write_reg(dev, 0x12, 0xFF); // clear flags return irq & 0x01; // bit0 CadDetected -- caller switches to RX if true }
RegPktSnrValue (signed) / 4 dB. RSSI offset differs by port: RSSI = −157 + PktRssi (HF, ≥779 MHz) or RSSI = −164 + PktRssi (LF). The band threshold is ~525 MHz (RF_MID_BAND_THRESH), not 868 MHz -- a common bug. When SNR < 0: RSSI = offset + PktRssi + PktSnr·0.25; when SNR ≥ 0 the slope is corrected: RSSI = offset + 16/15·PktRssi. Offsets shift with external LNA / LnaBoost, so single-point calibration matters for accuracy.
| Topic | Why | Where |
|---|---|---|
| SX1276 datasheet | The authoritative SX127x register map -- Rev. 0.0 May 2013 through Rev. 5/7. | Semtech SX1276/77/78/79 datasheet |
| SX1261/2 datasheet | The authoritative SX126x opcode/command reference. | Semtech SX1261/2 datasheet, Rev. 1.1 Dec 2017 / 1.2 Jun 2019 / 2.1 |
| AN1200.13 | Time-on-air formula, worked derivations. | Semtech application note AN1200.13 |
| AN1200.85 / AN1200.21 | CAD behavior and tuning. | Semtech application notes |
| LoRaWAN 1.0.x / 1.1 spec + RP002 | MAC layer and regional parameters, the ground truth for anything in LoRaWAN MAC. | LoRa Alliance technical specifications |
| Open-source drivers | Cross-check register/opcode values against independent implementations. | RadioLib, LoRaMac-node, tinygo, RadioHead, arduino-LoRa |
| PHY reverse-engineering literature | The whitening sequence, interleaver, and sync-word chip encoding were never published by Semtech -- this is where those values actually come from. | Knight/Seeber papers, EPFL/GNU Radio gr-lora, arXiv PHY papers |