Skip to main content

SX1276 LoRaWAN with RadioLib

Target Architecture & Scope

This guide covers building a production-ready LoRaWAN 1.0.4 end device using the classic Semtech SX1276 sub-GHz transceiver (and its common commercial module derivatives like the HopeRF RFM95W or AI-Thinker Ra-01H) paired with an Espressif ESP32 microcontroller, the RadioLib C++ library, and The Things Network (TTN).

Looking for the All-in-One Cubicore Devboard?

If you are using the integrated Cubicore Devboard (which houses an ESP32-S3 and Semtech SX1262 transceiver on a pre-routed RAK3112 module with onboard baseplate and battery management), consult Chapter 6: Joining a LoRaWAN Network. This guide is specifically tailored for developers wiring discrete, standalone SX1276 modules.


1. Overview & Supported Transceiver Modules​

The Semtech SX1276 is one of the most widely deployed LoRa transceivers in the history of the Internet of Things. Released prior to the newer SX126x family, it established the foundation for modern sub-GHz LoRaWAN networks worldwide. The transceiver operates from 137 MHz to 1020 MHz, supporting both high-frequency bands (868 MHz Europe, 915 MHz North America, and 923 MHz Asia-Pacific) and low-frequency bands (433/470 MHz).

Because raw SX1276 silicon requires specialized RF impedance matching networks and crystal circuitry, developers almost universally integrate the chip via prefabricated SMD modules and breakout boards:

  • HopeRF RFM95W / RFM95: The most prevalent commercial footprint. Features standard 2.0 mm pin pitch (or pre-soldered on 2.54 mm breadboard adapters) and an IPEX/U.FL connector or spring antenna solder pad.
  • AI-Thinker Ra-01H: Popular high-band (803–930 MHz) module utilizing the SX1276 chip, frequently found in low-cost development kits.
  • NiceRF LoRa1276: Industrial-grade SMD module with shielded RF circuitry and high TX stability.
  • Adafruit RFM95W LoRa Radio Breakout: An educational and prototyping breakout containing an onboard 3.3V LDO regulator and 5V-to-3.3V logic level shifters.
  • Generic SX1276 / RFM95 Breakout Modules: Widely sourced modules with RP-SMA or SMA edge-launch antenna connectors.

Regardless of your module brand, if the underlying silicon is an SX1276, the register maps, SPI timing, interrupt signaling, and RadioLib firmware logic detailed in this guide are 100% identical.

Connect Antenna Before Powering On

Never apply power or initiate an RF transmission without an antenna attached. Transmitting at high power (+17 dBm to +20 dBm via PA_BOOST) into an open circuit creates extreme Voltage Standing Wave Ratio (VSWR). The unradiated RF energy reflects directly back into the transceiver's silicon power amplifier output stage, causing rapid localized heating and permanent, irreversible semiconductor destruction. Always thread a tuned 868 MHz or 915/923 MHz antenna finger-tight onto the antenna jack prior to powering your microcontroller.


2. Choosing the Right Microcontroller (Why No Arduino Uno)​

When developers first experiment with LoRa, they often attempt to connect an SX1276 breakout to a classic 8-bit Arduino Uno, Nano, or Pro Mini (Microchip ATmega328P). While an Arduino Uno can handle basic Point-to-Point (P2P) raw ping-pong packets using minimal legacy libraries, it is entirely unsuitable and unsupported for modern LoRaWAN 1.0.4 operations.

The RadioLib library enforces strict cryptographic and protocol requirements that make 8-bit AVR microcontrollers technically unviable for LoRaWAN.

The Five Architectural Roadblocks of 8-Bit AVR (ATmega328P)​

  1. Flash Memory Exhaustion: The ATmega328P provides only 32 KB of Flash memory (with 0.5 KB to 2 KB consumed by the bootloader). A compliant LoRaWAN 1.0.4 stack requires AES-128 cryptographic primitives (CMAC computation and CTR encryption for Message Integrity Codes and payload obfuscation), MAC command decoders (LinkADRReq, RXParamSetupReq, DevStatusReq), regional channel frequency maps, and frame counter validation. The compiled RadioLib LoRaWAN stack consumes roughly 30 KB to 38 KB of Flash on its own—exceeding the physical capacity of the ATmega328P before you write a single line of application code or include a sensor driver.

  2. SRAM Depletion & Stack Collisions: The ATmega328P features only 2 KB of SRAM. In LoRaWAN, the microcontroller must maintain session context: Network Session Key (NwkSEncKey), Application Session Key (AppSKey), forward/serving integrity keys, DevNonce ledgers, uplink and downlink frame counters, and transmit/receive packet FIFO buffers. Attempting to allocate these buffers alongside the C++ runtime stack immediately results in stack-heap collisions, catastrophic variable corruption, and random watchdog resets.

  3. Ceramic Resonator Clock Drift (±0.5%): Most standard Arduino Uno and clone boards use a low-cost ceramic resonator rather than an accurate quartz crystal to drive the 16 MHz system clock. Ceramic resonators exhibit severe thermal drift of up to ±0.5% (5000 ppm). In LoRaWAN Class A, the end node must open exact downlink receive windows:

    • RX1: Opens precisely 1.0 second after TX completion (or 5.0 seconds for an OTAA JoinAccept).
    • RX2: Opens precisely 2.0 seconds after TX completion (or 6.0 seconds for an OTAA JoinAccept).

    Over a 5.0-second delay, a ±0.5% clock error accumulates up to ±25 ms of drift. Because the gateway transmits the downlink with microsecond precision, an end node drifting by 25 ms opens its receiver long after the downlink preamble has passed. Consequently, 100% of OTAA Join Attempts fail silently with timeout errors.

  4. 5V Logic Hazards & 3.3V Regulator Sag: The Semtech SX1276 is strictly a 3.3V CMOS device. Its absolute maximum supply voltage is 3.9V, and digital inputs cannot exceed VDD + 0.3V (3.6V). Connecting an Arduino Uno's 5V digital pins directly to the SX1276 SPI bus (SCK, MOSI, NSS) forces current through internal ESD clamping diodes, causing severe overheating, bus contention, and eventual silicon failure. Furthermore, the onboard 3.3V regulator on standard Uno boards (often tapped from the USB-to-UART bridge) is limited to 50 mA. The SX1276 draws peak currents of 120 mA to 130 mA during transmission at +17 dBm, causing the 3.3V power rail to brown out, rebooting the radio mid-packet.

  5. Flash / EEPROM Wear Fatigue: The ATmega328P has no battery-backed RTC SRAM. Persisting DevNonces to prevent replay attack rejections requires writing to the 1 KB internal EEPROM, which is rated for only 100,000 write cycles.

To achieve reliable LoRaWAN connectivity, modern 32-bit microcontrollers with hardware SPI, native 3.3V logic, and adequate memory are required:

MicrocontrollerCore ArchitectureClock SpeedFlash / SRAMLogic LevelLoRaWAN Suitability & Features
Espressif ESP32 (ESP-WROOM-32)Xtensa Dual-Core 32-bit LX6240 MHz4 MB / 520 KBNative 3.3VIdeal Reference Platform. Plentiful RAM, hardware AES, built-in non-volatile storage (Preferences.h), and 8 KB RTC Fast SRAM for deep sleep persistence.
Espressif ESP32-S3Xtensa Dual-Core 32-bit LX7240 MHz8 MB / 512 KBNative 3.3VHigh-Performance Platform. Native USB-C OTG, enhanced low-power states, full RadioLib support. Powering the Cubicore Devboard.
Raspberry Pi Pico (RP2040)ARM Cortex-M0+ Dual-Core133 MHz2 MB / 264 KBNative 3.3VSupported. Low-cost platform; utilizes LittleFS or external flash emulation for nonce persistence.
STM32 (L0 / L4 / WL Series)ARM Cortex-M0+ / M432–80 MHz64–256 KB / 20–64 KBNative 3.3VIndustrial Ultra-Low Power. Superb standby currents (sub-microamp); hardware crypto engine on select models.

For the remainder of this guide, the Espressif ESP32 (ESP-WROOM-32) serves as our primary reference platform.


3. Hardware Wiring & The Critical DIO0 + DIO1 Rule​

Connecting an SX1276 transceiver to an ESP32 requires an understanding of the architectural differences between the classic SX1276 and newer transceivers like the SX1262.

SX1276 vs. SX1262 Architectural Differences​

When transitioning between LoRa chips in RadioLib:

  • No BUSY Pin: The SX1276 does not have a BUSY pin. All internal state transitions are managed via SPI status registers and interrupt pins.
  • No setDio2AsRfSwitch() or setTCXO(): The SX1276 handles its RF switching directly through dedicated pin configurations (or external module circuitry) and uses a conventional crystal resonator rather than an internally powered TCXO. Calling radio.setDio2AsRfSwitch(true) or radio.setTCXO(...) on an SX1276 object causes compiler errors because these methods only exist for the SX126x/SX128x driver classes.
  • RadioLib Module Constructor:
    Module* loraMod = new Module(cs, irq, rst, gpio);
    // For SX1276: cs = NSS, irq = DIO0, rst = RESET, gpio = DIO1

The Critical DIO0 + DIO1 Rule for LoRaWAN​

In raw Point-to-Point (P2P) LoRa examples, many hobby guides connect only DIO0. While DIO0 is sufficient for basic packet transmission and reception, LoRaWAN Class A strictly mandates both DIO0 and DIO1:

  1. DIO0 (TxDone & RxDone):
    • Fires an interrupt when RF packet transmission finishes (TxDone).
    • Fires an interrupt when an incoming packet has been completely demodulated into the FIFO buffer (RxDone).
  2. DIO1 (RxTimeout & CadDone):
    • In LoRaWAN Class A, after transmitting, the node sleeps until the exact RX1 window opens. It turns on the receiver for a narrow window (e.g., 5 to 8 symbols).
    • If the gateway does not send a downlink, the SX1276 hardware timer expires and asserts DIO1 (RxTimeout).
    • What happens if DIO1 is disconnected? RadioLib enters RX1 and waits for an interrupt. Because DIO1 is missing, the microcontroller never receives the RxTimeout signal. RadioLib hangs until its internal safety watchdog fires, completely missing the opening of the RX2 receive window.
    • Consequently, the end device fails every OTAA join request with error code -1107 (RADIOLIB_ERR_LORAWAN_RX_TIMEOUT) or -705 (RADIOLIB_ERR_RX_TIMEOUT).
Wiring Mandatory

Both DIO0 and DIO1 MUST be wired to distinct, interrupt-capable GPIO pins on your microcontroller. Without DIO1, LoRaWAN OTAA activation and downlink reception will fail 100% of the time.


Wiring Table 1: ESP32 DevKitC (ESP-WROOM-32) to SX1276 / RFM95W​

This configuration utilizes the ESP32 hardware VSPI peripheral bus:

SX1276 / RFM95 PinESP32 DevKit PinFunction / Wire ColorNotes
VCC / 3.3V3V3Power (Red)Connect to regulated 3.3V rail. Do NOT connect to 5V / VIN!
GNDGNDGround (Black)Common ground reference.
SCKGPIO 18VSPI SCK (Yellow)SPI Serial Clock.
MISOGPIO 19VSPI MISO (White)Master In / Slave Out (SPI Data from SX1276 to ESP32).
MOSIGPIO 23VSPI MOSI (Blue)Master Out / Slave In (SPI Data from ESP32 to SX1276).
NSS / CSGPIO 5SPI Chip Select (Orange)Active-low slave select.
RESET / RSTGPIO 14Hardware Reset (Green)Active-low hardware reset.
DIO0GPIO 26Interrupt 0 (Purple)Mandatory: TxDone and RxDone interrupt.
DIO1GPIO 27Interrupt 1 (Brown)Mandatory: RxTimeout interrupt for LoRaWAN RX1/RX2.
ANTSMA Connector50-ohm RF OutputConnect tuned 868/915 MHz antenna before powering.
ESP32 Strapping Pins to Avoid

When choosing custom GPIO pins on an ESP32 for transceiver lines, never use the following strapping pins:

  • GPIO 12 (MTDI): High at boot selects 1.8V flash VDD, causing a bootloop flash crash on standard 3.3V ESP32 modules.
  • GPIO 0: Low at boot forces the ESP32 into UART serial flashing mode, preventing your code from executing.
  • GPIO 2: Connected to the onboard blue LED; pulling high or low unexpectedly can inhibit boot.
  • GPIO 34, 35, 36 (VP), 39 (VN): Input-only pins without software-configurable pull-up or pull-down resistors. Do NOT use for NSS or RESET (which require active output drive).

Wiring Table 2: ESP32-S3 DevKit to SX1276 / RFM95W​

If building on an ESP32-S3 DevKit using its hardware FSPI bus:

SX1276 / RFM95 PinESP32-S3 PinFunction / Wire ColorNotes
VCC / 3.3V3V3Power (Red)Regulated 3.3V rail.
GNDGNDGround (Black)Common ground reference.
SCKGPIO 12FSPI SCK (Yellow)SPI Serial Clock.
MISOGPIO 13FSPI MISO (White)Master In / Slave Out.
MOSIGPIO 11FSPI MOSI (Blue)Master Out / Slave In.
NSS / CSGPIO 10SPI Chip Select (Orange)Active-low slave select.
RESET / RSTGPIO 9Hardware Reset (Green)Active-low hardware reset.
DIO0GPIO 4Interrupt 0 (Purple)Mandatory: TxDone and RxDone.
DIO1GPIO 5Interrupt 1 (Brown)Mandatory: RxTimeout.
ANTSMA Connector50-ohm RF OutputConnect tuned antenna before powering.

4. Understanding LoRaWAN Keys & Regional Configuration (AS923-3)​

To communicate securely over a public or private LoRaWAN network, your device must be authenticated using cryptographic credentials defined by the LoRa Alliance.

The Three OTAA Cryptographic Credentials​

Under Over-The-Air Activation (OTAA), your end device does not store static network session keys. Instead, it holds three credentials:

  1. DevEUI (Device Extended Unique Identifier): A globally unique 64-bit (8-byte) identifier assigned to your specific hardware. Think of this as the MAC address or serial number of the node.
  2. JoinEUI (formerly AppEUI): A 64-bit (8-byte) identifier pointing to the Join Server responsible for authenticating the device. On The Things Network, entering 0000000000000000 (16 hexadecimal zeros) is standard and fully accepted for private and bench setups.
  3. AppKey (Application Key): A 128-bit (16-byte) AES root key known only to your end device and the network Join Server. During the OTAA Join exchange, this root key encrypts the JoinRequest and decrypts the JoinAccept, securely deriving session keys (AppSKey for payload encryption and NwkSKey for message authenticity).
What About nwkKey in RadioLib?

In the sketch below, you will notice RadioLib's initialization method requires four parameters:

node.beginOTAA(joinEUI, devEUI, nwkKey, appKey);

Under LoRaWAN 1.0.4, nwkKey and appKey are cryptographically identical. RadioLib includes both parameters to maintain forward compatibility with LoRaWAN 1.1 networks (which utilize two distinct 128-bit root keys). For TTN LoRaWAN 1.0.4, set both nwkKey and appKey to the single 16-byte AppKey generated by the TTN Console.


Regional Band: AS923 Group 3 (Philippines)​

In the Philippines and several Southeast Asian territories, LoRaWAN operates under the AS923-3 band plan:

  • Default Uplink Channel 0: 916.6 MHz
  • Default Uplink Channel 1: 916.8 MHz
  • Default Datarate: DR2 (Spreading Factor 10, Bandwidth 125 kHz)

Gateway Compatibility: Dual-Channel vs. 8-Channel Concentrators​

Depending on the gateway forwarding your packets, your firmware configuration must be adapted:

Feature / SettingCubicore Dual-Channel (Pocket / Hub)Standard 8-Channel Commercial Concentrator
Uplink FrequenciesExactly 2 fixed frequencies (916.6 MHz & 916.8 MHz)8 to 64 concurrent frequencies across regional sub-band
Spreading FactorFixed DR2 (SF10 / 125 kHz bandwidth)Dynamic demodulation (SF7 through SF12)
Adaptive Data Rate (ADR)Must be disabled: node.setADR(false);Enabled: node.setADR(true);
Data Rate LockingLocked to DR2: node.setDatarate(2);Dynamically commanded by TTN network server
Airtime per Uplink~164 ms (6-byte binary payload)Down to ~60 ms at SF7

Dual-Channel Gateways (Cubicore Gateway Pocket & Gateway Hub)​

Compact dual-channel gateways listen on exactly two uplink frequencies (916.6 MHz and 916.8 MHz) and decode a single fixed spreading factor (DR2 / SF10). To communicate through these gateways, you must enforce the Two Golden Firmware Rules:

  1. Disable ADR (node.setADR(false);): If Adaptive Data Rate (ADR) remains active, TTN's network server measures high signal quality and commands the node to switch to SF7 or SF8. Once the node transitions to SF7, the dual-channel gateway cannot decode the packets, causing complete telemetry loss!
  2. Lock Datarate to DR2 (node.setDatarate(2);): Forces the transceiver to transmit every frame at SF10 (125 kHz bandwidth).

Standard 8-Channel Commercial Concentrators​

Enterprise gateways (built with Semtech SX1302 or SX1303 multi-channel baseband processors) listen concurrently across eight frequencies and simultaneously demodulate SF7 through SF12. On these networks, enable ADR (node.setADR(true);). The network server will automatically optimize your transmission speed to SF7, reducing airtime from ~400 ms down to ~60 ms and dramatically extending battery life.


5. Registering Your SX1276 End Device on The Things Network (TTN)​

Follow these exact steps to register your custom ESP32 + SX1276 end node on The Things Network:

Step 5.1: Access the TTN Console​

  1. Navigate to The Things Network Cloud Console.
  2. Select your regional cluster:
    • Asia Pacific 1 (au1): au1.cloud.thethings.network (Philippines, Australia, Southeast Asia).
    • Europe 1 (eu1): eu1.cloud.thethings.network.
    • North America 1 (nam1): nam1.cloud.thethings.network.
  3. Log in with your The Things ID credentials.

Step 5.2: Create an Application​

  1. Click Applications in the top navigation bar, then click + Create application.
  2. Set the Application ID (e.g., lorawan-sx1276-nodes).
  3. Enter an optional Application name and description, then click Create application.

Step 5.3: Register the End Device Manually​

  1. Inside your application overview, click the + Register end device button.
  2. Select the option Enter end device specifics manually (do not select from the device repository).
  3. Configure the Network Layer settings:
    • Frequency plan: Select Asia 920-923 MHz (AS923 Group 3) with only default channels and dwell time disabled.
    • LoRaWAN version: Select LoRaWAN Specification 1.0.4.
    • Regional Parameters version: Select RP002 Regional Parameters 1.0.4.
  4. Click Show advanced activation, LoRaWAN class and cluster settings and ensure the activation mode is set to Over the air activation (OTAA) and the class is Class A.

Step 5.4: Generate Cryptographic Keys & Select MSB Format​

  1. Under the Provisioning information section:
    • JoinEUI: Enter 0000000000000000 (16 hexadecimal zeros).
    • DevEUI: Click the Generate button to create a random, unique 64-bit EUI.
    • AppKey: Click the Generate button to create a cryptographically secure 128-bit key.
    • End device ID: Enter a readable identifier (e.g., esp32-sx1276-custom-node).
  2. Click Register end device.
  3. On the resulting device overview dashboard, locate the keys under Device credentials:
    • Next to DevEUI, click the <> (Format as C array) button, and ensure the directional arrow reads -> (MSB - Most Significant Byte).
    • Next to AppKey, click <> and select -> (MSB).
    • Copy these byte arrays. They will be pasted directly into our Arduino IDE sketch.

6. DevNonce Persistence & Zero Runtime Flash Wear​

A major challenge encountered by developers deploying LoRaWAN 1.0.4 end devices is the DevNonce is too small rejection error.

6.1 The DevNonce Ledger Rule​

Under the LoRaWAN 1.0.4 specification, every JoinRequest transmitted by an end device includes a 2-byte counter known as the DevNonce.

Event / StepEnd Node Nonce CounterTTN Cloud Nonce LedgerServer Decision
Initial UplinkDevNonce = 12Last Recorded = 11Accepted: 12 > 11 (Strictly higher nonce verified)
Node Power Cycle (Without NVS)DevNonce = 0 (Reset to zero)Last Recorded = 12REJECTED: DevNonce is too small (0 <= 12)
Node Power Cycle (With NVS)DevNonce = 13 (Restored & incremented)Last Recorded = 12Accepted: 13 > 12 (Valid fresh nonce)

The network server maintains a strict monotonically increasing ledger of all nonces ever presented by that DevEUI. If an end node reboots, resets its internal memory, and transmits a join packet with a nonce lower than or equal to the last recorded value, TTN discards the request immediately to prevent replay attacks.

On the lab bench, if you wipe your microcontroller's flash memory or switch boards, you can reset TTN's ledger:

  1. Open TTN Console → Go to End Device → Settings → Network layer.
  2. Scroll to the bottom and click Reset used DevNonces.
  3. Click Save changes.

6.2 The "Flash for Bootstrapping, RAM for Runtime" Architecture​

In field production, nodes must survive power cycles, battery brownouts, and resets without requiring manual intervention in the cloud console. However, naive implementations that write state to flash memory on every packet will rapidly destroy the microcontroller's flash chip.

Embedded NOR flash memory has a finite endurance of roughly 100,000 write cycles. If an IoT sensor logs data every 30 seconds and writes to flash on every transmission:

100,000 cycles × 30 seconds = 3,000,000 seconds ≈ 34.7 days Within five weeks, the flash sector will develop physical silicon gate breakdown, permanently bricking the board.

To solve this, our firmware implements the "Flash for Bootstrapping, RAM for Runtime" architecture using the ESP32's built-in Preferences.h library:

  • At Boot (setup()): The ESP32 opens the NVS namespace (prefs.begin("lorawan", false)), restores the previous LWnonces buffer, and executes node.activateOTAA().
  • Upon Successful Join: The freshly incremented DevNonce buffer is written back to NVS flash EXACTLY ONCE per power cycle.
  • During Normal Operation (loop()): Uplink frame counters (FCntUp) and payload buffers execute 100% in RAM. No flash writes occur during continuous runtime.

Because a fresh OTAA activation resets session keys and synchronizes frame counters with TTN back to zero, uplinks flow smoothly with zero silent drops and zero flash degradation.

6.3 Deep Sleep Alternative (RTC_DATA_ATTR) for Battery Nodes​

For battery-powered field nodes that sleep between sensor readings, performing an OTAA join on every wake-up is inefficient: each join requires ~5 seconds of active radio listening, consuming immense battery energy.

Instead, the ESP32 provides 8 KB of RTC Fast SRAM that remains powered during deep sleep while consuming less than 10 µA. By tagging buffers with RTC_DATA_ATTR, both the session keys (LWsession) and nonces (LWnonces) persist in RAM across sleep cycles with zero flash writes:

#include <RadioLib.h>
#include <esp_sleep.h>

// Preserved in ESP32 RTC Fast SRAM during deep sleep (0 Flash wear!)
RTC_DATA_ATTR uint8_t LWnonces[RADIOLIB_LORAWAN_NONCES_BUF_SIZE];
RTC_DATA_ATTR uint8_t LWsession[RADIOLIB_LORAWAN_SESSION_BUF_SIZE];

void setup() {
Serial.begin(115200);

// Check if waking up from Deep Sleep
if (esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_UNDEFINED) {
Serial.println(F("[WAKE] Woke from deep sleep. Restoring session from RTC SRAM..."));
node.setBufferNonces(LWnonces);
node.setBufferSession(LWsession);
node.beginOTAA(joinEUI, devEUI, nwkKey, appKey);
} else {
Serial.println(F("[BOOT] Cold power-on boot. Joining via OTAA..."));
node.beginOTAA(joinEUI, devEUI, nwkKey, appKey);
node.activateOTAA();
}

// Transmit sensor payload immediately
uint8_t payload[6] = { /* sensor bytes */ };
node.sendReceive(payload, sizeof(payload), 1);

// Save updated session state back into RTC SRAM before sleeping
memcpy(LWnonces, node.getBufferNonces(), RADIOLIB_LORAWAN_NONCES_BUF_SIZE);
memcpy(LWsession, node.getBufferSession(), RADIOLIB_LORAWAN_SESSION_BUF_SIZE);

// Deep sleep for 10 minutes (600,000,000 microseconds)
Serial.println(F("[SLEEP] Entering deep sleep for 10 minutes..."));
esp_deep_sleep(600ULL * 1000000ULL);
}

void loop() {
// Never reached when using deep sleep
}

7. Complete Arduino IDE Setup & LoRaWAN OTAA Sketch​

Library Installation & Toolchain Configuration​

  1. Install RadioLib:
    • In Arduino IDE, navigate to Sketch → Include Library → Manage Libraries...
    • Search for RadioLib by Jan Gromes.
    • Install version 7.4.0 or newer.
  2. Install ESP32 Board Package:
    • In Arduino IDE, navigate to Tools → Board → Boards Manager...
    • Search for esp32 by Espressif Systems and ensure the latest stable release is installed.
  3. Board Selection:
    • Under Tools → Board → ESP32 Arduino, select ESP32 Dev Module.
    • Set Upload Speed to 921600.
    • Set CPU Frequency to 240MHz (WiFi/BT).
    • Select your USB-to-UART COM port under Tools → Port.

Complete Production Sketch (Single .ino File)​

Copy and paste the complete C++ sketch below into a blank Arduino IDE editor window. Update the joinEUI, devEUI, and appKey variables with your credentials from the TTN Console:

/*
* =====================================================================================
* Cubicore LoRaWAN Guides - SX1276 / RFM95W OTAA End Node
* Target: ESP32 DevKitC (ESP-WROOM-32) + Semtech SX1276 / HopeRF RFM95W
* Profile: LoRaWAN 1.0.4 OTAA on AS923-3 (916.6 / 916.8 MHz, DR2 / SF10)
* Library: RadioLib by Jan Gromes (v7.4.0+)
*
* Features:
* - Native 32-bit hardware VSPI driver.
* - Mandatory DIO0 (TxDone/RxDone) and DIO1 (RxTimeout) interrupt management.
* - Dual-channel gateway compatibility (Cubicore Gateway Pocket / Gateway Hub).
* - NVS Flash DevNonce persistence (zero Flash writes during continuous runtime).
* - 6-byte packed binary telemetry payload with downlink reception check.
* =====================================================================================
*/

#include <RadioLib.h>
#include <SPI.h>
#include <Preferences.h> // Built-in to ESP32 Arduino Core (NVS Flash Storage)

// =====================================================================================
// ⬇️ PASTE YOUR THE THINGS NETWORK (TTN) CREDENTIALS HERE (MSB Format) ⬇️
// =====================================================================================
uint64_t joinEUI = 0x0000000000000000;
uint64_t devEUI = 0x70B3D57ED007940B;

// In LoRaWAN 1.0.4, nwkKey and appKey are identical. Paste the TTN AppKey into both:
uint8_t appKey[] = { 0xB0, 0x7B, 0x60, 0xC1, 0x7E, 0xD0, 0x58, 0xFE,
0x8D, 0x3B, 0x76, 0x54, 0x10, 0x63, 0xD0, 0xB1 };
uint8_t nwkKey[] = { 0xB0, 0x7B, 0x60, 0xC1, 0x7E, 0xD0, 0x58, 0xFE,
0x8D, 0x3B, 0x76, 0x54, 0x10, 0x63, 0xD0, 0xB1 };
// =====================================================================================

// SX1276 / RFM95W Pin Definitions (ESP32 VSPI)
#define LORA_NSS 5 // Chip Select
#define LORA_DIO0 26 // TxDone & RxDone Interrupt
#define LORA_RESET 14 // Hardware Reset
#define LORA_DIO1 27 // RxTimeout Interrupt (CRITICAL FOR LORAWAN!)
#define LORA_SCK 18 // SPI Clock
#define LORA_MISO 19 // Master In / Slave Out
#define LORA_MOSI 23 // Master Out / Slave In

// Optional Onboard Diagnostic LED (GPIO 2 on most ESP32 DevKits)
#define PIN_LED 2

// Initialize dedicated hardware Module and SX1276 Radio
// RadioLib SX1276 Constructor: Module(cs, irq, rst, gpio)
Module* loraMod = new Module(LORA_NSS, LORA_DIO0, LORA_RESET, LORA_DIO1);
SX1276 radio(loraMod);

// LoRaWAN Node configured for AS923-3 regional parameters
LoRaWANNode node(&radio, &AS923_3);

// Flash NVS Nonce Persistence Engine
Preferences prefs;
#define NONCES_BUF_SIZE RADIOLIB_LORAWAN_NONCES_BUF_SIZE

// --- Helper: Restore Nonces from Flash and Join the Network ---
void handleNVSJoin() {
prefs.begin("lorawan", false);

// 1. Restore previous DevNonce buffer from NVS if available
if (prefs.isKey("nonces")) {
Serial.println(F("[NVS] Restoring saved DevNonce ledger from Flash..."));
uint8_t noncesBuf[NONCES_BUF_SIZE];
prefs.getBytes("nonces", noncesBuf, NONCES_BUF_SIZE);
node.setBufferNonces(noncesBuf);
} else {
Serial.println(F("[NVS] No prior DevNonce record found. Initializing fresh ledger."));
}

// 2. Perform OTAA Join Request
Serial.println(F("[NETWORK] Initiating OTAA Join Request..."));
int attempt = 1;
while (!node.isActivated() && attempt <= 5) {
Serial.printf("[NETWORK] OTAA Join Attempt %d of 5... ", attempt);
digitalWrite(PIN_LED, HIGH);

int16_t joinState = node.activateOTAA();
digitalWrite(PIN_LED, LOW);

if (joinState == RADIOLIB_LORAWAN_NEW_SESSION) {
Serial.println(F("SUCCESS! Network Joined."));
// Save newly incremented DevNonce EXACTLY ONCE per boot/power cycle
prefs.putBytes("nonces", node.getBufferNonces(), NONCES_BUF_SIZE);
Serial.println(F("[NVS] DevNonce updated in Flash (zero Flash wear during loop runtime)."));
break;
} else {
Serial.printf("FAILED (Error: %d). Retrying in 6 seconds...\n", joinState);
delay(6000);
attempt++;
}
}
prefs.end();

// 3. Halt if connection failed after 5 attempts
if (!node.isActivated()) {
Serial.println(F("\n[HALT] Failed to join LoRaWAN network."));
Serial.println(F("[HALT] Verify antenna connection, gateway status, and TTN keys."));
while (true) {
digitalWrite(PIN_LED, !digitalRead(PIN_LED));
delay(250);
}
}
}

void setup() {
Serial.begin(115200);
while (!Serial && millis() < 3000);

Serial.println(F("\n======================================================="));
Serial.println(F(" Cubicore SX1276 / RFM95W LoRaWAN Node (AS923-3) "));
Serial.println(F("======================================================="));

pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, LOW);

// Initialize VSPI bus
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_NSS);

// Initialize SX1276 Transceiver
Serial.print(F("[RADIO] Initializing SX1276 transceiver... "));
int16_t radioState = radio.begin();
if (radioState != RADIOLIB_ERR_NONE) {
Serial.printf("FAILED (Error %d). Check SPI wiring and 3.3V power.\n", radioState);
while (true);
}
Serial.println(F("SUCCESS."));

// Initialize LoRaWAN Stack with credentials
Serial.print(F("[LORAWAN] Initializing LoRaWAN stack (AS923-3)... "));
int16_t stackState = node.beginOTAA(joinEUI, devEUI, nwkKey, appKey);
if (stackState != RADIOLIB_ERR_NONE) {
Serial.printf("FAILED (Error %d). Verify key format.\n", stackState);
while (true);
}
Serial.println(F("SUCCESS."));

// ===================================================================================
// DUAL-CHANNEL GATEWAY RULES (Cubicore Gateway Pocket / Gateway Hub)
// For standard 8-channel enterprise gateways, set: node.setADR(true);
// ===================================================================================
node.setADR(false); // Disable Adaptive Data Rate to prevent channel/speed drift
node.setDatarate(2); // Lock transmission speed to DR2 (SF10 BW125)
Serial.println(F("[CONFIG] ADR Disabled. Data Rate locked to DR2 (SF10 / 125 kHz)."));

// Restore nonces and complete clean OTAA Join
handleNVSJoin();

Serial.println(F("\n[STATUS] Node is ready. Starting periodic uplink loop...\n"));
}

void loop() {
static uint16_t packetCounter = 0;
static unsigned long lastUplinkTime = 0;
const unsigned long UPLINK_INTERVAL = 30000; // 30 seconds

if (millis() - lastUplinkTime >= UPLINK_INTERVAL || lastUplinkTime == 0) {
lastUplinkTime = millis();
packetCounter++;

// Generate simulated environmental telemetry
// (Replace with real sensor readings, e.g., SHT30, BME280, or DS18B20)
float temperature = 27.45f + (float)(esp_random() % 100) / 50.0f; // ~27.45 - 29.45 C
float humidity = 58.20f + (float)(esp_random() % 100) / 25.0f; // ~58.20 - 62.20 %

// Scale floats into 16-bit integers for compact binary transmission
int16_t tempScaled = (int16_t)(temperature * 100.0f); // 28.50 C -> 2850
uint16_t humScaled = (uint16_t)(humidity * 100.0f); // 60.10 % -> 6010

// Construct 6-byte packed binary payload
uint8_t payload[6];
payload[0] = (packetCounter >> 8) & 0xFF;
payload[1] = packetCounter & 0xFF;
payload[2] = (tempScaled >> 8) & 0xFF;
payload[3] = tempScaled & 0xFF;
payload[4] = (humScaled >> 8) & 0xFF;
payload[5] = humScaled & 0xFF;

Serial.printf(">>> [TX #%u] Uplink -> Temp: %.2f C, Hum: %.2f %% (6 bytes)... ",
packetCounter, temperature, humidity);

// Transmit uplink on LoRaWAN Port 1
digitalWrite(PIN_LED, HIGH);
int16_t state = node.sendReceive(payload, sizeof(payload), 1);
digitalWrite(PIN_LED, LOW);

if (state == RADIOLIB_ERR_NONE) {
Serial.println(F("SUCCESS (No downlink received)."));
} else if (state > 0) {
Serial.printf("SUCCESS! Received %d downlink bytes from network.\n", state);

// Optional: Read downlink payload
uint8_t downlinkData[256];
size_t downlinkLen = 0;
node.getDownlinkData(downlinkData, &downlinkLen);
Serial.print(F(" Downlink HEX: "));
for (size_t i = 0; i < downlinkLen; i++) {
Serial.printf("%02X ", downlinkData[i]);
}
Serial.println();
} else {
Serial.printf("FAILED (Error: %d)\n", state);
}
}
}

Expected Serial Monitor Output​

When you open the Arduino IDE Serial Monitor at 115200 baud and power on your ESP32, you will observe the following sequence:

=======================================================
Cubicore SX1276 / RFM95W LoRaWAN Node (AS923-3)
=======================================================
[RADIO] Initializing SX1276 transceiver... SUCCESS.
[LORAWAN] Initializing LoRaWAN stack (AS923-3)... SUCCESS.
[CONFIG] ADR Disabled. Data Rate locked to DR2 (SF10 / 125 kHz).
[NVS] Restoring saved DevNonce ledger from Flash...
[NETWORK] Initiating OTAA Join Request...
[NETWORK] OTAA Join Attempt 1 of 5... SUCCESS! Network Joined.
[NVS] DevNonce updated in Flash (zero Flash wear during loop runtime).

[STATUS] Node is ready. Starting periodic uplink loop...

>>> [TX #1] Uplink -> Temp: 28.12 C, Hum: 59.44 % (6 bytes)... SUCCESS (No downlink received).
>>> [TX #2] Uplink -> Temp: 27.95 C, Hum: 60.18 % (6 bytes)... SUCCESS (No downlink received).
>>> [TX #3] Uplink -> Temp: 28.34 C, Hum: 58.82 % (6 bytes)... SUCCESS (No downlink received).

8. Decoding Binary Payloads on TTN & Integrating with Cubicore Cloud​

Why Binary Packing Beats JSON Over LoRa​

Beginners often attempt to transmit sensor telemetry formatted as ASCII text or JSON strings:

{"temp":28.12,"hum":59.44,"count":1}

This single string consumes 37 bytes of payload. At DR2 (Spreading Factor 10, 125 kHz bandwidth), transmitting 37 bytes requires an on-air transmission time of 390 milliseconds.

By packing the same telemetry into our 6-byte binary payload (packetCounter [2 bytes] + tempScaled [2 bytes] + humScaled [2 bytes]):

  • The total payload is reduced from 37 bytes to 6 bytes.
  • On-air transmission time drops to 164 milliseconds (a 58% reduction in airtime).
  • Radio energy consumption from the battery is halved.
  • Channel occupancy is minimized, reducing collision probabilities across neighboring nodes.

To convert the 6-byte binary array back into human-readable JSON fields inside The Things Network:

  1. Open your registered device in the TTN Console.
  2. Select the Payload formatters tab, then click Uplink.
  3. Under Formatter type, select Custom Javascript formatter.
  4. Paste the following decoder script into the code editor:
function decodeUplink(input) {
var bytes = input.bytes;

// Validate that payload contains the required 6 bytes
if (bytes.length < 6) {
return {
errors: ["Invalid payload length: expected 6 bytes, received " + bytes.length]
};
}

// 1. Unpack 16-bit Packet Counter (Big-Endian / MSB)
var counter = (bytes[0] << 8) | bytes[1];

// 2. Unpack 16-bit Temperature with two's complement signed integer handling
var rawTemp = (bytes[2] << 8) | bytes[3];
if (rawTemp & 0x8000) {
rawTemp -= 0x10000; // Sign extend negative temperatures below 0.00 C
}
var temperature = rawTemp / 100.0;

// 3. Unpack 16-bit Relative Humidity
var rawHum = (bytes[4] << 8) | bytes[5];
var humidity = rawHum / 100.0;

return {
data: {
packet_counter: counter,
temperature_c: temperature,
humidity_rh: humidity
}
};
}
  1. Click Save changes.

When your next uplink arrives, navigate to the Live data tab in TTN. The incoming raw bytes will decode automatically into structured JSON:

{
"packet_counter": 1,
"temperature_c": 28.12,
"humidity_rh": 59.44
}

Forwarding to Cubicore Cloud & MQTT​

Once decoded inside TTN, this structured JSON payload can be routed into downstream cloud infrastructure:

  • Cubicore Cloud Integration: Telemetry flows directly into Cubicore Cloud telemetry dashboards, enabling live graphing, automated device health alerting, and threshold notifications (see Chapter 7: Integrating with Cubicore Cloud).
  • TTN MQTT Broker: Subscribe to your application's real-time uplink stream using Node-RED, Python, or Home Assistant using the standard TTN MQTT integration.

9. Troubleshooting Common Errors​

The table below outlines common runtime errors encountered when deploying the SX1276 with RadioLib, their root causes, and verified fixes:

Error Code / SymptomDescriptionRoot Cause & Recommended Fix
Error: -2
RADIOLIB_ERR_CHIP_NOT_FOUND
RadioLib could not detect the SX1276 transceiver over SPI.1. SPI Pin Mismatch: Verify LORA_NSS (GPIO 5), SCK (GPIO 18), MISO (GPIO 19), and MOSI (GPIO 23).
2. Power Supply Sag: Ensure the SX1276 is powered from a stable 3.3V rail. Do not power the radio from 5V.
3. Loose Jumper Wire: Reseat the NSS and SCK jumper cables on your breadboard.
Error: -1107 / -705
RADIOLIB_ERR_LORAWAN_RX_TIMEOUT / RADIOLIB_ERR_RX_TIMEOUT
Receive window timed out during OTAA Join or uplink acknowledgment.Missing DIO1 Wire. On SX1276, DIO1 signals the RxTimeout interrupt when the RX1 window closes without a packet. If DIO1 is not wired to the MCU (GPIO 27), RadioLib never detects RX1 closing, misses RX2, and fails every Join attempt. Reseat and verify the DIO1 connection.
DevNonce is too small
(Seen in TTN Live Data Console)
The Things Network rejected the JoinRequest because the device submitted an old nonce.1. Flash Wipe / Board Swap: If you erased flash or uploaded to a new ESP32 using the same DevEUI, navigate to TTN Console → End Device → Settings → Network layer → click Reset used DevNonces.
2. Ensure NVS Persistence: Verify that handleNVSJoin() is executing in your code so nonces increment across resets.
Error: -1116
RADIOLIB_ERR_LORAWAN_NO_JOIN_ACCEPT
Node transmitted JoinRequest, but received no JoinAccept within RX1 or RX2.1. RF Antenna: Confirm the 915 MHz antenna is connected.
2. Gateway Offline: Verify your Cubicore Gateway Pocket / Hub is online and claimed on TTN.
3. Cluster Mismatch: Ensure your gateway and end device reside in the exact same TTN cluster (au1).
4. Credential Mismatch: Verify DevEUI, JoinEUI, and AppKey match TTN byte-for-byte in MSB format.
First Uplink Received, but Subsequent Uplinks Silently DisappearGateway receives packet #1, but nothing appears for packets #2, #3, etc.ADR Enabled on Dual-Channel Gateway. If node.setADR(true) is set when using a Cubicore Gateway Pocket or Hub, TTN commands the node to change spreading factors (e.g., SF7). The dual-channel gateway only listens on SF10 (DR2), missing all subsequent frames. Ensure node.setADR(false); and node.setDatarate(2); are present in setup().
Compiler Error:
'class SX1276' has no member named 'setDio2AsRfSwitch' or 'setTCXO'
Code fails to compile during verification.SX1262 Code Reused on SX1276. Methods like setDio2AsRfSwitch() and setTCXO() only exist for SX1262 transceivers. Remove these method calls from your SX1276 sketch.

Next Steps​

Now that your SX1276 end node is successfully transmitting telemetry to The Things Network, explore the following guides to expand your deployment:

  • SX1276 AT Command LoRaWAN Modem: Turn your ESP32 + SX1276 into a RAK RUI3-style serial AT command modem to configure keys, spreading factors, and send packets interactively without recompiling code.
  • Integrating with Cubicore Cloud: Connect your TTN application directly to Cubicore Cloud to build live telemetry widgets, set alert thresholds, and export historical CSV datasets.
  • TTN MQTT Integration: Ingest real-time LoRaWAN telemetry into custom Python, Node-RED, or enterprise backend services using secure MQTT subscriptions.
  • Cubicore Devboard Guide Series: Explore our integrated hardware platform featuring an onboard ESP32-S3, Semtech SX1262 transceiver, Qwiic/Grove sensor ports, and automated Li-Po battery charging.