Tutorials

Build a GPS Tracker with MQTT: Device, Broker and Live Map

Build an MQTT GPS tracker: ESP32 + GPS module publishing OwnTracks-style JSON, a Python simulator for testing, and a live Leaflet map over WebSockets.

Updated · 10 min read

Build a GPS Tracker with MQTT: Device, Broker and Live Map

A GPS tracker with MQTT is three small pieces: a device that reads a GPS fix and publishes it as JSON, a broker that routes it, and a map that subscribes and moves a marker. Because MQTT keeps a single connection open and adds only a few bytes per message, it suits trackers that report often over Wi-Fi or cellular links.

This tutorial builds all three: ESP32 firmware with a serial GPS module, a Python simulator so you can test without hardware, and a browser map with Leaflet and MQTT.js. The payload follows the OwnTracks location format, so phones running OwnTracks can share the same map.

Architecture

Data flow
GPS module ──NMEA──▶ ESP32 ──MQTT/TLS 8883──▶ broker ──WSS 8084──▶ browser map
                                               │
                                               └──▶ recorder / database (optional)
PieceRoleWhat to choose
GPS moduleOutputs NMEA sentences over UARTu-blox NEO-6M/M8N or similar, 9600 baud
DeviceParses NMEA, decides when to publishESP32 (Wi-Fi) or ESP32 + LTE modem
BrokerRoutes, retains last position, enforces ACLsAny MQTT 3.1.1/5 broker with TLS
MapSubscribes over WebSockets, draws markersLeaflet + MQTT.js in a static page
Storage (optional)Keeps historyOwnTracks Recorder, InfluxDB, PostgreSQL

Topics and payload

Use one topic per tracker and keep a separate status topic for availability:

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

On your own broker you would typically use owntracks/<user>/<device>, which is what the OwnTracks apps and Recorder expect. Here we use a testmqtt/ prefix so the examples are safe to run on a shared test endpoint. You can check which subscriptions match which tracker with the topic matcher.

The payload is a subset of the OwnTracks location message:

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

lat/lon are decimal degrees, tst is the UTC Unix timestamp of the fix (not of the publish), vel is km/h, cog is the course in degrees and acc an accuracy estimate in metres. tid is a two-character label shown on maps. Always send the fix time: QoS 1 retries, offline buffers and the retained message mean a subscriber can receive a position long after it was measured.

Publish the location retained so a map that opens later immediately shows the last known position. See retained messages for how retention works and how to clear it.

The ESP32 firmware

Wire the module's TX to ESP32 GPIO16 and RX to GPIO17, power it from 3.3 V (check your board), and install TinyGPSPlus and PubSubClient from the Arduino Library Manager. If you have not set up an ESP32 MQTT project before, start with ESP32 MQTT with 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;
  }
}

A few design choices worth keeping:

  • Distance-based reporting. Publishing every second wastes data while parked; publishing every minute makes the trail jagged while moving. Distance plus heartbeat gives both.
  • Last Will on the status topic. If power or coverage disappears, the broker publishes offline after about 1.5 × keep alive. The map can grey the marker out. More in Last Will and Testament.
  • TLS from the start. setInsecure() is fine on your desk; for real devices load the broker's root CA with setCACert(). Location data is personal data.
  • The connect loop blocks while offline, so NMEA bytes pile up in the UART buffer. For a production tracker, reconnect without blocking and buffer fixes so the trail has no gaps.

Test without hardware: a Python simulator

Before you solder anything, simulate a tracker. This script (paho-mqtt 2.x) walks a route through Berlin and publishes the same payload and topics as the 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()

Watch the messages arrive from a terminal:

Subscribe to all trackers
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
  -u "$MQTT_USER" -P "$MQTT_PASS" -t 'testmqtt/gps/#' -v

Or open the online MQTT client and subscribe to testmqtt/gps/#. Because the location is retained, a subscriber that connects after the simulator stops still receives the last fix.

A live map in the browser

Browsers speak MQTT over WebSockets. This static page uses Leaflet with OpenStreetMap tiles and MQTT.js 5 on port 8084 (wss, path /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/+/+ matches exactly two levels below the prefix, so it receives locations but not the /status topics one level deeper. Subscribe to those separately if you want to grey out offline trackers. For a fuller dashboard with history and charts, see building a live MQTT dashboard.

Security and privacy

A location stream shows where a person sleeps, works and travels. Treat it accordingly:

  1. One user per device with an ACL that allows publishing only to its own .../<user>/<device> topics.
  2. A read-only user for maps and dashboards, scoped to the users it may display.
  3. TLS everywhere: port 8883 for devices, 8084 (wss) for browsers.
  4. No wildcard-for-everyone. A subscriber with # sees every tracker on the broker.
  5. Retention policy for stored history. See MQTT and GDPR.

Testing checklist

  • Run the simulator and confirm the marker moves on the map.
  • Restart the map page: the last position should appear instantly (retained message).
  • Kill the simulator with Ctrl+C: after roughly 1.5 × keep alive the status topic switches to offline (Last Will).
  • Log in to the map with the device user and try to publish elsewhere: the broker should refuse it.
  • Take the real device outdoors and compare tst with the receive time to see your end-to-end delay.

Watch tracker messages in the browser

A tracker built this way is small enough to understand completely: a few dozen lines of firmware, a retained topic per device, a Last Will for availability and a static map page. Once the simulator, map and ACLs behave as expected, swap in the real hardware and point everything at your own broker.

Frequently asked questions

Why use MQTT instead of HTTP for a GPS tracker?

A tracker sends small messages often over unreliable links. MQTT keeps one connection open, adds only a few bytes of overhead per message, can retain the last position for new subscribers and reports a dead tracker through a Last Will message. HTTP needs a new request with full headers for every fix.

Do I have to use the OwnTracks JSON format?

No, any JSON works. Using the OwnTracks location format (_type, lat, lon, tst, acc…) is a convenient choice because the OwnTracks apps, the OwnTracks Recorder and several home-automation integrations already understand it, so your DIY tracker and phones can share one map.

How often should a GPS tracker publish?

Publish on movement rather than on a fixed clock: for example when the device has moved more than 25–50 metres, plus a heartbeat every minute or two while stationary. That keeps the map smooth while moving and saves data and battery when parked.