Um rastreador GPS com MQTT tem três partes pequenas: um dispositivo que lê a posição GPS e a publica como JSON, um broker que a encaminha e um mapa que assina o topic e move um marcador. Como o MQTT mantém uma única conexão aberta e adiciona poucos bytes por mensagem, ele é ideal para rastreadores que reportam com frequência via Wi-Fi ou rede celular.
Este tutorial constrói as três: firmware para ESP32 com um módulo GPS serial, um simulador em Python para você testar sem hardware e um mapa no navegador com Leaflet e MQTT.js. O payload segue o formato location do OwnTracks, então celulares com OwnTracks podem compartilhar o mesmo mapa.
Arquitetura
GPS module ──NMEA──▶ ESP32 ──MQTT/TLS 8883──▶ broker ──WSS 8084──▶ browser map
│
└──▶ recorder / database (optional)| Parte | Função | O que escolher |
|---|---|---|
| Módulo GPS | Emite sentenças NMEA via UART | u-blox NEO-6M/M8N ou similar, 9600 baud |
| Dispositivo | Interpreta NMEA e decide quando publicar | ESP32 (Wi-Fi) ou ESP32 + modem LTE |
| Broker | Encaminha, retém a última posição, aplica ACLs | Qualquer broker MQTT 3.1.1/5 com TLS |
| Mapa | Assina via WebSockets e desenha marcadores | Leaflet + MQTT.js em uma página estática |
| Armazenamento (opcional) | Guarda o histórico | OwnTracks Recorder, InfluxDB, PostgreSQL |
Topics e payload
Use um topic por rastreador e um topic de status separado para a disponibilidade:
testmqtt/gps/<user>/<device> location JSON, retained
testmqtt/gps/<user>/<device>/status "online" / "offline", retained (Last Will)No seu próprio broker você normalmente usaria owntracks/<user>/<device>, que é o que os apps OwnTracks e o Recorder esperam. Aqui usamos o prefixo testmqtt/ para que os exemplos possam rodar com segurança em um endpoint de testes compartilhado. Você pode verificar quais assinaturas casam com qual rastreador no topic matcher.
O payload é um subconjunto da mensagem location do OwnTracks:
{
"_type": "location",
"tid": "t1",
"lat": 52.520008,
"lon": 13.404954,
"alt": 38,
"vel": 12,
"cog": 270,
"acc": 6,
"tst": 1792236000
}lat/lon são graus decimais, tst é o timestamp Unix em UTC da posição (não da publicação), vel é em km/h, cog é o rumo em graus e acc uma estimativa de precisão em metros. tid é um rótulo de dois caracteres exibido nos mapas. Sempre envie o horário da posição: retentativas de QoS 1, buffers offline e a mensagem retida fazem com que um assinante possa receber uma posição muito depois de ela ter sido medida.
Publique a localização como retida para que um mapa aberto depois mostre imediatamente a última posição conhecida. Veja mensagens retidas para entender como a retenção funciona e como limpá-la.
O firmware do ESP32
Ligue o TX do módulo ao GPIO16 do ESP32 e o RX ao GPIO17, alimente-o com 3,3 V (confira sua placa) e instale TinyGPSPlus e PubSubClient pelo gerenciador de bibliotecas do Arduino. Se você nunca montou um projeto MQTT com ESP32, comece por ESP32 MQTT com 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;
}
}Algumas decisões de design que vale manter:
- Envio por distância. Publicar a cada segundo desperdiça dados quando o dispositivo está parado; publicar a cada minuto deixa o trajeto picotado em movimento. Distância mais heartbeat entrega as duas coisas.
- Last Will no topic de status. Se a energia ou o sinal acabar, o broker publica
offlineapós cerca de 1,5 × keep alive. O mapa pode deixar o marcador cinza. Mais em Last Will and Testament. - TLS desde o início.
setInsecure()serve na bancada; em dispositivos reais carregue a CA raiz do broker comsetCACert(). Dados de localização são dados pessoais. - O loop de conexão bloqueia enquanto está offline, então os bytes NMEA se acumulam no buffer da UART. Em um rastreador de produção, reconecte sem bloquear e guarde as posições em buffer para o trajeto não ter lacunas.
Teste sem hardware: um simulador em Python
Antes de soldar qualquer coisa, simule um rastreador. Este script (paho-mqtt 2.x) percorre uma rota em Berlim e publica o mesmo payload e os mesmos topics do 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()Acompanhe a chegada das mensagens em um terminal:
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
-u "$MQTT_USER" -P "$MQTT_PASS" -t 'testmqtt/gps/#' -vOu abra o cliente MQTT online e assine testmqtt/gps/#. Como a localização é retida, um assinante que se conectar depois que o simulador parar ainda recebe a última posição.
Um mapa ao vivo no navegador
Navegadores falam MQTT sobre WebSockets. Esta página estática usa Leaflet com tiles do OpenStreetMap e MQTT.js 5 na porta 8084 (wss, caminho /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/+/+ casa exatamente com dois níveis abaixo do prefixo, então recebe as localizações, mas não os topics /status, que ficam um nível abaixo. Assine-os separadamente se quiser deixar cinza os rastreadores offline. Para um painel mais completo com histórico e gráficos, veja como criar um dashboard MQTT ao vivo.
Segurança e privacidade
Um fluxo de localizações revela onde uma pessoa dorme, trabalha e viaja. Trate-o de acordo:
- Um usuário por dispositivo com uma ACL que só permita publicar nos próprios topics
.../<user>/<device>. - Um usuário somente leitura para mapas e dashboards, limitado aos usuários que ele pode exibir.
- TLS em todo lugar: porta 8883 para dispositivos, 8084 (
wss) para navegadores. - Nada de curinga para todos. Um assinante com
#vê todos os rastreadores do broker. - Política de retenção para o histórico armazenado. Veja MQTT e LGPD/GDPR.
Checklist de testes
- Rode o simulador e confirme que o marcador se move no mapa.
- Recarregue a página do mapa: a última posição deve aparecer na hora (mensagem retida).
- Interrompa o simulador com Ctrl+C: após cerca de 1,5 × keep alive o topic de status muda para
offline(Last Will). - Entre no mapa com o usuário do dispositivo e tente publicar em outro topic: o broker deve recusar.
- Leve o dispositivo real para fora e compare
tstcom o horário de recebimento para ver seu atraso de ponta a ponta.
Veja as mensagens do rastreador no navegador
Um rastreador construído assim é pequeno o suficiente para ser entendido por completo: algumas dezenas de linhas de firmware, um topic retido por dispositivo, um Last Will para disponibilidade e uma página de mapa estática. Quando o simulador, o mapa e as ACLs se comportarem como esperado, troque para o hardware real e aponte tudo para o seu próprio broker.
Perguntas frequentes
Por que usar MQTT em vez de HTTP em um rastreador GPS?
Um rastreador envia mensagens pequenas com frequência por links instáveis. O MQTT mantém uma única conexão aberta, adiciona poucos bytes por mensagem, pode reter a última posição para novos assinantes e avisa quando um rastreador cai por meio de uma mensagem Last Will. O HTTP precisa de uma nova requisição com todos os cabeçalhos a cada posição.
Preciso usar o formato JSON do OwnTracks?
Não, qualquer JSON funciona. Usar o formato location do OwnTracks (_type, lat, lon, tst, acc…) é prático porque os apps OwnTracks, o OwnTracks Recorder e várias integrações de automação residencial já o entendem, então seu rastreador caseiro e os celulares podem compartilhar um mapa.
Com que frequência um rastreador GPS deve publicar?
Publique conforme o movimento, e não em um intervalo fixo: por exemplo quando o dispositivo se deslocou mais de 25–50 metros, mais um heartbeat a cada um ou dois minutos enquanto estiver parado. Assim o mapa fica fluido em movimento e você economiza dados e bateria quando ele está estacionado.

