Community project
20km ESP32 Radio Network
Build a three-node LoRa mesh network capable of relaying messages across 20 kilometers using the RFM95W transceiver and ESP32. Each station combines solar charging, battery storage, and a directional antenna to create a self-powered, long-range communication system. The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for configuring relay nodes that forward packets across the network.
The firmware implements packet routing with acknowledgment handling, duplicate detection, and retry logic to ensure reliable message delivery between stations. Builders will learn how to configure each node's role, aim the directional antennas for optimal range, and test the network hop by hop to verify coverage and performance.
Wiring diagram

Gather all the parts
Assemble it in 5 steps
1. Build three identical station boxes
Make three weatherproof boxes: Station A at the first point, Relay R at the highest practical midpoint, and Station B at the second point. Put one ESP32 board, one LoRa radio, one charger, one regulator, one solar panel connection, and two matched battery cells in each box.
- Label the boxes A, R, and B before wiring so the relay is not accidentally installed at an endpoint.
- Do not mount a radio or battery where rain can reach it; water can short the electronics and damage the battery.
2. Wire the LoRa radio to the ESP32
For each box, connect RFM95W VCC to the ESP32 3V3 pin (power), GND to GND (ground), MOSI to GPIO23 (data), MISO to GPIO19 (data), SCK to GPIO18 (clock), NSS to GPIO4 (radio select), RESET to GPIO27 (reset), and DIO0 to GPIO26 (receive signal).
- Use short wires between the ESP32 and radio. The radio and ESP32 both use 3.3 V signals, so do not connect the radio to the ESP32 5 V or VIN pin.
- Make sure VCC and GND are not swapped — swapped power can permanently damage the LoRa radio.
3. Connect the solar charging chain
Connect solar-panel V+ to charger DCIN+ (charging power) and solar-panel GND to charger DCIN− (charging return). Connect two identical protected 18650 cells in parallel inside a suitable protected holder: both positive ends join the charger BATT JST+ (battery power), and both negative ends join BATT JST− (battery return). Connect charger LOAD JST+ to regulator VIN (load power) and LOAD JST− to regulator GND (load return). Connect regulator VOUT to ESP32 3V3 (power) and its GND to ESP32 GND (ground).
- Use a proper two-cell parallel holder or preassembled protected 1S2P battery pack; both cells must be the same model, age, and charge level.
- Set the solar panel where it receives direct daylight and route its cable through a sealed cable gland.
- Never join cells with different charge levels or different types — a large equalising current can overheat the cells. Never connect the solar panel directly to the battery or ESP32.
4. Fit and aim the antennas
Connect each 866 MHz directional antenna to its RFM95W antenna connection with a short low-loss coax lead. Mount the A antenna toward the relay, mount the relay antenna toward the best shared path to both ends, and mount B toward the relay. Raise all three above as much wet foliage as safely possible.
- Keep antenna coax short and place the radio box close to the antenna mast.
- Use a mast, ridge, watchtower, or other approved high point for the relay; its job is to avoid trying to force one 40 km signal through dense trees.
- Never power the RFM95W while its antenna is disconnected — transmitting without an antenna can damage the radio. Keep antenna masts away from overhead power lines.
5. Configure the three roles and test hop by hop
Power each box one at a time and use its USB serial console to enter CONFIG A at the first endpoint, CONFIG R at the midpoint box, and CONFIG B at the second endpoint. Then from A enter TO B test message. A delivered message is confirmed only when its acknowledgement returns.
- First test A to R and R to B at the planned antenna heights before attempting the full route.
- If delivery fails, raise or move the relay and antennas instead of increasing radio power.
- Use only the legal 865–867 MHz settings and allowed radiated power for the installation; local wireless rules still apply inside a forest.
Review all connections
1. Connections between "solar_panel" and "ESP32"
2. Connections between "battery" and "ESP32"
3. Connections between "charger" and "ESP32"
4. Connections between "boost_3v3" and "ESP32"
5. Connections between "lora_radio" and "ESP32"
Deploy the firmware
#include <SPI.h>
#include <LoRa.h>
#include <Preferences.h>
// ESP32 DevKit v1 to RFM95W connections.
#define LORA_SCK 18
#define LORA_MISO 19
#define LORA_MOSI 23
#define LORA_NSS 4
#define LORA_RESET 27
#define LORA_DIO0 26
// India IN865 plan: use only after confirming the channel and EIRP are permitted
// for the installation with the applicable Indian wireless authority.
const long LORA_FREQUENCY_HZ = 866500000L;
const int LORA_TX_POWER_DBM = 14;
const unsigned long ACK_TIMEOUT_MS = 3000;
const int MAX_SEND_ATTEMPTS = 3;
const int MAX_SEEN = 20;
Preferences preferences;
String nodeId;
String seenIds[MAX_SEEN];
int seenNext = 0;
String pendingPacket;
String pendingId;
unsigned long pendingAt = 0;
int pendingAttempts = 0;
unsigned long nextMessageNumber = 1;
String fieldAt(const String &packet, int wanted) {
int start = 0;
for (int field = 0; field < wanted; field++) {
int divider = packet.indexOf('|', start);
if (divider < 0) return "";
start = divider + 1;
}
int end = packet.indexOf('|', start);
return end < 0 ? packet.substring(start) : packet.substring(start, end);
}
bool alreadySeen(const String &id) {
for (int i = 0; i < MAX_SEEN; i++) {
if (seenIds[i] == id) return true;
}
return false;
}
void remember(const String &id) {
seenIds[seenNext] = id;
seenNext = (seenNext + 1) % MAX_SEEN;
}
void transmit(const String &packet) {
LoRa.idle();
LoRa.beginPacket();
LoRa.print(packet);
LoRa.endPacket();
LoRa.receive();
Serial.println("TX " + packet);
}
bool validRole(const String &role) {
return role == "A" || role == "B" || role == "R";
}
void configureRole(const String &role) {
if (!validRole(role)) {
Serial.println("Use CONFIG A, CONFIG B, or CONFIG R.");
return;
}
nodeId = role;
preferences.putString("role", nodeId);
Serial.println("This station is now " + nodeId + ". Restarting radio receive.");
}
void queueMessage(const String &destination, String text) {
if (nodeId != "A" && nodeId != "B") {
Serial.println("Only endpoint A or B may create messages. Configure this unit first.");
return;
}
if ((destination != "A" && destination != "B") || destination == nodeId) {
Serial.println("Use TO A message or TO B message, addressed to the other endpoint.");
return;
}
text.replace("|", "/");
text.trim();
if (text.length() == 0 || text.length() > 100) {
Serial.println("Message must contain 1 to 100 characters.");
return;
}
pendingId = nodeId + String(nextMessageNumber++);
pendingPacket = "M|" + pendingId + "|" + nodeId + "|" + destination + "|3|" + text;
pendingAttempts = 1;
pendingAt = millis();
remember(pendingId);
transmit(pendingPacket);
}
void forwardPacket(const String &type, const String &id, const String &from,
const String &to, int ttl, const String &text) {
if (ttl <= 0) return;
String packet = type + "|" + id + "|" + from + "|" + to + "|" + String(ttl - 1) + "|" + text;
delay(120 + random(180));
transmit(packet);
}
void handlePacket(String packet) {
String type = fieldAt(packet, 0);
String id = fieldAt(packet, 1);
String from = fieldAt(packet, 2);
String to = fieldAt(packet, 3);
int ttl = fieldAt(packet, 4).toInt();
String text = fieldAt(packet, 5);
if ((type != "M" && type != "A") || id.length() == 0 || !validRole(from) ||
!validRole(to) || ttl < 0 || ttl > 3) return;
if (alreadySeen(type + id)) return;
remember(type + id);
Serial.println("RX " + packet + " RSSI=" + String(LoRa.packetRssi()));
if (nodeId == "R") {
forwardPacket(type, id, from, to, ttl, text);
return;
}
if (to != nodeId) return;
if (type == "M") {
Serial.println("MESSAGE FROM " + from + ": " + text);
String acknowledgement = "A|" + id + "|" + nodeId + "|" + from + "|3|OK";
transmit(acknowledgement);
} else if (type == "A" && id == pendingId) {
Serial.println("DELIVERED: message " + id + " was acknowledged by " + from);
pendingPacket = "";
pendingId = "";
pendingAttempts = 0;
}
}
void handleSerialLine(String line) {
line.trim();
if (line.startsWith("CONFIG ")) {
String role = line.substring(7);
role.trim();
role.toUpperCase();
configureRole(role);
} else if (line.startsWith("TO ")) {
int space = line.indexOf(' ', 3);
if (space < 0) {
Serial.println("Example: TO B Trail gate closed");
return;
}
String destination = line.substring(3, space);
destination.toUpperCase();
queueMessage(destination, line.substring(space + 1));
} else if (line == "STATUS") {
Serial.println("ROLE=" + (nodeId.length() ? nodeId : "UNSET") +
" RSSI=" + String(LoRa.packetRssi()));
} else if (line.length()) {
Serial.println("Commands: CONFIG A, CONFIG B, CONFIG R, TO A/B message, STATUS");
}
}
void setup() {
Serial.begin(115200);
delay(300);
randomSeed((uint32_t)esp_random());
preferences.begin("forestlora", false);
nodeId = preferences.getString("role", "");
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_NSS);
LoRa.setPins(LORA_NSS, LORA_RESET, LORA_DIO0);
if (!LoRa.begin(LORA_FREQUENCY_HZ)) {
Serial.println("LoRa radio was not found. Check 3.3 V, ground, and the five signal wires.");
while (true) delay(1000);
}
LoRa.setTxPower(LORA_TX_POWER_DBM, PA_OUTPUT_PA_BOOST_PIN);
LoRa.setSpreadingFactor(10);
LoRa.setSignalBandwidth(125E3);
LoRa.setCodingRate4(5);
LoRa.setPreambleLength(12);
LoRa.enableCrc();
LoRa.receive();
Serial.println("Forest relay network ready. Stored role: " + (nodeId.length() ? nodeId : "UNSET"));
Serial.println("Configure the three boards once: CONFIG A, CONFIG B, CONFIG R.");
}
void loop() {
while (Serial.available()) {
String line = Serial.readStringUntil('\n');
handleSerialLine(line);
}
int packetSize = LoRa.parsePacket();
if (packetSize) {
String packet;
while (LoRa.available()) packet += (char)LoRa.read();
handlePacket(packet);
}
if (pendingPacket.length() && millis() - pendingAt >= ACK_TIMEOUT_MS) {
if (pendingAttempts < MAX_SEND_ATTEMPTS) {
pendingAttempts++;
pendingAt = millis();
Serial.println("No acknowledgement yet; retrying " + pendingId);
transmit(pendingPacket);
} else {
Serial.println("NOT DELIVERED: no acknowledgement after three sends.");
pendingPacket = "";
pendingId = "";
}
}
delay(5);
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




