If you are searching for an ESP32 LoRa gateway, you need to clear up a common misconception immediately: an ESP32 cannot natively drive the multi-channel concentrator chips (like the SX1301 or SX1302) required for a true, TTN-compliant LoRaWAN gateway. What you are actually building is a private LoRa-to-MQTT bridge (sometimes called a single-channel gateway or proprietary gateway). This setup listens for LoRa packets from remote sensor nodes and forwards them via WiFi to an MQTT broker like Mosquitto, Node-RED, or Home Assistant.

This guide walks through building a robust, private LoRa gateway targeting the Heltec WiFi LoRa 32 V3 (ESP32-S3 + SX1262). We will cover the exact hardware pinouts, provide complete, compilable C++ firmware with WiFi reconnection logic, and break down the specific RadioLib error codes that halt most first-time builds.

The "ESP32 LoRa Gateway" Reality Check

Before wiring anything, it is critical to understand where an ESP32-based gateway fits into the LoRa ecosystem. Hobbyists often buy an ESP32 LoRa board expecting it to replace a $150 RAKwireless concentrator. It will not. Here is how the architectures compare in 2026:

Gateway Type Core Hardware Channels / Duty Cycle Network Compatibility Typical Cost
True LoRaWAN Concentrator Raspberry Pi + SX1302/SX1303 8 to 16 channels, 100% TTN, Helium, ChirpStack (Full compliance) $120 - $250
Single-Channel Packet Forwarder ESP32 + SX1276/SX1262 1 channel, restricted Deprecated by TTN; private servers only $25 - $40
Private LoRa-to-MQTT Bridge (This Guide) ESP32-S3 + SX1262 1 channel, user-defined Proprietary payloads, Home Assistant, Node-RED $25 - $35
LoRaWAN End Node ESP32 + SX1262 (Running MAC) 1 channel (transmit only) Connects to Concentrators, not a gateway itself $20 - $30

By building the Private LoRa-to-MQTT Bridge, you bypass the strict duty-cycle and join-request overhead of LoRaWAN. You control the payload format, the encryption, and the polling rate, making it ideal for private agricultural sensors, off-grid weather stations, or localized asset tracking.

Hardware BOM and Internal Pin Mapping

The code in this guide specifically targets the Heltec WiFi LoRa 32 V3. This board pairs the dual-core ESP32-S3 with the Semtech SX1262 transceiver. Do not use this code on the older V2 boards (which use the SX1278) without modifying the pin definitions and swapping to the SX1278 class.

Parts List:
  • MCU/LoRa Board: Heltec WiFi LoRa 32 V3 (Select 868MHz for EU/UK/AU or 915MHz for US/Americas).
  • Antenna: OEM spring antenna or a tuned 868/915MHz SMA whip. Never power the board without the antenna attached; the SX1262 will blow its internal RF front-end.
  • Power: 5V 2A USB-C power supply (the ESP32-S3 WiFi radio draws sharp 350mA transient spikes).
  • MQTT Broker: Mosquitto running on a local Raspberry Pi, or a cloud broker like HiveMQ.

The SX1262 on the Heltec V3 is wired to the ESP32-S3's internal SPI bus. You do not need to jumper any wires on the breadboard; you only need to define these internal GPIO pins in your firmware.

SX1262 Function ESP32-S3 GPIO (Heltec V3) Notes
NSS (Chip Select) GPIO 8 Active LOW
DIO1 (Interrupt) GPIO 14 Triggers on RX/TX done
NRST (Reset) GPIO 12 Active LOW
BUSY GPIO 13 SX1262 specific; HIGH when processing
MOSI GPIO 10 Internal SPI
MISO GPIO 11 Internal SPI
SCK GPIO 9 Internal SPI

The Firmware: LoRa-to-MQTT Bridge Code

This firmware uses RadioLib (the modern standard for sub-GHz radios) and PubSubClient for MQTT. Install both via the Arduino Library Manager before compiling. Ensure your Arduino IDE board manager is set to esp32 by Espressif Systems (v2.0.14 or newer) and select Heltec WiFi LoRa 32(V3) from the boards menu.

#include <RadioLib.h>
#include <WiFi.h>
#include <PubSubClient.h>

// --- Heltec WiFi LoRa 32 V3 Pin Definitions ---
#define LORA_NSS    8
#define LORA_DIO1   14
#define LORA_NRST   12
#define LORA_BUSY   13

// Initialize SX1262 instance
SX1262 radio = new Module(LORA_NSS, LORA_DIO1, LORA_NRST, LORA_BUSY);

// --- Network Credentials ---
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* mqtt_server = "192.168.1.50"; // Local Mosquitto IP
const int mqtt_port = 1883;
const char* mqtt_topic = "lora/gateway/payload";

WiFiClient espClient;
PubSubClient mqttClient(espClient);

// Interrupt flag
volatile bool receivedFlag = false;

// Interrupt Service Routine (ISR)
void IRAM_ATTR setFlag(void) {
  receivedFlag = true;
}

void setupWiFi() {
  delay(10);
  Serial.print("Connecting to WiFi");
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected. IP:");
  Serial.println(WiFi.localIP());
}

void reconnectMQTT() {
  while (!mqttClient.connected()) {
    Serial.print("Attempting MQTT connection...");
    String clientId = "ESP32-LoRa-GW-";
    clientId += String(random(0xffff), HEX);
    if (mqttClient.connect(clientId.c_str())) {
      Serial.println("connected");
    } else {
      Serial.print("failed, rc=");
      Serial.print(mqttClient.state());
      Serial.println(" retry in 5 seconds");
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  delay(2000); // Wait for serial monitor
  
  setupWiFi();
  mqttClient.setServer(mqtt_server, mqtt_port);

  Serial.println("[SX1262] Initializing...");
  // Parameters: Freq (MHz), BW (kHz), SF, CR, SyncWord, Power (dBm), Preamble, TCXO Voltage
  // Adjust 915.0 to 868.0 if in Europe/UK
  int state = radio.begin(915.0, 125.0, 7, 5, 0x18, 10, 8, 1.6, false);
  
  if (state == RADIOLIB_ERR_NONE) {
    Serial.println("[SX1262] Init success!");
  } else {
    Serial.printf("[SX1262] init failed, code %d\n", state);
    while (true); // Halt execution
  }

  // Set DIO1 interrupt action
  radio.setDio1Action(setFlag);
  
  // Put radio into continuous receive mode
  radio.startReceive();
}

void loop() {
  if (!mqttClient.connected()) {
    reconnectMQTT();
  }
  mqttClient.loop();

  if (receivedFlag) {
    receivedFlag = false; // Reset flag immediately
    
    String strData;
    int state = radio.readData(strData);

    if (state == RADIOLIB_ERR_NONE) {
      Serial.printf("[LoRa] Received: %s (RSSI: %.1f dBm, SNR: %.1f dB)\n", 
                    strData.c_str(), radio.getRSSI(), radio.getSNR());
      
      // Publish to MQTT
      if (mqttClient.connected()) {
        mqttClient.publish(mqtt_topic, strData.c_str());
      }
    } else if (state == RADIOLIB_ERR_CRC_MISMATCH) {
      Serial.println("[LoRa] CRC Mismatch - corrupted packet dropped.");
    } else {
      Serial.printf("[LoRa] Read failed, code %d\n", state);
    }

    // Return to receive mode
    radio.startReceive();
  }
}

Debugging: Exact Error Strings and the First 3 Checks

Sub-GHz RF debugging is notoriously frustrating because failures are silent. When the serial monitor halts or payloads drop, use this decision tree.

Quoted Error: [SX1262] init failed, code -2

Error -2 is RADIOLIB_ERR_CHIP_NOT_FOUND. The ESP32-S3 cannot communicate with the SX1262 over SPI. Ranked causes:

  1. Wrong Board Selected in IDE: You selected "ESP32 Dev Module" instead of "Heltec WiFi LoRa 32(V3)". The standard ESP32 maps SPI to entirely different GPIOs. Switch the board and re-upload.
  2. RadioLib Version Mismatch: You are using an outdated fork of RadioLib. Ensure you are on v6.x or newer via the Library Manager.
  3. Dead SX1262 Module: If you previously ran transmit code without an antenna attached, the RF PA (Power Amplifier) fried the silicon. The board must be replaced.

Quoted Error: [SX1262] init failed, code -707

Error -707 is RADIOLIB_ERR_SPI_CMD_FAILED. The chip is found, but it rejected the configuration command. This almost always means your TCXO voltage parameter in radio.begin() is wrong. The Heltec V3 requires 1.6V for its internal TCXO. If you pass 0 or 3.3, the chip refuses to initialize the PLL.

The First 3 Things to Check When Payloads Drop

If the code compiles, connects to MQTT, but you aren't seeing sensor data in Node-RED:

  1. SyncWord Mismatch: In the code above, the SyncWord is 0x18. If your remote sensor nodes are using the default LoRaWAN SyncWord (0x34), the gateway hardware will silently filter the packets out before the ESP32 ever sees them. Ensure both ends use 0x18 for private networks.
  2. Frequency Drift / Region: A US 915MHz node will not be heard by an EU 868MHz gateway, even if they are sitting on the same desk. Verify the physical antenna tuning matches the radio.begin() frequency parameter.
  3. MQTT Keep-Alive Timeout: If your gateway sits idle for 10 minutes and then drops packets, your router is likely killing the TCP socket. Add mqttClient.setKeepAlive(60); in your setup function to force ping packets.

Scaling: Simplifying or Extending the Build

Depending on your deployment environment, you may need to alter this baseline architecture.

Simplify: Drop WiFi for ESP-NOW

If you don't have WiFi coverage at the gateway site (e.g., a remote barn), strip out PubSubClient and use ESP-NOW. The ESP32-S3 can receive the LoRa packet and instantly bridge it via ESP-NOW to a base station inside your house, bypassing the need for a local router entirely.

Extend: Dual-Band Simultaneous RX

Need to listen to 868MHz and 433MHz simultaneously? The ESP32-S3 has two SPI buses. Wire a second SX1262 module to the HSPI pins (MOSI=35, MISO=37, SCK=36) and instantiate a second SX1262 object in RadioLib. You can poll both DIO1 interrupts in the main loop without dropping packets.

Building a private ESP32 LoRa gateway gives you total control over your sensor network's payload structure and polling intervals. By targeting the Heltec V3 and utilizing RadioLib's robust error handling, you avoid the fragile SPI timing issues that plague older SX1276 implementations. Always verify your antenna VSWR before transmitting, and ensure your MQTT broker is configured to handle the high-frequency transient reconnects typical of ESP32 WiFi radios.