LoRa Point-to-Point with ESP32 and SX1276: Sender, Receiver and ACK

Wi-Fi is excellent inside a building but rarely reaches the far end of a farm, a borewell pump house or a water tank on another block. LoRa (short for Long Range) is a radio modulation from Semtech that trades data rate for range and robustness. With simple antennas it can cover distances that Wi-Fi cannot, while using little power.

In this tutorial you will connect two ESP32 boards to SX1276-based modules such as the HopeRF RFM95W, configure the radio parameters, and build a sender that transmits sensor packets and waits for an acknowledgement, plus a receiver that prints signal quality and replies. This is raw point-to-point LoRa, not LoRaWAN, so no gateway or network server is required. The receiver can later forward data to MQTT as a gateway.

LoRa Parameters That Matter

LoRa uses chirp spread spectrum: each symbol is a frequency sweep across the channel bandwidth. Three settings control the trade-off between range and speed, and both radios must use identical values or they will not hear each other.

ParameterTypical valuesEffect of increasing it
Spreading factor (SF)7 to 12Longer range and better sensitivity, but each packet takes much longer to send
Bandwidth (BW)125, 250, 500 kHzFaster transmission, but lower sensitivity and shorter range
Coding rate (CR)4/5 to 4/8More error correction, more robustness, longer airtime
TX power2 to 20 dBm on PA_BOOSTLonger range, more current; limited by local regulations
Sync wordOne byte, e.g. 0xF3Not a range setting; radios ignore packets with a different sync word
Symbol time:  Ts = 2^SF / BW

SF7,  BW 125 kHz:  Ts = 128 / 125000  = 1.024 ms
SF12, BW 125 kHz:  Ts = 4096 / 125000 = 32.768 ms

Each step up in SF doubles the symbol time, so SF12 symbols
are 32 times longer than SF7 symbols at the same bandwidth.

Longer packets occupy the channel longer and use more battery, so start with SF7 and raise it only if the link is unreliable. Semtech provides a LoRa airtime calculator if you need exact packet durations.

Hardware and Wiring

The SX1276 covers the sub-GHz bands, but each module is tuned for one band by its matching network. RFM95W modules are sold for the 868 and 915 MHz bands; Ai-Thinker Ra-02 modules use the SX1278 for 433 MHz. In India the de-licensed band commonly used for LoRa is 865 to 867 MHz, so buy a module tuned for 868 MHz and check the current WPC rules for power limits.

SX1276 module pinESP32 pinFunction
VCC3.3VNever 5 V
GNDGNDGround
SCKGPIO 18SPI clock (VSPI)
MISOGPIO 19SPI data out of module
MOSIGPIO 23SPI data into module
NSSGPIO 5Chip select
RESETGPIO 14Module reset
DIO0GPIO 26Interrupt: TX done / RX done

Code

Install the LoRa library by Sandeep Mistry from the Library Manager. Upload the sender to one board and the receiver to the other. The shared settings are in a function so both sketches stay identical.

Sender: transmits a counter and reading, waits up to 1 s for an ACK
#include <SPI.h>
#include <LoRa.h>

const int PIN_NSS = 5, PIN_RST = 14, PIN_DIO0 = 26;
uint32_t counter = 0;

void radioConfig() {               // MUST match the receiver exactly
  LoRa.setSpreadingFactor(9);
  LoRa.setSignalBandwidth(125E3);
  LoRa.setCodingRate4(5);
  LoRa.setSyncWord(0xF3);
  LoRa.setTxPower(14);             // dBm, PA_BOOST output
  LoRa.enableCrc();                // drop corrupted packets
}

void setup() {
  Serial.begin(115200);
  LoRa.setPins(PIN_NSS, PIN_RST, PIN_DIO0);
  if (!LoRa.begin(866E6)) {        // frequency in Hz
    Serial.println("LoRa init failed, check wiring");
    while (true) delay(1000);
  }
  radioConfig();
  Serial.println("Sender ready");
}

bool waitForAck(uint32_t id, uint32_t timeoutMs) {
  uint32_t start = millis();
  while (millis() - start < timeoutMs) {
    if (LoRa.parsePacket()) {      // returns packet size, 0 if none
      String reply;
      while (LoRa.available()) reply += (char)LoRa.read();
      if (reply == "ACK:" + String(id)) return true;
    }
  }
  return false;
}

void loop() {
  float tankLevel = analogRead(34) * 100.0 / 4095;  // demo sensor, percent
  String msg = "N1," + String(counter) + "," + String(tankLevel, 1);

  bool ok = false;
  for (int attempt = 1; attempt <= 3 && !ok; attempt++) {
    LoRa.beginPacket();
    LoRa.print(msg);
    LoRa.endPacket();              // blocks until transmission ends
    ok = waitForAck(counter, 1000);
    Serial.printf("Sent %s, attempt %d, %s", msg.c_str(), attempt, ok ? "ACK" : "no ACK");
    Serial.println();
  }
  counter++;
  delay(10000);
}
Receiver: prints each packet with RSSI and SNR, replies with ACK
#include <SPI.h>
#include <LoRa.h>

const int PIN_NSS = 5, PIN_RST = 14, PIN_DIO0 = 26;

void radioConfig() {               // identical to the sender
  LoRa.setSpreadingFactor(9);
  LoRa.setSignalBandwidth(125E3);
  LoRa.setCodingRate4(5);
  LoRa.setSyncWord(0xF3);
  LoRa.setTxPower(14);
  LoRa.enableCrc();
}

void setup() {
  Serial.begin(115200);
  LoRa.setPins(PIN_NSS, PIN_RST, PIN_DIO0);
  if (!LoRa.begin(866E6)) {
    Serial.println("LoRa init failed");
    while (true) delay(1000);
  }
  radioConfig();
  Serial.println("Receiver ready");
}

void loop() {
  int size = LoRa.parsePacket();
  if (size == 0) return;

  String msg;
  while (LoRa.available()) msg += (char)LoRa.read();
  int rssi = LoRa.packetRssi();    // dBm, closer to 0 is stronger
  float snr = LoRa.packetSnr();    // dB, can be negative for LoRa

  // Format: N1,<counter>,<value>
  int c1 = msg.indexOf(','), c2 = msg.indexOf(',', c1 + 1);
  if (c1 < 0 || c2 < 0) return;   // not our format
  String id = msg.substring(c1 + 1, c2);

  delay(50);                       // give the sender time to switch to RX
  LoRa.beginPacket();
  LoRa.print("ACK:" + id);
  LoRa.endPacket();

  Serial.printf("%s  RSSI %d dBm  SNR %.1f dB", msg.c_str(), rssi, snr);
  Serial.println();
}
N1,41,63.2  RSSI -97 dBm  SNR 7.5 dB
N1,42,63.1  RSSI -101 dBm  SNR 4.0 dB

Understanding the Results

  • RSSI is received signal strength. SNR is how far the signal is above the noise. LoRa can decode packets with a negative SNR, and the lowest workable SNR gets lower as SF increases. If SNR sits near that limit, increase SF or improve the antennas.
  • ACK with retries gives reliable delivery over an unreliable link. The counter in the ACK makes sure an old ACK is not mistaken for a new one.
  • After endPacket() the library returns the radio to standby; parsePacket() puts it back in receive mode, which is why the sender can listen for the ACK immediately.
  • CRC enabled means corrupted packets are discarded by the radio instead of reaching your code.

Troubleshooting

  • LoRa init failed: wrong NSS or RESET pin, loose SPI wire, or 5 V on VCC damaged the module.
  • Init works but nothing received: frequency, SF, bandwidth, coding rate or sync word differ, or one module is a 433 MHz type and the other 868 MHz.
  • Works on the bench, fails at 200 m: antennas are poor or blocked. Raise them, keep them vertical and away from metal, then increase SF.
  • Packets received but no ACK at the sender: the receiver replies before the sender has switched to receive; the short delay before the ACK helps.
  • Transmitting very often: many regions limit duty cycle or airtime in these bands. Keep messages short and infrequent.

Summary

Two ESP32 boards with SX1276 modules form a long-range link once frequency, spreading factor, bandwidth, coding rate and sync word match. Higher SF buys range at the cost of airtime, RSSI and SNR tell you how much margin you have, and a simple counter-based ACK with retries makes delivery reliable. Add Wi-Fi and MQTT code to the receiver and it becomes a single-channel gateway for your remote sensors.