Un rastreador GPS con MQTT son tres piezas pequeñas: un dispositivo que lee una posición GPS y la publica como JSON, un broker que la enruta y un mapa que se suscribe y mueve un marcador. Como MQTT mantiene una única conexión abierta y añade solo unos pocos bytes por mensaje, encaja muy bien con rastreadores que informan a menudo por Wi-Fi o redes móviles.
Este tutorial construye las tres: firmware para ESP32 con un módulo GPS serie, un simulador en Python para probar sin hardware y un mapa en el navegador con Leaflet y MQTT.js. El payload sigue el formato location de OwnTracks, así que los móviles con OwnTracks pueden compartir el mismo mapa.
Arquitectura
GPS module ──NMEA──▶ ESP32 ──MQTT/TLS 8883──▶ broker ──WSS 8084──▶ browser map
│
└──▶ recorder / database (optional)| Pieza | Función | Qué elegir |
|---|---|---|
| Módulo GPS | Emite sentencias NMEA por UART | u-blox NEO-6M/M8N o similar, 9600 baudios |
| Dispositivo | Interpreta NMEA y decide cuándo publicar | ESP32 (Wi-Fi) o ESP32 + módem LTE |
| Broker | Enruta, retiene la última posición, aplica ACLs | Cualquier broker MQTT 3.1.1/5 con TLS |
| Mapa | Se suscribe por WebSockets y dibuja marcadores | Leaflet + MQTT.js en una página estática |
| Almacenamiento (opcional) | Guarda el histórico | OwnTracks Recorder, InfluxDB, PostgreSQL |
Topics y payload
Usa un topic por rastreador y un topic de estado aparte para la disponibilidad:
testmqtt/gps/<user>/<device> location JSON, retained
testmqtt/gps/<user>/<device>/status "online" / "offline", retained (Last Will)En tu propio broker normalmente usarías owntracks/<user>/<device>, que es lo que esperan las apps de OwnTracks y el Recorder. Aquí usamos el prefijo testmqtt/ para que los ejemplos se puedan ejecutar sin riesgo en un endpoint de pruebas compartido. Puedes comprobar qué suscripciones coinciden con qué rastreador con el topic matcher.
El payload es un subconjunto del mensaje location de OwnTracks:
{
"_type": "location",
"tid": "t1",
"lat": 52.520008,
"lon": 13.404954,
"alt": 38,
"vel": 12,
"cog": 270,
"acc": 6,
"tst": 1792236000
}lat/lon son grados decimales, tst es la marca de tiempo Unix en UTC de la posición (no de la publicación), vel va en km/h, cog es el rumbo en grados y acc una estimación de la precisión en metros. tid es una etiqueta de dos caracteres que se muestra en los mapas. Envía siempre la hora de la posición: los reintentos de QoS 1, los búferes offline y el mensaje retenido hacen que un suscriptor pueda recibir una posición mucho después de medirse.
Publica la ubicación como retenida para que un mapa que se abra más tarde muestre al instante la última posición conocida. Consulta mensajes retenidos para ver cómo funciona la retención y cómo borrarla.
El firmware del ESP32
Conecta el TX del módulo al GPIO16 del ESP32 y el RX al GPIO17, aliméntalo a 3,3 V (revisa tu placa) e instala TinyGPSPlus y PubSubClient desde el gestor de librerías de Arduino. Si nunca has montado un proyecto MQTT con ESP32, empieza por ESP32 MQTT con 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;
}
}Algunas decisiones de diseño que conviene mantener:
- Informar por distancia. Publicar cada segundo malgasta datos cuando está aparcado; publicar cada minuto hace que la ruta salga a saltos en movimiento. Distancia más latido te da ambas cosas.
- Last Will en el topic de estado. Si se pierde la alimentación o la cobertura, el broker publica
offlinetras aproximadamente 1,5 × keep alive. El mapa puede mostrar el marcador en gris. Más en Last Will and Testament. - TLS desde el principio.
setInsecure()vale en tu mesa; en dispositivos reales carga la CA raíz del broker consetCACert(). Los datos de ubicación son datos personales. - El bucle de conexión bloquea mientras no hay red, así que los bytes NMEA se acumulan en el búfer de la UART. En un rastreador de producción, reconecta sin bloquear y guarda las posiciones en un búfer para que la ruta no tenga huecos.
Prueba sin hardware: un simulador en Python
Antes de soldar nada, simula un rastreador. Este script (paho-mqtt 2.x) recorre una ruta por Berlín y publica el mismo payload y los mismos topics que el 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()Observa cómo llegan los mensajes desde una terminal:
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
-u "$MQTT_USER" -P "$MQTT_PASS" -t 'testmqtt/gps/#' -vO abre el cliente MQTT online y suscríbete a testmqtt/gps/#. Como la ubicación está retenida, un suscriptor que se conecte cuando el simulador ya ha terminado sigue recibiendo la última posición.
Un mapa en vivo en el navegador
Los navegadores hablan MQTT sobre WebSockets. Esta página estática usa Leaflet con teselas de OpenStreetMap y MQTT.js 5 en el puerto 8084 (wss, ruta /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/+/+ coincide exactamente con dos niveles bajo el prefijo, así que recibe las ubicaciones pero no los topics /status, que están un nivel más abajo. Suscríbete a ellos por separado si quieres atenuar los rastreadores desconectados. Para un panel más completo con histórico y gráficas, consulta cómo construir un dashboard MQTT en vivo.
Seguridad y privacidad
Un flujo de ubicaciones revela dónde duerme, trabaja y viaja una persona. Trátalo en consecuencia:
- Un usuario por dispositivo con una ACL que solo permita publicar en sus propios topics
.../<user>/<device>. - Un usuario de solo lectura para mapas y dashboards, limitado a los usuarios que puede mostrar.
- TLS en todas partes: puerto 8883 para dispositivos, 8084 (
wss) para navegadores. - Nada de comodines para todos. Un suscriptor con
#ve todos los rastreadores del broker. - Política de retención para el histórico almacenado. Consulta MQTT y RGPD.
Lista de comprobación
- Ejecuta el simulador y confirma que el marcador se mueve en el mapa.
- Recarga la página del mapa: la última posición debe aparecer al instante (mensaje retenido).
- Detén el simulador con Ctrl+C: tras unas 1,5 × keep alive el topic de estado cambia a
offline(Last Will). - Entra en el mapa con el usuario del dispositivo e intenta publicar en otro topic: el broker debe rechazarlo.
- Saca el dispositivo real al exterior y compara
tstcon la hora de recepción para ver tu retardo extremo a extremo.
Ver los mensajes del rastreador en el navegador
Un rastreador construido así es lo bastante pequeño como para entenderlo por completo: unas decenas de líneas de firmware, un topic retenido por dispositivo, un Last Will para la disponibilidad y una página de mapa estática. Cuando el simulador, el mapa y las ACLs se comporten como esperas, cambia al hardware real y apúntalo todo a tu propio broker.
Preguntas frecuentes
¿Por qué usar MQTT en lugar de HTTP para un rastreador GPS?
Un rastreador envía mensajes pequeños con frecuencia por enlaces poco fiables. MQTT mantiene una sola conexión abierta, añade solo unos pocos bytes por mensaje, puede retener la última posición para nuevos suscriptores y avisa de un rastreador caído mediante un mensaje Last Will. HTTP necesita una petición nueva con todas sus cabeceras para cada posición.
¿Tengo que usar el formato JSON de OwnTracks?
No, cualquier JSON sirve. Usar el formato location de OwnTracks (_type, lat, lon, tst, acc…) es práctico porque las apps de OwnTracks, el OwnTracks Recorder y varias integraciones de domótica ya lo entienden, así que tu rastreador casero y los móviles pueden compartir un mapa.
¿Con qué frecuencia debe publicar un rastreador GPS?
Publica según el movimiento y no con un reloj fijo: por ejemplo cuando el dispositivo se ha movido más de 25–50 metros, más un latido cada uno o dos minutos mientras está parado. Así el mapa es fluido en movimiento y ahorras datos y batería cuando está aparcado.

