Tutoriels

Créer un traceur GPS avec MQTT : appareil, broker et carte en direct

Créez un traceur GPS MQTT : un ESP32 et un module GPS publiant du JSON au format OwnTracks, un simulateur Python pour tester et une carte Leaflet en direct.

Mis à jour · 10 min de lecture

Créer un traceur GPS avec MQTT : appareil, broker et carte en direct

Un traceur GPS avec MQTT se compose de trois petites briques : un appareil qui lit une position GPS et la publie en JSON, un broker qui la route, et une carte qui s'abonne et déplace un marqueur. Comme MQTT garde une seule connexion ouverte et n'ajoute que quelques octets par message, il convient bien aux traceurs qui remontent souvent leur position en Wi-Fi ou en réseau cellulaire.

Ce tutoriel construit les trois : un firmware ESP32 avec un module GPS série, un simulateur Python pour tester sans matériel, et une carte dans le navigateur avec Leaflet et MQTT.js. Le payload suit le format location d'OwnTracks, ce qui permet aux smartphones équipés d'OwnTracks de partager la même carte.

Architecture

Flux de données
GPS module ──NMEA──▶ ESP32 ──MQTT/TLS 8883──▶ broker ──WSS 8084──▶ browser map
                                               │
                                               └──▶ recorder / database (optional)
BriqueRôleQuoi choisir
Module GPSÉmet des trames NMEA sur UARTu-blox NEO-6M/M8N ou équivalent, 9600 bauds
AppareilAnalyse le NMEA, décide quand publierESP32 (Wi-Fi) ou ESP32 + modem LTE
BrokerRoute, retient la dernière position, applique les ACLTout broker MQTT 3.1.1/5 avec TLS
CarteS'abonne via WebSockets, dessine les marqueursLeaflet + MQTT.js dans une page statique
Stockage (optionnel)Conserve l'historiqueOwnTracks Recorder, InfluxDB, PostgreSQL

Topics et payload

Utilisez un topic par traceur et un topic de statut séparé pour la disponibilité :

Organisation des topics
testmqtt/gps/<user>/<device>          location JSON, retained
testmqtt/gps/<user>/<device>/status   "online" / "offline", retained (Last Will)

Sur votre propre broker, vous utiliseriez en général owntracks/<user>/<device>, ce qu'attendent les applications OwnTracks et le Recorder. Ici nous utilisons le préfixe testmqtt/ pour que les exemples puissent tourner sans risque sur un endpoint de test partagé. Vous pouvez vérifier quels abonnements correspondent à quel traceur avec le topic matcher.

Le payload est un sous-ensemble du message location d'OwnTracks :

Payload de position
{
  "_type": "location",
  "tid": "t1",
  "lat": 52.520008,
  "lon": 13.404954,
  "alt": 38,
  "vel": 12,
  "cog": 270,
  "acc": 6,
  "tst": 1792236000
}

lat/lon sont en degrés décimaux, tst est l'horodatage Unix UTC de la position (pas de la publication), vel est en km/h, cog est le cap en degrés et acc une estimation de précision en mètres. tid est une étiquette de deux caractères affichée sur les cartes. Envoyez toujours l'heure de la mesure : les renvois QoS 1, les tampons hors ligne et le message retenu font qu'un abonné peut recevoir une position bien après sa mesure.

Publiez la position en retained pour qu'une carte ouverte plus tard affiche immédiatement la dernière position connue. Voir messages retenus pour comprendre la rétention et comment l'effacer.

Le firmware ESP32

Reliez le TX du module au GPIO16 de l'ESP32 et le RX au GPIO17, alimentez-le en 3,3 V (vérifiez votre carte) et installez TinyGPSPlus et PubSubClient depuis le gestionnaire de bibliothèques Arduino. Si vous n'avez jamais monté de projet MQTT sur ESP32, commencez par ESP32 MQTT avec Arduino.

gps_tracker.ino
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <TinyGPSPlus.h>
#include <time.h>

const char* WIFI_SSID  = "your-ssid";
const char* WIFI_PASS  = "your-wifi-password";
const char* MQTT_HOST  = "broker.connectmq.com";
const int   MQTT_PORT  = 8883;
const char* MQTT_USER  = "username";
const char* MQTT_PASS  = "password";
const char* LOC_TOPIC  = "testmqtt/gps/alice/tracker1";
const char* STAT_TOPIC = "testmqtt/gps/alice/tracker1/status";

TinyGPSPlus gps;
HardwareSerial gpsSerial(2);
WiFiClientSecure net;
PubSubClient mqtt(net);

double lastLat = 0, lastLon = 0;
unsigned long lastSend = 0;

time_t fixEpoch() {
  struct tm t = {};
  t.tm_year = gps.date.year() - 1900;
  t.tm_mon  = gps.date.month() - 1;
  t.tm_mday = gps.date.day();
  t.tm_hour = gps.time.hour();
  t.tm_min  = gps.time.minute();
  t.tm_sec  = gps.time.second();
  return mktime(&t);  // TZ is set to UTC in setup()
}

void connectMqtt() {
  while (!mqtt.connected()) {
    String id = "gps-" + String((uint32_t)ESP.getEfuseMac(), HEX);
    // Last Will: the broker publishes "offline" if the tracker vanishes
    if (mqtt.connect(id.c_str(), MQTT_USER, MQTT_PASS, STAT_TOPIC, 1, true, "offline")) {
      mqtt.publish(STAT_TOPIC, "online", true);
    } else {
      Serial.printf("MQTT connect failed, state=%d\n", mqtt.state());
      delay(5000);
    }
  }
}

void publishFix() {
  char payload[256];
  snprintf(payload, sizeof(payload),
    "{\"_type\":\"location\",\"tid\":\"t1\",\"lat\":%.6f,\"lon\":%.6f,"
    "\"alt\":%d,\"vel\":%d,\"cog\":%d,\"acc\":%d,\"tst\":%lu}",
    gps.location.lat(), gps.location.lng(),
    (int)gps.altitude.meters(), (int)gps.speed.kmph(), (int)gps.course.deg(),
    (int)(gps.hdop.hdop() * 5),           // rough heuristic, not a real accuracy
    (unsigned long)fixEpoch());
  mqtt.publish(LOC_TOPIC, payload, true);  // retained
}

void setup() {
  Serial.begin(115200);
  gpsSerial.begin(9600, SERIAL_8N1, 16, 17);
  setenv("TZ", "UTC0", 1);
  tzset();

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  net.setInsecure();  // first test only, use net.setCACert(rootCA) in production
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setBufferSize(512);
  mqtt.setKeepAlive(30);
}

void loop() {
  while (gpsSerial.available()) gps.encode(gpsSerial.read());
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();

  if (!gps.location.isValid() || !gps.date.isValid() || gps.location.age() > 2000) return;

  double moved = TinyGPSPlus::distanceBetween(
      gps.location.lat(), gps.location.lng(), lastLat, lastLon);
  unsigned long now = millis();

  // publish on movement (> 25 m) or every 60 s as a heartbeat
  if (moved > 25 || now - lastSend > 60000) {
    publishFix();
    lastLat = gps.location.lat();
    lastLon = gps.location.lng();
    lastSend = now;
  }
}

Quelques choix de conception à conserver :

  • Envoi selon la distance. Publier chaque seconde gaspille des données à l'arrêt ; publier chaque minute rend le tracé saccadé en mouvement. Distance plus heartbeat offre les deux.
  • Last Will sur le topic de statut. Si l'alimentation ou la couverture disparaît, le broker publie offline après environ 1,5 × keep alive. La carte peut griser le marqueur. Plus de détails dans Last Will and Testament.
  • TLS dès le départ. setInsecure() convient sur votre bureau ; sur de vrais appareils, chargez la CA racine du broker avec setCACert(). Les données de localisation sont des données personnelles.
  • La boucle de connexion bloque tant que le réseau est absent, et les octets NMEA s'accumulent dans le tampon UART. Pour un traceur en production, reconnectez-vous sans bloquer et mettez les positions en tampon pour que le tracé n'ait pas de trous.

Tester sans matériel : un simulateur Python

Avant de souder quoi que ce soit, simulez un traceur. Ce script (paho-mqtt 2.x) parcourt un itinéraire dans Berlin et publie le même payload sur les mêmes topics que le firmware :

gps_sim.py
import json, os, time
import paho.mqtt.client as mqtt

HOST, PORT = "broker.connectmq.com", 8883
TOPIC = "testmqtt/gps/alice/sim1"
STATUS = TOPIC + "/status"
ROUTE = [(52.5200, 13.4050), (52.5163, 13.3777), (52.5096, 13.3759), (52.5074, 13.3904)]

def steps(a, b, n=10):
    for i in range(n):
        f = i / n
        yield a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="gps-sim-1")
if os.getenv("MQTT_USER"):
    client.username_pw_set(os.environ["MQTT_USER"], os.getenv("MQTT_PASS"))
client.tls_set()
client.will_set(STATUS, "offline", qos=1, retain=True)
client.connect(HOST, PORT, keepalive=30)
client.loop_start()
client.publish(STATUS, "online", qos=1, retain=True)

for a, b in zip(ROUTE, ROUTE[1:]):
    for lat, lon in steps(a, b):
        fix = {"_type": "location", "tid": "s1", "lat": round(lat, 6),
               "lon": round(lon, 6), "acc": 8, "vel": 30, "tst": int(time.time())}
        client.publish(TOPIC, json.dumps(fix), qos=1, retain=True)
        print("sent", fix["lat"], fix["lon"])
        time.sleep(2)

client.publish(STATUS, "offline", qos=1, retain=True).wait_for_publish()
client.disconnect()
client.loop_stop()

Regardez les messages arriver depuis un terminal :

S'abonner à tous les traceurs
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
  -u "$MQTT_USER" -P "$MQTT_PASS" -t 'testmqtt/gps/#' -v

Ou ouvrez le client MQTT en ligne et abonnez-vous à testmqtt/gps/#. Comme la position est retenue, un abonné qui se connecte après l'arrêt du simulateur reçoit quand même la dernière position.

Une carte en direct dans le navigateur

Les navigateurs parlent MQTT via WebSockets. Cette page statique utilise Leaflet avec les tuiles OpenStreetMap et MQTT.js 5 sur le port 8084 (wss, chemin /mqtt) :

map.html
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>MQTT GPS map</title>
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
  <style>html, body, #map { height: 100%; margin: 0; }</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
<script>
  const map = L.map('map').setView([52.52, 13.40], 13);
  L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '&copy; OpenStreetMap contributors'
  }).addTo(map);

  const markers = {}, trails = {};
  const client = mqtt.connect('wss://broker.connectmq.com:8084/mqtt', {
    username: 'username',   // a read-only map user
    password: 'password',
  });

  client.on('connect', () => client.subscribe('testmqtt/gps/+/+', { qos: 1 }));
  client.on('message', (topic, payload) => {
    let p;
    try { p = JSON.parse(payload.toString()); } catch { return; }
    if (p._type !== 'location') return;
    const pos = [p.lat, p.lon];
    if (!markers[topic]) {
      markers[topic] = L.marker(pos).addTo(map).bindTooltip(p.tid || topic);
      trails[topic] = L.polyline([], { weight: 3 }).addTo(map);
    }
    markers[topic].setLatLng(pos);
    trails[topic].addLatLng(pos);
    map.panTo(pos);
  });
</script>
</body>
</html>

testmqtt/gps/+/+ correspond exactement à deux niveaux sous le préfixe : il reçoit les positions, mais pas les topics /status situés un niveau plus bas. Abonnez-vous à ceux-ci séparément si vous voulez griser les traceurs hors ligne. Pour un tableau de bord plus complet avec historique et graphiques, voir créer un dashboard MQTT en direct.

Sécurité et confidentialité

Un flux de positions révèle où une personne dort, travaille et voyage. Traitez-le en conséquence :

  1. Un utilisateur par appareil, avec une ACL qui n'autorise la publication que sur ses propres topics .../<user>/<device>.
  2. Un utilisateur en lecture seule pour les cartes et dashboards, limité aux utilisateurs qu'il peut afficher.
  3. TLS partout : port 8883 pour les appareils, 8084 (wss) pour les navigateurs.
  4. Pas de joker pour tout le monde. Un abonné avec # voit tous les traceurs du broker.
  5. Politique de conservation pour l'historique stocké. Voir MQTT et RGPD.

Checklist de test

  • Lancez le simulateur et vérifiez que le marqueur se déplace sur la carte.
  • Rechargez la page de la carte : la dernière position doit apparaître immédiatement (message retenu).
  • Arrêtez le simulateur avec Ctrl+C : après environ 1,5 × keep alive, le topic de statut passe à offline (Last Will).
  • Connectez la carte avec l'utilisateur de l'appareil et essayez de publier ailleurs : le broker doit refuser.
  • Sortez avec l'appareil réel et comparez tst à l'heure de réception pour mesurer votre délai de bout en bout.

Observer les messages du traceur dans le navigateur

Un traceur construit ainsi est assez petit pour être compris entièrement : quelques dizaines de lignes de firmware, un topic retenu par appareil, un Last Will pour la disponibilité et une page de carte statique. Une fois que le simulateur, la carte et les ACL se comportent comme prévu, passez au vrai matériel et pointez tout vers votre propre broker.

Questions fréquentes

Pourquoi utiliser MQTT plutôt que HTTP pour un traceur GPS ?

Un traceur envoie souvent de petits messages sur des liaisons peu fiables. MQTT garde une seule connexion ouverte, n'ajoute que quelques octets par message, peut retenir la dernière position pour les nouveaux abonnés et signale un traceur hors ligne grâce au message Last Will. HTTP exige une nouvelle requête avec tous ses en-têtes pour chaque position.

Dois-je utiliser le format JSON d'OwnTracks ?

Non, n'importe quel JSON fonctionne. Le format location d'OwnTracks (_type, lat, lon, tst, acc…) est pratique parce que les applications OwnTracks, OwnTracks Recorder et plusieurs intégrations domotiques le comprennent déjà : votre traceur maison et les smartphones peuvent partager une même carte.

À quelle fréquence un traceur GPS doit-il publier ?

Publiez selon le déplacement plutôt qu'à intervalle fixe : par exemple quand l'appareil a bougé de plus de 25 à 50 mètres, plus un heartbeat toutes les une à deux minutes à l'arrêt. La carte reste fluide en mouvement et vous économisez données et batterie à l'arrêt.