Géolocalisation d’un répéteur MeshCore

0
(0)

Dans ce tutoriel, nous allons utiliser un module Heltec V3 pour géolocaliser un répéteur MeshCore.

Le principe repose sur la mesure du RSSI (Received Signal Strength Indicator, indicateur de la puissance du signal reçu). Notre module analyse la force du signal radio émis par le répéteur à chaque réception de paquet.

Plus le RSSI est élevé, plus le module est proche du répéteur. En effectuant plusieurs mesures tout en ce déplaçant, il est possible d’estimer la position physique par une méthode de recherche directionnelle, en suivant le signal le plus fort.

Pour rappel, le RSSI s’exprime en dBm (décibel-milliwatt). Pour une introduction à cette notion, consultez cet article.

Identification du répéteur à géolocaliser

L’identification du répéteur à géolocaliser repose sur sa clé publique. Vous pouvez récupérer cette clé à partir d’outils comme Paris MeshCore Analyser, qui cartographie les répéteurs du réseau.

Le programme ci-dessous se limite à vérifier les deux premiers octets de la clé publique du répéteur cible. Cette approche est actuellement suffisante dans la région parisienne, où le nombre de répéteurs reste limité. Cependant, pour une identification plus robuste, il est possible d’utiliser un plus grand nombre d’octets de cette clé.

Le matériel

J’ai utilisé un module Heltec V3 équipé d’une antenne Moxon. L’intérêt de cette antenne est d’être compacte et légère et relativement bien directrice.

Le programme

Pour rendre ce tutoriel accessible au plus grand nombre, j’ai utilisé l’IDE Arduino.

C++
/**
 * @file    rssi-meshcore-repeater.ino
 * @brief   Géolocalisation d'un répéteur Meshcore
 *
 * @details Affiche le RSSI (niveau de signal) et le temps écoulé depuis la
 *          dernière réception d'un paquet provenant d'un répéteur Meshcore
 *          spécifique, sur un Heltec V3.
 *
 * @author  Tutoduino
 * @see     https://tutoduino.fr/menu-sdr/geolocalisation-repeteur-meshcore/
 */

#include <SPI.h>
#include <RadioLib.h>
#include <Adafruit_SSD1306.h>

// =====================================================================
// 1) BROCHAGE — 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  // bouton "PRG" du Heltec V3, utilise ici pour declencher un ping

// =====================================================================
// 2) PARAMETRES RADIO — 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

// !! A ADAPTER : prefixe de la cle publique du repeteur MeshCore vise.
const uint8_t TARGET_PUBKEY_PREFIX[] = { 0x21, 0xF2 };
#define TARGET_PUBKEY_PREFIX_LEN (sizeof(TARGET_PUBKEY_PREFIX))

// =====================================================================
// 3) FORMAT DE PAQUET MESHCORE (doc officielle meshcore-dev/MeshCore)
//    header (1 octet) = 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 un chemin" : c'est le ping natif de MeshCore

#define MAX_PACKET_LEN 256
#define ADVERT_PUBKEY_LEN 32  // une annonce contient la cle publique complete (32 octets)

// =====================================================================
// 4) ETAT GLOBAL DE L'APPLICATION
// =====================================================================
// On regroupe tout ce qui concerne "le dernier paquet vu du repeteur cible"
// dans une seule structure : c'est plus simple à lire qu'une liste de
// variables globales éparpillées.
struct RepeaterStatus {
  bool hasPacket = false;      // avons-nous deja recu un paquet du repeteur cible ?
  unsigned long lastSeenMs = 0;
  float rssi = 0;
  float snr = 0;
};

RepeaterStatus targetStatus;

// Tag de la derniere requete TRACE envoyee, et instant d'envoi : permet de
// reconnaitre la reponse (qui renvoie ce meme tag) sans avoir a en decoder
// un hash, puisque le format du payload TRACE est different des messages.
uint32_t lastSentTag = 0;
unsigned long lastPingMs = 0;
#define TRACE_REPLY_TIMEOUT_MS 10000UL  // on n'accepte une reponse que dans les 10s suivant le 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);

// Ce drapeau est mis a "true" par l'interruption radio des qu'un paquet arrive.
// On le traite ensuite tranquillement dans loop(), jamais dans l'interruption
// elle-meme (regle de base avec RadioLib / ESP32).
volatile bool packetReceived = false;
void onPacketReceivedISR() {
  packetReceived = true;
}

// =====================================================================
// 5) AFFICHAGE OLED
// =====================================================================
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;
  }

  // --- Titre : identifiant du repeteur surveille ---
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.printf("REPETEUR %02X%02X", TARGET_PUBKEY_PREFIX[0], TARGET_PUBKEY_PREFIX[1]);

  // --- RSSI en grand, centre horizontalement ---
  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 et temps ecoule depuis le dernier paquet ---
  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) INITIALISATION MATERIELLE
// =====================================================================
void initOled() {
  // Alimentation Vext (necessaire pour l'OLED sur le Heltec V3, actif a l'etat bas)
  pinMode(VEXT_PIN, OUTPUT);
  digitalWrite(VEXT_PIN, LOW);
  delay(150);

  // Reset materiel de l'ecran, avant toute communication I2C
  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) {}  // on bloque ici : sans ecran, pas la peine de continuer
  }

  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) ENVOI D'UN "PING" (paquet TRACE) VERS LE REPETEUR CIBLE
// =====================================================================
// MeshCore a une fonctionnalite native pour ca : le payload TRACE (0x09),
// concu pour tracer un chemin et collecter le SNR de chaque saut. Avec un
// seul noeud dans la liste, c'est l'equivalent MeshCore d'un ping.
// Format confirme par la doc officielle (wiki MeshCore, "Companion Radio
// Protocol") : [tag 4o][auth_code 4o][flags 1o][liste de hash a tracer].
//
// Ici on vise un seul repeteur, en "zero-hop" (path_len=0 au niveau du
// paquet) : le paquet est emis une seule fois, sans etre relaye plus loin.
void sendTracePing() {
  uint8_t buf[16];
  size_t offset = 0;

  // --- Header du paquet : payload TRACE + route DIRECT ---
  buf[offset++] = (PAYLOAD_TYPE_TRACE << 2) | ROUTE_TYPE_DIRECT;

  // --- path_length du paquet (niveau routage) : 0 = zero-hop ---
  buf[offset++] = 0x00;
  // (pas d'octets de chemin a la suite puisqu'il n'y a aucun saut)

  // --- Payload TRACE ---
  uint32_t tag = esp_random();  // identifiant aleatoire de cette requete
  memcpy(buf + offset, &tag, sizeof(tag));
  offset += sizeof(tag);

  lastSentTag = tag;   // on retiendra ce tag pour reconnaitre la reponse
  lastPingMs = millis();

  uint32_t authCode = 0;  // pas de code d'authentification particulier
  memcpy(buf + offset, &authCode, sizeof(authCode));
  offset += sizeof(authCode);

  buf[offset++] = 0x00;  // flags, reserve pour l'instant

  // Liste des noeuds a tracer : un seul, le repeteur cible
  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() declenche aussi une interruption DIO1 de "fin d'emission",
  // qui peut avoir arme packetReceived a tort : on l'ignore avant de
  // repasser en ecoute, pour ne pas traiter du vide comme un paquet recu.
  packetReceived = false;
  lora.startReceive();
}

// =====================================================================
// 8) DECODAGE DU PAQUET MESHCORE
// =====================================================================
// Cette fonction cherche l'identifiant (hash de cle publique) du DERNIER
// noeud qui a retransmis le paquet, et le compare au prefixe du repeteur
// que l'on surveille. Elle renvoie true si ca correspond.
//
// Rappel du format d'un paquet MeshCore :
//   [header 1 octet] [transport_codes 0 ou 4 octets] [path_length 1 octet]
//   [path 0-64 octets] [payload 0-184 octets]
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;

  // Les modes "transport" ajoutent 4 octets de code de transport
  if (routeType == ROUTE_TYPE_TRANSPORT_FLOOD || routeType == ROUTE_TYPE_TRANSPORT_DIRECT) {
    offset += 4;
  }
  if (offset >= len) return false;

  // Octet path_length : bits 0-5 = nombre de sauts, bits 6-7 = (taille du hash - 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;  // paquet incoherent, on l'ignore

  const uint8_t *payload = packet + offset;
  size_t payloadLen = len - offset;

  // Cas particulier : un paquet TRACE ne contient PAS de hash en tete de
  // payload (contrairement aux messages REQ/RESPONSE/TXT). Il commence par
  // un tag aleatoire de 4 octets. On ne peut donc pas le reconnaitre par
  // hash : on compare son tag a celui de notre dernier ping envoye.
  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;
  }

  // Identifiant du dernier "sauteur" a comparer au repeteur cible
  const uint8_t *lastHopId = nullptr;
  size_t lastHopIdLen = hashSize;

  if (hopCount >= 1) {
    // Cas normal : le dernier hash du chemin est le dernier repeteur traverse
    lastHopId = pathBytes + (size_t)(hopCount - 1) * hashSize;
  } else if (payloadType == PAYLOAD_TYPE_ADVERT && payloadLen >= ADVERT_PUBKEY_LEN) {
    // Annonce sans saut : la cle publique complete de l'emetteur est en tete du payload
    lastHopId = payload;
    lastHopIdLen = ADVERT_PUBKEY_LEN;
  } else if (payloadLen >= 1) {
    // Paquet direct sans saut : le premier octet du payload est le hash source
    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) TRAITEMENT D'UN PAQUET RECU
// =====================================================================
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();

  // On ignore les paquets illisibles OU dont le CRC est invalide :
  // un paquet corrompu ne doit jamais etre interprete comme venant du
  // repeteur cible (risque de fausse 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();  // mise a jour immediate de l'ecran
  }

  lora.startReceive();
}

// =====================================================================
// 10) SETUP / LOOP
// =====================================================================
void setup() {
  Serial.begin(115200);
  delay(200);

  pinMode(BUTTON_PIN, INPUT_PULLUP);  // bouton PRG, relie a la masse quand presse

  initOled();
  showMessage("Initialisation...");

  initRadio();

  Serial.println(F("En attente de paquets MeshCore..."));
  showMessage("En attente de", "paquets MeshCore...");
}

void loop() {
  // Rafraichit l'ecran une fois par seconde (pour le compteur "temps ecoule")
  static unsigned long lastDisplayUpdate = 0;
  if (millis() - lastDisplayUpdate >= 1000) {
    lastDisplayUpdate = millis();
    drawStatusScreen();
  }

  // Appui sur le bouton PRG : on force un ping TRACE vers le repeteur cible,
  // au lieu d'attendre passivement son prochain paquet.
  static unsigned long lastButtonPress = 0;
  if (digitalRead(BUTTON_PIN) == LOW && millis() - lastButtonPress > 500) {
    lastButtonPress = millis();
    sendTracePing();
  }

  if (packetReceived) {
    packetReceived = false;
    handleIncomingPacket();
  }
}

Forcer l’émission d’un message par le répéteur

Le RSSI du dernier paquet reçu du répéteur s’affiche directement sur l’écran du module Heltec V3. Cependant, attendre passivement qu’un répéteur émette spontanément un paquet peut s’avérer long, surtout lorsque plusieurs mesures sont nécessaires pour le localiser — ce qui risquerait de rendre le processus de géolocalisation trop lent.

Pour accélérer ce processus, lorsque vous appuyez sur le bouton PRG du module Heltec V3, il émet un paquet de type TRACE en route directe vers le répéteur (l’équivalent MeshCore d’un ping). Le répéteur y répond aussitôt, et le module Heltec V3, qui écoute en permanence, capte cette réponse : c’est à ce moment-là que son récepteur radio mesure le RSSI du signal reçu, qu’il affiche ensuite à l’écran.

Procédure de localisation

Voici la méthode que je propose pour localiser un répéteur, c’est une technique de recherche directionnelle par gradient (proche du fox hunting en radioamateurisme) :

  1. Positionnement initial
    Se placer à proximité du répéteur (position approximative généralement configurée par le répéteur et disponible sur une carte du réseau MeshCore).
  2. Mesure du RSSI dans les 4 directions cardinales
    À l’aide d’une antenne directrice, mesurer le RSSI (en dBm) dans les directions Nord, Sud, Est et Ouest.
  3. Déplacement vers la direction optimale
    Avancer d’environ 100 mètres dans la direction où le RSSI est le plus élevé (c’est-à-dire la valeur la moins négative).
  4. Enregistrement des données
    Noter sur une carte :
    • Votre position actuelle.
    • La valeur du RSSI mesurée.
  5. Itération
    Répéter les étapes 2 à 4 (mesure dans les 4 directions et enregistrement) jusqu’à converger vers la position du répéteur.

Voici sur ce schéma un exemple de géolocalisation d’un répéteur MehCore dans un environnement urbain en utilisant cette méthode. Ma position initiale était localisée à 200 m du répéteur.

Voici une estimation très approximative du RSSI en fonction de la distance d’un répéteur MeshCore sur la fréquence 869 MHz et dont la puissance d’émission est 22 dBm:

RSSIDistance approximative
-100 dBm1000 m
-88 dBm500 m
-50 dBm50 m
-30 dBm20 m
-20 dBm10 m

Notez que cette table fournit une estimation très approximative. En effet, le RSSI dépend de nombreux facteurs variables, tels que :

  • la puissance d’émission du répéteur,
  • les conditions météorologiques,
  • les caractéristiques géographiques (relief, obstacles, etc.).

Voici un exemple de mesures effectuées pour un répéteur positionné dans le par de sceaux avec une antenne omnidirectionnelle.

Exemple de mesure de RSSI du signal radio émis par un répéteur (cercle rouge)

Notez que le type d’antenne utilisée par le répéteur influence significativement la procédure. En effet, si l’antenne du répéteur est directive (par exemple, une antenne Moxon ou Yagi), la triangulation devient plus complexe, car le gain de l’antenne est concentré dans une direction spécifique.

L’afficheur indique également le SNR (Signal-to-Noise Ratio), qui complète le RSSI en mesurant la qualité du signal par rapport au bruit ambiant, exprimé en dB. Un SNR élevé (> 10 dB) indique une réception propre et fiable, tandis qu’un SNR faible ou négatif (< -10 dB) signale un signal proche du bruit, plus susceptible d’erreurs — bien que LoRa reste capable de décoder des paquets même dans ce cas grâce à son gain de traitement.

Ce tutoriel pourrait être utile lors d’un Meshcaching organisé par la communauté du Mesh Ile de France 😉

How useful was this post?

Click on a star to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.

We are sorry that this post was not useful for you!

Let us improve this post!

Tell us how we can improve this post?