← All projects
Sensors Intermediate

NEO-6M GPS Tracker: Coordinates, Speed and a Pin on the Map

A NEO-6M module locks onto satellites, TinyGPSPlus parses NMEA and the ESP32 shows coordinates, speed, altitude and distance travelled on a web page with an OpenStreetMap link.

Difficulty
Intermediate
Time
1–2 h
Category
Sensors

What you get

A GPS module sends text lines in NMEA format ($GPRMC,..., $GPGGA,...) every second. The TinyGPSPlus library turns them into meaningful numbers: latitude, longitude, altitude, speed, course, satellite count and exact UTC time. We'll show all of that on a web page, add a distance-travelled counter and an "Open on map" link.

Parts

  • ESP32 DevKit
  • u-blox NEO-6M module (GY-GPS6MV2) with a ceramic antenna
  • For the first run — a spot by a window or outdoors

Wiring

The module talks UART. The ESP32 has three hardware UARTs — we'll use UART2.

NEO-6M ESP32
VCC 3V3 (or 5V — most modules have a regulator)
GND GND
TX GPIO 16 (RX2)
RX GPIO 17 (TX2)

The module's TX goes to the board's RX and vice versa.

Library

TinyGPSPlus (Mikal Hart).

Code

#include <WiFi.h>
#include <WebServer.h>
#include <TinyGPSPlus.h>

const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";

const int GPS_RX = 16;   // to the module's TX
const int GPS_TX = 17;   // to the module's RX

HardwareSerial gpsSerial(2);
TinyGPSPlus gps;
WebServer server(80);

double lastLat = 0, lastLng = 0;
bool haveLast = false;
double distanceM = 0;
unsigned long lastTrack = 0;

const char PAGE[] = R"rawliteral(
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GPS</title>
<style>body{font-family:system-ui;max-width:480px;margin:30px auto;padding:0 16px}
td{padding:6px 10px}td:first-child{color:#667}a{font-size:1.1rem}</style>
</head><body><h2>GPS tracker</h2>
<table id="t"></table>
<p><a id="map" target="_blank">Open on map</a></p>
<script>
async function update() {
  const d = await (await fetch('/data')).json();
  const rows = [
    ['Fix', d.fix ? 'yes' : 'searching…'], ['Satellites', d.sats],
    ['Latitude', d.lat], ['Longitude', d.lng], ['Altitude', d.alt + ' m'],
    ['Speed', d.kmph + ' km/h'], ['Distance', (d.dist / 1000).toFixed(2) + ' km'],
    ['UTC', d.time]
  ];
  document.getElementById('t').innerHTML = rows.map(r => `<tr><td>${r[0]}</td><td>${r[1]}</td></tr>`).join('');
  const a = document.getElementById('map');
  a.href = d.fix ? `https://www.openstreetmap.org/?mlat=${d.lat}&mlon=${d.lng}#map=17/${d.lat}/${d.lng}` : '#';
}
update(); setInterval(update, 2000);
</script></body></html>
)rawliteral";

void handleData() {
  bool fix = gps.location.isValid() && gps.location.age() < 5000;
  char json[256];
  snprintf(json, sizeof(json),
           "{\"fix\":%s,\"sats\":%u,\"lat\":%.6f,\"lng\":%.6f,\"alt\":%.0f,"
           "\"kmph\":%.1f,\"dist\":%.0f,\"time\":\"%02d:%02d:%02d\"}",
           fix ? "true" : "false", (unsigned)gps.satellites.value(),
           gps.location.lat(), gps.location.lng(), gps.altitude.meters(),
           gps.speed.kmph(), distanceM,
           gps.time.hour(), gps.time.minute(), gps.time.second());
  server.send(200, "application/json", json);
}

void setup() {
  Serial.begin(115200);
  gpsSerial.begin(9600, SERIAL_8N1, GPS_RX, GPS_TX);

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println(WiFi.localIP());

  server.on("/", [] { server.send(200, "text/html", PAGE); });
  server.on("/data", handleData);
  server.begin();
}

void loop() {
  // Feed every byte from the module to the parser
  while (gpsSerial.available()) gps.encode(gpsSerial.read());
  server.handleClient();

  // Add a path segment every 5 s; ignore small jitter while standing still
  if (millis() - lastTrack > 5000 && gps.location.isValid() && gps.hdop.hdop() < 3.0) {
    lastTrack = millis();
    double lat = gps.location.lat(), lng = gps.location.lng();
    if (haveLast) {
      double d = TinyGPSPlus::distanceBetween(lastLat, lastLng, lat, lng);
      if (d > 5) {
        distanceM += d;
        lastLat = lat;
        lastLng = lng;
      }
    } else {
      lastLat = lat;
      lastLng = lng;
      haveLast = true;
    }
  }

  if (millis() > 10000 && gps.charsProcessed() < 10) {
    Serial.println("No data from GPS — check wiring (TX↔RX)");
    delay(2000);
  }
}

First run

  • A cold start takes 30 s to several minutes — the module has to download the almanac from the satellites. It's faster under open sky; indoors it may never get a fix.
  • Once it has a fix, the module's LED starts blinking (once a second).
  • The backup battery on the module keeps the almanac — later starts take seconds.

Accuracy

  • The NEO-6M's typical error is 2.5–5 m in open space.
  • hdop (horizontal dilution of precision) < 2 is excellent, 2–5 is fine, > 5 means don't trust the coordinates. We ignore points with HDOP ≥ 3 so jitter while standing still doesn't inflate the distance.

Ideas

  • A standalone tracker: GPS + SD card logging a GPX track every 10 s, then import into Google Earth or Strava.
  • A geofence: a Telegram alert when your bike leaves the yard (see the "Telegram Bot" project).
  • A precise clock without internet — GPS time is accurate to microseconds.
  • To go Wi-Fi-free: output to the OLED display from the "Accurate OLED Clock" project.