Tutoriais

Construa um rastreador GPS com MQTT: dispositivo, broker e mapa ao vivo

Construa um rastreador GPS com MQTT: ESP32 e módulo GPS publicando JSON no formato OwnTracks, um simulador em Python para testes e um mapa Leaflet ao vivo.

Atualizado · 10 min de leitura

Construa um rastreador GPS com MQTT: dispositivo, broker e mapa ao vivo

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

Fluxo de dados
GPS module ──NMEA──▶ ESP32 ──MQTT/TLS 8883──▶ broker ──WSS 8084──▶ browser map
                                               │
                                               └──▶ recorder / database (optional)
ParteFunçãoO que escolher
Módulo GPSEmite sentenças NMEA via UARTu-blox NEO-6M/M8N ou similar, 9600 baud
DispositivoInterpreta NMEA e decide quando publicarESP32 (Wi-Fi) ou ESP32 + modem LTE
BrokerEncaminha, retém a última posição, aplica ACLsQualquer broker MQTT 3.1.1/5 com TLS
MapaAssina via WebSockets e desenha marcadoresLeaflet + MQTT.js em uma página estática
Armazenamento (opcional)Guarda o históricoOwnTracks Recorder, InfluxDB, PostgreSQL

Topics e payload

Use um topic por rastreador e um topic de status separado para a disponibilidade:

Estrutura de topics
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:

Payload de localização
{
  "_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.

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;
  }
}

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 offline apó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 com setCACert(). 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:

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()

Acompanhe a chegada das mensagens em um terminal:

Assinar todos os rastreadores
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
  -u "$MQTT_USER" -P "$MQTT_PASS" -t 'testmqtt/gps/#' -v

Ou 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):

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/+/+ 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:

  1. Um usuário por dispositivo com uma ACL que só permita publicar nos próprios topics .../<user>/<device>.
  2. Um usuário somente leitura para mapas e dashboards, limitado aos usuários que ele pode exibir.
  3. TLS em todo lugar: porta 8883 para dispositivos, 8084 (wss) para navegadores.
  4. Nada de curinga para todos. Um assinante com # vê todos os rastreadores do broker.
  5. 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 tst com 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.