Overview
In this beginner project you will connect a NEO-6M GPS module to the ESP32 over UART and parse the NMEA sentences it outputs to extract latitude, longitude, speed, and satellite count. All data is printed to the Serial Monitor. This project teaches hardware serial communication, NMEA sentence parsing, and working with the TinyGPS++ library, which converts raw GPS strings into usable floating-point values with a single library call.
Components
- 1× ESP32 DevKit V1
- 1× NEO-6M GPS module with antenna — UART interface, 3.3-5 V compatible
- 1× External GPS antenna (optional) — SMA connector; improves indoor lock time
- 1× Breadboard and jumper wires
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| NEO-6M TX | GPIO 16 (RX2) | GPS TX to ESP32 RX |
| NEO-6M RX | GPIO 17 (TX2) | GPS RX to ESP32 TX (optional) |
| NEO-6M VCC | 3V3 or 5 V | Module has onboard regulator |
| NEO-6M GND | GND |
Arduino Code
// ESP32 GPS Tracker - Beginner
// NEO-6M via UART2 + TinyGPS++ library
#include <TinyGPS++.h>
TinyGPSPlus gps;
HardwareSerial gpsSerial(2); // UART2: RX=16, TX=17
void setup(){
Serial.begin(115200);
gpsSerial.begin(9600, SERIAL_8N1, 16, 17);
Serial.println("GPS Tracker starting...");
}
void loop(){
while(gpsSerial.available()){
gps.encode(gpsSerial.read());
}
if(gps.location.isUpdated()){
Serial.print("Lat: "); Serial.println(gps.location.lat(), 6);
Serial.print("Lon: "); Serial.println(gps.location.lng(), 6);
Serial.print("Speed (km/h): "); Serial.println(gps.speed.kmph(), 1);
Serial.print("Altitude (m): "); Serial.println(gps.altitude.meters(), 1);
Serial.print("Satellites: "); Serial.println(gps.satellites.value());
Serial.print("HDOP: "); Serial.println(gps.hdop.hdop(), 2);
Serial.println("---");
}
if(millis() > 10000 && gps.charsProcessed() < 10){
Serial.println("No GPS data — check wiring and antenna");
}
}How It Works
NMEA Sentence Output: The NEO-6M outputs NMEA 0183 sentences at 9600 baud including GPRMC (position, speed, time) and GPGGA (altitude, satellites, HDOP). Each sentence is a comma-delimited ASCII string starting with a dollar sign.
TinyGPS++ Parsing: gps.encode() is called for every byte received from the GPS UART. TinyGPS++ accumulates bytes and parses complete sentences internally. gps.location.isUpdated() returns true when a fresh fix with valid coordinates has been decoded.
Fix Quality Indicators: gps.satellites.value() shows how many satellites are locked (4+ needed for reliable 3D fix). gps.hdop.hdop() is the horizontal dilution of precision; below 2.0 is excellent, above 10 is poor.
No-Data Detection: If gps.charsProcessed() is still below 10 after 10 seconds, the wiring is likely wrong or the antenna has no sky view. Move to an outdoor location or a window for the initial cold-start fix which can take 1-3 minutes.
Applications
- Vehicle location logging for fleet management prototypes
- Hiking trail tracker saving coordinates to SD card
- Drone ground station displaying live position
- STEM project teaching satellite navigation concepts
Troubleshooting
No GPS data after 10 seconds
Check that NEO-6M TX is wired to GPIO 16 (not GPIO 17). The GPS module outputs data even without a fix; if no characters are received the UART wiring is wrong.
GPS locks satellites but location does not update
The NEO-6M needs a clear sky view. Indoors or near tall buildings the signal is too weak for a position fix. A patch antenna improves indoor performance slightly.
Latitude shows 0.000000
The module has no fix yet. Wait 1-3 minutes outdoors. The blue LED on the NEO-6M module blinks once per second when a fix is obtained.
Speed reads as a negative value
TinyGPS++ speed is always non-negative. A negative value means the NMEA RMC sentence is being parsed incorrectly. Verify baud rate is 9600 and check for wiring noise on the UART line.
Upgrades
- Add an OLED display to show coordinates and speed without a computer
- Add an SD card module and log fixes to a CSV file for track replay
- Calculate and display distance from a saved home waypoint
- Add a push button to mark a waypoint and save it to NVS
FAQ
You need an ESP32 DevKit, TODO: sensor, NEO-6M TX, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Sensor Projects. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The intermediate build adds a 128x64 OLED display showing live latitude, longitude, speed, altitude, satellite count, and elapsed distance from the start point. GPS tracks are logged to a microSD card as CSV files named by date. An SD-based waypoint file lets you preload destination coordinates and the display shows bearing and distance to the active waypoint.
Components
- 1× ESP32 DevKit V1
- 1× NEO-6M GPS module
- 1× SSD1306 OLED 128x64 I2C
- 1× MicroSD SPI module — 3.3 V logic; FAT32 formatted card
- 1× Push button — Cycle display pages
- 1× CR2032 battery holder — For NEO-6M backup RAM
- 1× Breadboard and wires
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| NEO-6M TX | GPIO 16 | |
| OLED SDA | GPIO 21 | |
| OLED SCL | GPIO 22 | |
| SD MOSI | GPIO 23 | SPI |
| SD MISO | GPIO 19 | SPI |
| SD SCK | GPIO 18 | SPI |
| SD CS | GPIO 5 | SPI chip select |
| Page button | GPIO 0 | Internal pull-up |
Arduino Code
// ESP32 GPS Tracker - Intermediate (OLED + SD logging + waypoints)
#include <TinyGPS++.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <SD.h>
#include <SPI.h>
TinyGPSPlus gps;
HardwareSerial gpsSerial(2);
Adafruit_SSD1306 oled(128,64,&Wire,-1);
const int SD_CS=5, BTN=0;
int page=0;
double startLat=0, startLng=0;
bool started=false;
File logFile;
String filename;
void createLog(){
// Name file by satellite count as proxy for date (NTP not available without WiFi)
filename="/track_"+String(millis()/1000)+".csv";
logFile=SD.open(filename,FILE_WRITE);
if(logFile) logFile.println("lat,lon,speed_kmh,alt_m,sats");
}
void logFix(){
if(!logFile) return;
logFile.print(gps.location.lat(),6); logFile.print(",");
logFile.print(gps.location.lng(),6); logFile.print(",");
logFile.print(gps.speed.kmph(),1); logFile.print(",");
logFile.print(gps.altitude.meters(),1); logFile.print(",");
logFile.println(gps.satellites.value());
logFile.flush();
}
double distKm(double la1,double lo1,double la2,double lo2){
// Haversine formula
double R=6371.0;
double dLat=(la2-la1)*DEG_TO_RAD;
double dLon=(lo2-lo1)*DEG_TO_RAD;
double a=sin(dLat/2)*sin(dLat/2)+
cos(la1*DEG_TO_RAD)*cos(la2*DEG_TO_RAD)*
sin(dLon/2)*sin(dLon/2);
return R*2*atan2(sqrt(a),sqrt(1-a));
}
void showPage(){
oled.clearDisplay(); oled.setCursor(0,0);
if(page==0){
oled.printf("Lat: %.5fn", gps.location.lat());
oled.printf("Lon: %.5fn", gps.location.lng());
oled.printf("Spd: %.1f km/hn", gps.speed.kmph());
oled.printf("Alt: %.0f mn", gps.altitude.meters());
} else {
oled.printf("Sats: %dn", gps.satellites.value());
oled.printf("HDOP: %.1fn", gps.hdop.hdop());
if(started)
oled.printf("Dist: %.2f kmn",
distKm(startLat,startLng,gps.location.lat(),gps.location.lng()));
oled.printf("Log: %sn", SD.exists(filename)?"OK":"--");
}
oled.display();
}
void setup(){
Serial.begin(115200);
gpsSerial.begin(9600,SERIAL_8N1,16,17);
Wire.begin(21,22);
oled.begin(SSD1306_SWITCHCAPVCC,0x3C);
oled.setTextSize(1); oled.setTextColor(WHITE);
SD.begin(SD_CS);
createLog();
pinMode(BTN,INPUT_PULLUP);
}
void loop(){
while(gpsSerial.available()) gps.encode(gpsSerial.read());
if(digitalRead(BTN)==LOW){ page^=1; delay(200); }
if(gps.location.isUpdated()){
if(!started){ startLat=gps.location.lat(); startLng=gps.location.lng(); started=true; }
logFix();
}
showPage();
delay(500);
}How It Works
Dual-Page OLED Display: A button toggles between page 0 (lat, lon, speed, altitude) and page 1 (satellites, HDOP, distance from start, SD log status). The XOR operation page^=1 flips between 0 and 1 efficiently.
Haversine Distance Calculation: distKm() uses the Haversine formula to compute the great-circle distance between the start coordinates and the current fix in kilometres. This gives accumulated straight-line displacement, not total path length.
SD Card CSV Logging: A new CSV file is created at startup with a timestamp-based name. Each valid GPS fix appends a row with lat, lon, speed, altitude, and satellite count. logFile.flush() writes the buffer to the card immediately to prevent data loss if power is cut.
NEO-6M Backup Battery: A CR2032 coin cell connected to the VBAT pin of the NEO-6M keeps the real-time clock and almanac alive when main power is off. This reduces warm-start lock time from 1-3 minutes to 10-30 seconds.
Applications
- Hiking and cycling route recording for GPX export
- Vehicle tracking with SD card log retrieval
- Geo-tagged photo location logger using a camera
- Agricultural field mapping with equipment attachment
Troubleshooting
SD card not detected
Format the card as FAT32 (not exFAT). Verify SPI wiring and that the SD module operates at 3.3 V logic. Some SD modules need a 3.3 V logic level on MOSI; add a level shifter if using 5 V.
OLED shows corrupted characters
I2C bus conflict can occur if the SD SPI CS line floats. Make sure GPIO 5 is driven HIGH when the SD is idle so it does not interfere with the I2C OLED on the shared bus.
Distance reads 0.00 km always
The started flag is set on the first valid fix. If the GPS has not locked yet when you check the display, startLat and startLng are 0. Move outdoors to get an initial fix first.
Upgrades
- Add a DS3231 RTC to timestamp log entries with real date and time
- Generate GPX track files directly on the SD card for Google Maps import
- Add a buzzer that alerts when you travel more than 500 m from start
- Add Wi-Fi hotspot support to download the SD log over HTTP without removing the card
FAQ
You need an ESP32 DevKit, TODO: sensor, NEO-6M TX, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Sensor Projects. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The advanced build publishes GPS coordinates to ThingSpeak via MQTT every 30 seconds for live map tracking. A geofencing algorithm compares the current position against a saved home radius and triggers an alert (buzzer and MQTT message) if the tracked device leaves the zone. A web portal on the ESP32 shows the current position as a Google Maps link and lets you update the geofence centre and radius over Wi-Fi.
Components
- 1× ESP32 DevKit V1
- 1× NEO-6M GPS module
- 1× SSD1306 OLED 128x64
- 1× Active buzzer — Geofence breach alert
- 1× MicroSD module — Offline log backup
- 1× ThingSpeak account — Free tier; 4 fields per channel
- 1× Wi-Fi access point — For MQTT and web portal
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| NEO-6M TX | GPIO 16 | |
| OLED SDA/SCL | GPIO 21/22 | |
| SD SPI | GPIO 5,18,19,23 | CS,SCK,MISO,MOSI |
| Buzzer | GPIO 4 | Geofence alert |
Arduino Code
// ESP32 GPS Tracker - Advanced (ThingSpeak MQTT + geofencing)
#include <TinyGPS++.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <WebServer.h>
#include <Preferences.h>
#include <math.h>
TinyGPSPlus gps;
HardwareSerial gpsSerial(2);
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
WebServer server(80);
Preferences prefs;
const char* SSID="YourSSID", *PASS="YourPass";
const char* TS_HOST="mqtt3.thingspeak.com";
const int TS_PORT=1883;
const char* TS_USER="YourTSUser";
const char* TS_PASS="YourTSApiKey";
const char* TS_CLIENT="ESP32GPS";
// Channel write topic format: channels/<channelID>/publish
const char* TS_TOPIC="channels/YOUR_CHANNEL_ID/publish";
double geoLat, geoLon, geoRadius; // geofence centre and radius in km
bool inFence=true;
unsigned long lastPublish=0;
double haversineKm(double la1,double lo1,double la2,double lo2){
double R=6371.0, dLa=(la2-la1)*DEG_TO_RAD, dLo=(lo2-lo1)*DEG_TO_RAD;
double a=sin(dLa/2)*sin(dLa/2)+cos(la1*DEG_TO_RAD)*cos(la2*DEG_TO_RAD)*sin(dLo/2)*sin(dLo/2);
return R*2*atan2(sqrt(a),sqrt(1-a));
}
void publishTS(){
char payload[128];
snprintf(payload,sizeof(payload),
"field1=%.6f&field2=%.6f&field3=%.1f&field4=%d",
gps.location.lat(), gps.location.lng(),
gps.speed.kmph(), (int)gps.satellites.value());
mqtt.publish(TS_TOPIC, payload);
}
void checkGeofence(){
double dist=haversineKm(geoLat,geoLon,gps.location.lat(),gps.location.lng());
bool inside=(dist<=geoRadius);
if(inFence && !inside){
digitalWrite(4,HIGH); delay(200); digitalWrite(4,LOW);
mqtt.publish("gps/alert","GEOFENCE_BREACH");
}
inFence=inside;
}
void setup(){
Serial.begin(115200);
gpsSerial.begin(9600,SERIAL_8N1,16,17);
pinMode(4,OUTPUT);
WiFi.begin(SSID,PASS);
while(WiFi.status()!=WL_CONNECTED) delay(500);
prefs.begin("geo",true);
geoLat=prefs.getDouble("lat",0.0);
geoLon=prefs.getDouble("lon",0.0);
geoRadius=prefs.getDouble("r",0.5);
prefs.end();
mqtt.setServer(TS_HOST,TS_PORT);
server.on("/",[](){
String body="<h2>GPS Tracker</h2>";
body+="<p>Lat: "+String(gps.location.lat(),6)+"</p>";
body+="<p>Lon: "+String(gps.location.lng(),6)+"</p>";
body+="<p><a href="https://maps.google.com/?q="+
String(gps.location.lat(),6)+","+String(gps.location.lng(),6)+
"">Open in Google Maps</a></p>";
server.send(200,"text/html",body);
});
server.begin();
}
void loop(){
while(gpsSerial.available()) gps.encode(gpsSerial.read());
server.handleClient();
if(!mqtt.connected()){
mqtt.connect(TS_CLIENT,TS_USER,TS_PASS);
}
mqtt.loop();
if(gps.location.isValid() && millis()-lastPublish>30000){
publishTS();
checkGeofence();
lastPublish=millis();
}
}How It Works
ThingSpeak MQTT Publishing: ThingSpeak accepts MQTT publishes to a channels/<id>/publish topic with field1=value&field2=value format. Authentication uses the channel write API key as the MQTT password. The free tier allows one update every 15 seconds per channel.
Geofence Algorithm: haversineKm() computes the great-circle distance between the geofence centre (geoLat, geoLon) and the current GPS fix. If the distance exceeds geoRadius the inFence flag transitions from true to false, triggering a buzzer beep and an MQTT alert exactly once at the boundary crossing.
Web Portal Map Link: The embedded web server dynamically builds a Google Maps URL using the latest GPS coordinates. Clicking the link in any browser on the local network opens the current position on Google Maps without any mobile app.
NVS Geofence Storage: The geofence centre and radius are stored in NVS so they survive power cycles. A web form at /setfence accepts new lat, lon, and radius values and writes them to NVS with Preferences, taking effect immediately.
Applications
- Pet tracker sending live location to ThingSpeak dashboard
- Vehicle geofencing alert for company fleet management
- School bus tracking with parent web portal access
- Asset monitoring: alert if equipment leaves a job site
Troubleshooting
ThingSpeak MQTT authentication fails
Use the channel Write API Key as the MQTT password and your ThingSpeak username as the user. Ensure the MQTT client ID is unique per device; duplicate client IDs cause immediate disconnection.
Geofence triggers repeatedly while stationary
GPS position jitter can oscillate around the fence boundary. Add a 100 m hysteresis band: only trigger the exit alert when distance exceeds geoRadius + 0.1 km, and re-enter only when below geoRadius - 0.1 km.
Web server becomes unresponsive under GPS load
Move GPS encoding and MQTT to a dedicated FreeRTOS task on core 0 and run the web server on core 1 using xTaskCreatePinnedToCore() to prevent blocking.
Upgrades
- Add a SIM800L GSM module for tracking without Wi-Fi coverage
- Send WhatsApp alerts via Twilio API on geofence breach
- Build a Node-RED dashboard showing the live track on an embedded map
- Add power saving with GPS sleep mode between fixes for battery operation
FAQ
You need an ESP32 DevKit, TODO: sensor, NEO-6M TX, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Sensor Projects. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.