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
GPS module ──NMEA──▶ ESP32 ──MQTT/TLS 8883──▶ broker ──WSS 8084──▶ browser map
│
└──▶ recorder / database (optional)| Teil | Aufgabe | Empfehlung |
|---|---|---|
| GPS-Modul | Gibt NMEA-Sätze über UART aus | u-blox NEO-6M/M8N o. ä., 9600 Baud |
| Gerät | Parst NMEA, entscheidet, wann gepublisht wird | ESP32 (WLAN) oder ESP32 + LTE-Modem |
| Broker | Leitet weiter, hält die letzte Position, setzt ACLs durch | Jeder MQTT-3.1.1/5-Broker mit TLS |
| Karte | Subscribt über WebSockets, zeichnet Marker | Leaflet + MQTT.js auf einer statischen Seite |
| Speicher (optional) | Hält die Historie | OwnTracks Recorder, InfluxDB, PostgreSQL |
Topics und Payload
Verwende ein Topic pro Tracker und ein separates Status-Topic für die Verfügbarkeit:
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:
{
"_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.
#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 mitsetCACert(). 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:
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:
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
-u "$MQTT_USER" -P "$MQTT_PASS" -t 'testmqtt/gps/#' -vOder ö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):
<!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: '© 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:
- Ein User pro Gerät mit einer ACL, die nur das Publishen auf die eigenen
.../<user>/<device>-Topics erlaubt. - Ein Read-only-User für Karten und Dashboards, beschränkt auf die User, die er anzeigen darf.
- TLS überall: Port 8883 für Geräte, 8084 (
wss) für Browser. - Keine Wildcard für alle. Ein Subscriber mit
#sieht jeden Tracker auf dem Broker. - 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
tstmit 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.

