Tutorials

GPS-Tracker mit MQTT bauen: Gerät, Broker und Live-Karte

Baue einen MQTT-GPS-Tracker: ESP32 mit GPS-Modul sendet JSON im OwnTracks-Format, dazu ein Python-Simulator zum Testen und eine Live-Karte mit Leaflet.

Aktualisiert · 10 Min. Lesezeit

GPS-Tracker mit MQTT bauen: Gerät, Broker und Live-Karte

Ein GPS-Tracker mit MQTT besteht aus drei kleinen Teilen: einem Gerät, das einen GPS-Fix liest und als JSON publisht, einem Broker, der ihn weiterleitet, und einer Karte, die subscribt und einen Marker bewegt. Weil MQTT eine einzige Verbindung offen hält und pro Nachricht nur wenige Bytes hinzufügt, passt es gut zu Trackern, die häufig über WLAN oder Mobilfunk melden.

Dieses Tutorial baut alle drei: ESP32-Firmware mit seriellem GPS-Modul, einen Python-Simulator, damit du ohne Hardware testen kannst, und eine Browser-Karte mit Leaflet und MQTT.js. Der Payload folgt dem OwnTracks-Location-Format, sodass Smartphones mit OwnTracks dieselbe Karte nutzen können.

Architektur

Datenfluss
GPS module ──NMEA──▶ ESP32 ──MQTT/TLS 8883──▶ broker ──WSS 8084──▶ browser map
                                               │
                                               └──▶ recorder / database (optional)
TeilAufgabeEmpfehlung
GPS-ModulGibt NMEA-Sätze über UART ausu-blox NEO-6M/M8N o. ä., 9600 Baud
GerätParst NMEA, entscheidet, wann gepublisht wirdESP32 (WLAN) oder ESP32 + LTE-Modem
BrokerLeitet weiter, hält die letzte Position, setzt ACLs durchJeder MQTT-3.1.1/5-Broker mit TLS
KarteSubscribt über WebSockets, zeichnet MarkerLeaflet + MQTT.js auf einer statischen Seite
Speicher (optional)Hält die HistorieOwnTracks Recorder, InfluxDB, PostgreSQL

Topics und Payload

Verwende ein Topic pro Tracker und ein separates Status-Topic für die Verfügbarkeit:

Topic-Struktur
testmqtt/gps/<user>/<device>          location JSON, retained
testmqtt/gps/<user>/<device>/status   "online" / "offline", retained (Last Will)

Auf deinem eigenen Broker würdest du typischerweise owntracks/<user>/<device> verwenden, das erwarten die OwnTracks-Apps und der Recorder. Hier nutzen wir das Präfix testmqtt/, damit die Beispiele gefahrlos auf einem geteilten Test-Endpoint laufen. Welche Subscriptions auf welchen Tracker passen, prüfst du mit dem Topic-Matcher.

Der Payload ist eine Teilmenge der OwnTracks-Nachricht location:

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

lat/lon sind Dezimalgrad, tst ist der UTC-Unix-Zeitstempel des Fixes (nicht des Publish), vel ist km/h, cog der Kurs in Grad und acc eine Genauigkeitsschätzung in Metern. tid ist ein zweistelliges Kürzel, das auf Karten angezeigt wird. Sende immer die Fix-Zeit mit: QoS-1-Wiederholungen, Offline-Puffer und die Retained Message führen dazu, dass ein Subscriber eine Position lange nach der Messung erhalten kann.

Publishe die Position retained, damit eine später geöffnete Karte sofort die letzte bekannte Position zeigt. Wie Retention funktioniert und wie du sie wieder löschst, steht unter Retained Messages.

Die ESP32-Firmware

Verbinde TX des Moduls mit GPIO16 und RX mit GPIO17 des ESP32, versorge es mit 3,3 V (prüfe dein Board) und installiere TinyGPSPlus und PubSubClient über den Arduino-Bibliotheksverwalter. Falls du noch kein ESP32-MQTT-Projekt aufgesetzt hast, starte mit ESP32 MQTT mit 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;
  }
}

Ein paar Designentscheidungen, die du beibehalten solltest:

  • Distanzbasiertes Senden. Jede Sekunde zu publishen verschwendet im Stand Daten; jede Minute zu publishen macht die Spur in Bewegung eckig. Distanz plus Heartbeat liefert beides.
  • Last Will auf dem Status-Topic. Fallen Strom oder Empfang weg, publisht der Broker nach etwa 1,5 × Keep Alive offline. Die Karte kann den Marker ausgrauen. Mehr unter Last Will and Testament.
  • TLS von Anfang an. setInsecure() ist am Schreibtisch okay; auf echten Geräten lädst du die Root-CA des Brokers mit setCACert(). Standortdaten sind personenbezogene Daten.
  • Die Connect-Schleife blockiert, solange keine Verbindung besteht, und NMEA-Bytes stauen sich im UART-Puffer. Für einen produktiven Tracker solltest du ohne Blockieren reconnecten und Fixes puffern, damit die Spur keine Lücken hat.

Testen ohne Hardware: ein Python-Simulator

Bevor du irgendetwas lötest, simuliere einen Tracker. Dieses Skript (paho-mqtt 2.x) fährt eine Route durch Berlin ab und publisht denselben Payload auf denselben Topics wie die 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()

Beobachte die eingehenden Nachrichten im Terminal:

Alle Tracker abonnieren
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
  -u "$MQTT_USER" -P "$MQTT_PASS" -t 'testmqtt/gps/#' -v

Oder öffne den Online-MQTT-Client und subscribe auf testmqtt/gps/#. Weil die Position retained ist, bekommt auch ein Subscriber, der sich nach dem Ende des Simulators verbindet, noch den letzten Fix.

Eine Live-Karte im Browser

Browser sprechen MQTT über WebSockets. Diese statische Seite nutzt Leaflet mit OpenStreetMap-Kacheln und MQTT.js 5 auf Port 8084 (wss, Pfad /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/+/+ passt auf genau zwei Ebenen unterhalb des Präfixes, empfängt also die Positionen, aber nicht die eine Ebene tieferen /status-Topics. Subscribe sie separat, wenn du Offline-Tracker ausgrauen willst. Für ein umfangreicheres Dashboard mit Historie und Diagrammen siehe Live-MQTT-Dashboard bauen.

Sicherheit und Datenschutz

Ein Standortstrom verrät, wo eine Person schläft, arbeitet und reist. Behandle ihn entsprechend:

  1. Ein User pro Gerät mit einer ACL, die nur das Publishen auf die eigenen .../<user>/<device>-Topics erlaubt.
  2. Ein Read-only-User für Karten und Dashboards, beschränkt auf die User, die er anzeigen darf.
  3. TLS überall: Port 8883 für Geräte, 8084 (wss) für Browser.
  4. Keine Wildcard für alle. Ein Subscriber mit # sieht jeden Tracker auf dem Broker.
  5. Aufbewahrungsrichtlinie für gespeicherte Historie. Siehe MQTT und DSGVO.

Test-Checkliste

  • Starte den Simulator und prüfe, dass sich der Marker auf der Karte bewegt.
  • Lade die Kartenseite neu: Die letzte Position muss sofort erscheinen (Retained Message).
  • Beende den Simulator mit Strg+C: Nach etwa 1,5 × Keep Alive wechselt das Status-Topic auf offline (Last Will).
  • Melde dich in der Karte mit dem Geräte-User an und versuche, anderswohin zu publishen: Der Broker muss das ablehnen.
  • Nimm das echte Gerät mit nach draußen und vergleiche tst mit der Empfangszeit, um deine End-to-End-Verzögerung zu sehen.

Tracker-Nachrichten im Browser beobachten

Ein so gebauter Tracker ist klein genug, um ihn vollständig zu verstehen: ein paar Dutzend Zeilen Firmware, ein Retained-Topic pro Gerät, ein Last Will für die Verfügbarkeit und eine statische Kartenseite. Sobald Simulator, Karte und ACLs wie erwartet funktionieren, tauschst du die echte Hardware ein und richtest alles auf deinen eigenen Broker aus.

Häufig gestellte Fragen

Warum MQTT statt HTTP für einen GPS-Tracker?

Ein Tracker sendet häufig kleine Nachrichten über unzuverlässige Verbindungen. MQTT hält eine einzige Verbindung offen, fügt pro Nachricht nur wenige Bytes hinzu, kann die letzte Position für neue Subscriber vorhalten und meldet einen ausgefallenen Tracker per Last-Will-Nachricht. HTTP braucht für jeden Fix einen neuen Request mit allen Headern.

Muss ich das OwnTracks-JSON-Format verwenden?

Nein, jedes JSON funktioniert. Das OwnTracks-Location-Format (_type, lat, lon, tst, acc…) ist aber praktisch, weil die OwnTracks-Apps, der OwnTracks Recorder und mehrere Smart-Home-Integrationen es bereits verstehen. So teilen sich dein Eigenbau-Tracker und Smartphones eine Karte.

Wie oft sollte ein GPS-Tracker publishen?

Publishe bei Bewegung statt nach festem Takt: zum Beispiel, wenn sich das Gerät mehr als 25–50 Meter bewegt hat, plus ein Heartbeat alle ein bis zwei Minuten im Stillstand. So bleibt die Karte in Bewegung flüssig, und im geparkten Zustand sparst du Daten und Akku.