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.
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.