In this tutorial, we will use a Heltec V3 module to geolocate a MeshCore repeater.
The principle relies on measuring RSSI (Received Signal Strength Indicator). Our module analyzes the strength of the radio signal emitted by the repeater each time a packet is received.
The higher the RSSI, the closer the module is to the repeater. By taking multiple measurements while moving around, it is possible to estimate the physical location using a directional search method by following the strongest signal.
As a reminder, RSSI is expressed in dBm (decibel-milliwatts). For an introduction to this concept, please refer to this article.
Identification of the repeater to be geolocated
Identifying the repeater to be geolocated relies on its public key (its ID). You can retrieve this key using tools such as MeshCore Analyser, which maps the network’s repeaters.

The program below simply checks the first two bytes of the target repeater’s public key. This approach is currently sufficient for the Paris region, where the number of repeaters remains limited. However, for more robust identification, it is possible to use a larger number of bytes from this key.
Hardware
I used a Heltec V3 module equipped with a Moxon antenna. The advantage of this antenna is that it is compact, lightweight, and relatively directional.

Program
To make this tutorial accessible to the widest possible audience, I used the Arduino IDE.
/**
* @file rssi-meshcore-repeater.ino
* @brief MeshCore repeater geolocation
*
* @details Displays the RSSI (signal level) and the time elapsed since the
* last received packet from a specific MeshCore repeater,
* on a Heltec WiFi LoRa 32 V3.
*
* @author Tutoduino
* @see https://tutoduino.fr/
*/
#include <SPI.h>
#include <RadioLib.h>
#include <Adafruit_SSD1306.h>
// =====================================================================
// 1) PINOUT — Heltec WiFi LoRa 32 V3 (ESP32-S3 + SX1262)
// =====================================================================
#define LORA_CS_PIN 8
#define LORA_SCK_PIN 9
#define LORA_MOSI_PIN 10
#define LORA_MISO_PIN 11
#define LORA_RST_PIN 12
#define LORA_BUSY_PIN 13
#define LORA_DIO1_PIN 14
#define OLED_SDA_PIN 17
#define OLED_SCL_PIN 18
#define OLED_RST_PIN 21
#define VEXT_PIN 36
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define BUTTON_PIN 0 // "PRG" button on the Heltec V3, used here to trigger a ping
// =====================================================================
// 2) RADIO PARAMETERS — Meshcore Ile de France
// =====================================================================
#define LORA_FREQ_MHZ 869.618
#define LORA_BW_KHZ 62.5
#define LORA_SF 8
#define LORA_CR 8
#define LORA_TX_POWER 14
// !! TO ADAPT: public key prefix of the targeted MeshCore repeater.
const uint8_t TARGET_PUBKEY_PREFIX[] = { 0xC6, 0xF1 };
#define TARGET_PUBKEY_PREFIX_LEN (sizeof(TARGET_PUBKEY_PREFIX))
// =====================================================================
// 3) MESHCORE PACKET FORMAT (official doc meshcore-dev/MeshCore)
// header (1 byte) = VV PPPP RR (version / payload type / route type)
// =====================================================================
#define ROUTE_TYPE_TRANSPORT_FLOOD 0x00
#define ROUTE_TYPE_FLOOD 0x01
#define ROUTE_TYPE_DIRECT 0x02
#define ROUTE_TYPE_TRANSPORT_DIRECT 0x03
#define PAYLOAD_TYPE_ADVERT 0x04
#define PAYLOAD_TYPE_TRACE 0x09 // "trace a path" : MeshCore's native ping
#define MAX_PACKET_LEN 256
#define ADVERT_PUBKEY_LEN 32 // an advert contains the full public key (32 bytes)
// =====================================================================
// 4) GLOBAL APPLICATION STATE
// =====================================================================
// We group everything related to "the last packet seen from the target
// repeater" into a single structure : easier to read than scattered
// global variables.
struct RepeaterStatus {
bool hasPacket = false; // have we already received a packet from the target repeater?
unsigned long lastSeenMs = 0;
float rssi = 0;
float snr = 0;
};
RepeaterStatus targetStatus;
// Tag of the last TRACE request sent, and the time it was sent: lets us
// recognize the reply (which echoes back this same tag) without having to
// decode a hash, since the TRACE payload format differs from other messages.
uint32_t lastSentTag = 0;
unsigned long lastPingMs = 0;
#define TRACE_REPLY_TIMEOUT_MS 10000UL // only accept a reply within 10s of the ping
SX1262 lora = new Module(LORA_CS_PIN, LORA_DIO1_PIN, LORA_RST_PIN, LORA_BUSY_PIN);
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
// This flag is set to "true" by the radio interrupt as soon as a packet
// arrives. It is then handled calmly inside loop(), never inside the
// interrupt itself (basic rule with RadioLib / ESP32).
volatile bool packetReceived = false;
void onPacketReceivedISR() {
packetReceived = true;
}
// =====================================================================
// 5) OLED DISPLAY
// =====================================================================
void showMessage(const char *ligne1, const char *ligne2 = "") {
display.clearDisplay();
display.setCursor(0, 0);
display.setTextSize(1);
display.println(ligne1);
display.println(ligne2);
display.display();
}
void drawStatusScreen() {
display.clearDisplay();
if (!targetStatus.hasPacket) {
showMessage("En attente de", "paquets MeshCore...");
return;
}
// --- Title: identifier of the monitored repeater ---
display.setTextSize(1);
display.setCursor(0, 0);
display.printf("REPETEUR %02X%02X", TARGET_PUBKEY_PREFIX[0], TARGET_PUBKEY_PREFIX[1]);
// --- RSSI in large text, horizontally centered ---
display.setTextSize(3);
char rssiStr[10];
snprintf(rssiStr, sizeof(rssiStr), "%.0f", targetStatus.rssi);
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds(rssiStr, 0, 0, &x1, &y1, &w, &h);
int16_t xCentre = (SCREEN_WIDTH - w) / 2;
display.setCursor(xCentre, 26);
display.print(rssiStr);
display.setTextSize(1);
display.print(" dBm");
// --- SNR and time elapsed since the last packet ---
unsigned long secondesEcoulees = (millis() - targetStatus.lastSeenMs) / 1000;
display.setCursor(0, 54);
display.printf("SNR:%.1fdB", targetStatus.snr);
display.setCursor(90, 54);
display.printf("%lus", secondesEcoulees);
display.display();
}
// =====================================================================
// 6) HARDWARE INITIALIZATION
// =====================================================================
void initOled() {
// Vext power supply (needed for the OLED on the Heltec V3, active low)
pinMode(VEXT_PIN, OUTPUT);
digitalWrite(VEXT_PIN, LOW);
delay(150);
// Hardware reset of the screen, before any I2C communication
pinMode(OLED_RST_PIN, OUTPUT);
digitalWrite(OLED_RST_PIN, HIGH);
delay(10);
digitalWrite(OLED_RST_PIN, LOW);
delay(20);
digitalWrite(OLED_RST_PIN, HIGH);
delay(20);
Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
Wire.setClock(100000);
Serial.println(F("Initialisation OLED..."));
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("Echec init SSD1306"));
while (true) {} // stop here: no point continuing without a screen
}
display.setTextColor(SSD1306_WHITE);
display.dim(false);
display.clearDisplay();
display.display();
delay(100);
}
void initRadio() {
Serial.println(F("Initialisation LoRa..."));
SPI.begin(LORA_SCK_PIN, LORA_MISO_PIN, LORA_MOSI_PIN, LORA_CS_PIN);
int state = lora.begin(LORA_FREQ_MHZ, LORA_BW_KHZ, LORA_SF, LORA_CR);
if (state != RADIOLIB_ERR_NONE) {
Serial.print(F("Erreur LoRa : "));
Serial.println(state);
showMessage("Erreur LoRa", String(state).c_str());
while (true) {}
}
lora.setOutputPower(LORA_TX_POWER);
lora.setDio1Action(onPacketReceivedISR);
state = lora.startReceive();
if (state != RADIOLIB_ERR_NONE) {
Serial.print(F("Erreur startReceive : "));
Serial.println(state);
}
}
// =====================================================================
// 7) SENDING A "PING" (TRACE packet) TO THE TARGET REPEATER
// =====================================================================
// MeshCore has a native feature for this: the TRACE payload (0x09),
// designed to trace a path and collect the SNR of each hop. With a
// single node in the list, it's the MeshCore equivalent of a ping.
// Format confirmed by the official documentation (MeshCore wiki,
// "Companion Radio Protocol"): [tag 4b][auth_code 4b][flags 1b][list of
// hashes to trace].
//
// Here we target a single repeater, in "zero-hop" mode (path_len=0 at
// the packet level): the packet is sent once, without being relayed
// any further.
void sendTracePing() {
uint8_t buf[16];
size_t offset = 0;
// --- Packet header: TRACE payload + DIRECT route ---
buf[offset++] = (PAYLOAD_TYPE_TRACE << 2) | ROUTE_TYPE_DIRECT;
// --- Packet-level path_length: 0 = zero-hop ---
buf[offset++] = 0x00;
// (no path bytes follow, since there are no hops)
// --- TRACE payload ---
uint32_t tag = esp_random(); // random identifier for this request
memcpy(buf + offset, &tag, sizeof(tag));
offset += sizeof(tag);
lastSentTag = tag; // remember this tag to recognize the reply
lastPingMs = millis();
uint32_t authCode = 0; // no particular authentication code
memcpy(buf + offset, &authCode, sizeof(authCode));
offset += sizeof(authCode);
buf[offset++] = 0x00; // flags, reserved for now
// List of nodes to trace: a single one, the target repeater
buf[offset++] = TARGET_PUBKEY_PREFIX[0];
Serial.printf("Envoi TRACE (tag=%08lX) vers REPETEUR %02X...\n",
(unsigned long)tag, TARGET_PUBKEY_PREFIX[0]);
int state = lora.transmit(buf, offset);
if (state != RADIOLIB_ERR_NONE) {
Serial.print(F("Erreur d'emission : "));
Serial.println(state);
}
// transmit() also triggers a DIO1 "end of transmission" interrupt,
// which may have wrongly set packetReceived: we clear it before
// going back to listening, so we don't treat nothing as a received
// packet.
packetReceived = false;
lora.startReceive();
}
// =====================================================================
// 8) DECODING THE MESHCORE PACKET
// =====================================================================
// This function looks for the identifier (public key hash) of the LAST
// node that relayed the packet, and compares it to the prefix of the
// repeater we are monitoring. It returns true if it matches.
//
// Reminder of the MeshCore packet format:
// [header 1 byte] [transport_codes 0 or 4 bytes] [path_length 1 byte]
// [path 0-64 bytes] [payload 0-184 bytes]
bool packetComesFromTarget(const uint8_t *packet, size_t len) {
if (len < 2) return false;
uint8_t header = packet[0];
uint8_t routeType = header & 0x03;
uint8_t payloadType = (header >> 2) & 0x0F;
size_t offset = 1;
// "Transport" modes add 4 bytes of transport code
if (routeType == ROUTE_TYPE_TRANSPORT_FLOOD || routeType == ROUTE_TYPE_TRANSPORT_DIRECT) {
offset += 4;
}
if (offset >= len) return false;
// path_length byte: bits 0-5 = hop count, bits 6-7 = (hash size - 1)
uint8_t pathLengthByte = packet[offset];
uint8_t hopCount = pathLengthByte & 0x3F;
uint8_t hashSize = ((pathLengthByte >> 6) & 0x03) + 1;
offset += 1;
const uint8_t *pathBytes = packet + offset;
offset += (size_t)hopCount * hashSize;
if (offset > len) return false; // inconsistent packet, ignore it
const uint8_t *payload = packet + offset;
size_t payloadLen = len - offset;
// Special case: a TRACE packet does NOT contain a hash at the start of
// the payload (unlike REQ/RESPONSE/TXT messages). It starts with a
// random 4-byte tag instead. So it can't be recognized by hash: we
// compare its tag to the one from our last ping sent.
if (payloadType == PAYLOAD_TYPE_TRACE) {
if (payloadLen < 4 || lastSentTag == 0) return false;
if (millis() - lastPingMs > TRACE_REPLY_TIMEOUT_MS) return false;
uint32_t receivedTag;
memcpy(&receivedTag, payload, sizeof(receivedTag));
return receivedTag == lastSentTag;
}
// Identifier of the last "hopper" to compare against the target repeater
const uint8_t *lastHopId = nullptr;
size_t lastHopIdLen = hashSize;
if (hopCount >= 1) {
// Normal case: the last hash in the path is the last repeater traversed
lastHopId = pathBytes + (size_t)(hopCount - 1) * hashSize;
} else if (payloadType == PAYLOAD_TYPE_ADVERT && payloadLen >= ADVERT_PUBKEY_LEN) {
// Advert with no hop: the sender's full public key is at the start of the payload
lastHopId = payload;
lastHopIdLen = ADVERT_PUBKEY_LEN;
} else if (payloadLen >= 1) {
// Direct packet with no hop: the first byte of the payload is the source hash
lastHopId = payload;
lastHopIdLen = 1;
}
if (lastHopId == nullptr) return false;
size_t compareLen = min(lastHopIdLen, (size_t)TARGET_PUBKEY_PREFIX_LEN);
return memcmp(lastHopId, TARGET_PUBKEY_PREFIX, compareLen) == 0;
}
// =====================================================================
// 9) HANDLING A RECEIVED PACKET
// =====================================================================
void handleIncomingPacket() {
uint8_t buf[MAX_PACKET_LEN];
size_t len = lora.getPacketLength();
if (len == 0 || len > sizeof(buf)) {
lora.startReceive();
return;
}
int state = lora.readData(buf, len);
float rssi = lora.getRSSI();
float snr = lora.getSNR();
// Ignore unreadable packets OR packets with an invalid CRC: a corrupted
// packet must never be interpreted as coming from the target repeater
// (risk of false detection).
if (state != RADIOLIB_ERR_NONE) {
if (state != RADIOLIB_ERR_CRC_MISMATCH) {
Serial.print(F("Erreur de reception : "));
Serial.println(state);
}
lora.startReceive();
return;
}
bool isTarget = packetComesFromTarget(buf, len);
Serial.printf("Paquet recu : len=%u RSSI=%.1f dBm SNR=%.1f dB %s\n",
(unsigned)len, rssi, snr, isTarget ? "[REPETEUR CIBLE]" : "");
if (isTarget) {
targetStatus.hasPacket = true;
targetStatus.lastSeenMs = millis();
targetStatus.rssi = rssi;
targetStatus.snr = snr;
drawStatusScreen(); // update the screen immediately
}
lora.startReceive();
}
// =====================================================================
// 10) SETUP / LOOP
// =====================================================================
void setup() {
Serial.begin(115200);
delay(200);
pinMode(BUTTON_PIN, INPUT_PULLUP); // PRG button, pulled to ground when pressed
initOled();
showMessage("Initialisation...");
initRadio();
Serial.println(F("En attente de paquets MeshCore..."));
showMessage("En attente de", "paquets MeshCore...");
}
void loop() {
// Refresh the screen once per second (to update the "elapsed time" counter)
static unsigned long lastDisplayUpdate = 0;
if (millis() - lastDisplayUpdate >= 1000) {
lastDisplayUpdate = millis();
drawStatusScreen();
}
// PRG button press: force a TRACE ping to the target repeater, instead
// of passively waiting for its next packet.
static unsigned long lastButtonPress = 0;
if (digitalRead(BUTTON_PIN) == LOW && millis() - lastButtonPress > 500) {
lastButtonPress = millis();
sendTracePing();
}
if (packetReceived) {
packetReceived = false;
handleIncomingPacket();
}
}Force the repeater to transmit a message.
The RSSI of the last packet received from the repeater is displayed directly on the Heltec V3 module’s screen. However, passively waiting for a repeater to spontaneously transmit a packet can take a long time—especially when multiple measurements are needed to locate it—potentially making the geolocation process too slow.
To speed up this process, pressing the PRG button on the Heltec V3 module triggers the transmission of a TRACE packet directly to the repeater (the MeshCore equivalent of a ping). The repeater responds immediately, and the Heltec V3 module—which is constantly listening—captures this response; it is at this point that its radio receiver measures the RSSI of the received signal and displays it on the screen.

Localization procedure
Here is the method I propose for locating a repeater; it is a directional gradient-based search technique (similar to “fox hunting” in amateur radio):
- Initial positioning
- Position yourself near the repeater (at the approximate location generally configured for the repeater and shown on a MeshCore network map).
- Measuring RSSI in the four cardinal directions
- Using a directional antenna, measure the RSSI (in dBm) in the North, South, East, and West directions.
- Moving in the optimal direction
- Move forward approximately 50-100 meters in the direction with the highest RSSI (i.e., the least negative value).
- Recording data
- Note the following on a map:
- Your current position.
- The measured RSSI value.
- Note the following on a map:
- Iteration
- Repeat steps 2 through 4 (measuring in the four directions and recording data) until you converge on the repeater’s location.
This picture shows an example of the geolocation of a MehCore repeater in an urban environment using this method. My initial position was 200 m from the repeater.

Here is a very rough estimate of the RSSI based on distance for a MeshCore repeater operating at 869 MHz with a transmit power of 22 dBm:
| RSSI | Approximate distance |
|---|---|
| -100 dBm | 1000 m |
| -88 dBm | 500 m |
| -50 dBm | 50 m |
| -30 dBm | 20 m |
| -20 dBm | 10 m |
Note that this table provides a very rough estimate. Indeed, the RSSI depends on numerous variable factors, such as:
- the repeater’s transmission power,
- frequency,
- weather conditions,
- geographical characteristics (terrain, obstacles, etc.).
Here is an example of measurements taken for a repeater positioned in a Parc using an omnidirectional antenna.

Note that the type of antenna used by the repeater significantly affects the procedure. Indeed, if the repeater’s antenna is directional (for example, a Moxon or Yagi antenna), localization becomes more complex because the antenna’s gain is concentrated in a specific direction.
The display also shows the SNR (Signal-to-Noise Ratio), which complements the RSSI by measuring signal quality relative to background noise, expressed in dB. A high SNR (> 10 dB) indicates clean, reliable reception, whereas a low or negative SNR (< -10 dB) signals a signal close to the noise floor that is more prone to errors—although LoRa remains capable of decoding packets even in such cases, thanks to its processing gain.
This tutorial could come in handy during a Meshcaching event organized by your local MeshCore community 😉
