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.
| Parameter | Typical values | Effect of increasing it |
|---|---|---|
| Spreading factor (SF) | 7 to 12 | Longer range and better sensitivity, but each packet takes much longer to send |
| Bandwidth (BW) | 125, 250, 500 kHz | Faster transmission, but lower sensitivity and shorter range |
| Coding rate (CR) | 4/5 to 4/8 | More error correction, more robustness, longer airtime |
| TX power | 2 to 20 dBm on PA_BOOST | Longer range, more current; limited by local regulations |
| Sync word | One byte, e.g. 0xF3 | Not 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 pin | ESP32 pin | Function |
|---|---|---|
| VCC | 3.3V | Never 5 V |
| GND | GND | Ground |
| SCK | GPIO 18 | SPI clock (VSPI) |
| MISO | GPIO 19 | SPI data out of module |
| MOSI | GPIO 23 | SPI data into module |
| NSS | GPIO 5 | Chip select |
| RESET | GPIO 14 | Module reset |
| DIO0 | GPIO 26 | Interrupt: 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.
#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);
}
#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.