How to Build a Pocket GPS Dashboard
A portable tracker that hosts speed, distance, and fix status on its own local page
Updated

What you'll build
This guide builds a pocket GPS dashboard: a small battery-powered tracker that broadcasts its own Wi-Fi access point and serves a phone-friendly page at 192.168.4.1. Connect to it from any phone and you get live speed, satellite count, latitude and longitude, fix status, and accumulated trip distance — all updating every two seconds without a page reload.
The build uses an ESP32 development board, NEO-6M GPS module, 1000 mAh LiPo, TP4056 charger board, and the on-board status LED. There is no internet connection required. The ESP32 itself is the hotspot and the server. The charger wiring is covered so the project can leave the desk.
GPS needs patience on first lock. The guide tells you how to confirm the dashboard is running while the NEO-6M is still searching, so you know the board side is working before heading outdoors.
What you are building
The firmware has four main jobs:
- Read NMEA sentences from the NEO-6M over UART1 (GPIO16 RX, GPIO17 TX) using TinyGPSPlus,
- Host a soft-access-point named
GPS-Dashboardwith no password and serve the dashboard at 192.168.4.1, - Blink the status LED at 500 ms while no fix is held and keep it solid once one is acquired,
- Accumulate trip distance using the Haversine formula and expose a
/resetendpoint for the dashboard button.
The dashboard is not out of scope — it runs on the ESP32. What is out of scope is internet connectivity, cloud logging, and multi-device syncing.
Upload and calibrate
Open the project in Schematik and click Deploy using Chrome or Edge. Once the firmware is uploaded, open Serial Monitor at 115200 baud. Within a second or two you should see the access point name and the URL http://192.168.4.1.
On your phone, join the GPS-Dashboard Wi-Fi network (no password). If your phone warns about no internet connection, choose "Use this network anyway". Open a browser and go to http://192.168.4.1. The dashboard loads immediately and starts polling /data every two seconds.
The status LED blinks until the NEO-6M acquires a fix. Outdoors with a clear sky view, first lock usually takes 30–90 seconds. A cold start on a brand new module can take up to five minutes. The dashboard shows "Searching…" in red until a fix is held, then switches to green and shows coordinates and speed.
The firmware ignores position updates smaller than 2 metres (MIN_MOVE_M in the code) to keep the trip counter stable while stationary. Speed comes directly from the NEO-6M's NMEA speed field so it tracks correctly even at walking pace.
Troubleshooting
- Dashboard does not load at 192.168.4.1: confirm your phone is still connected to
GPS-Dashboard— some phones silently drop access points with no internet. Turn off mobile data temporarily and retry. - Status LED never goes solid: take the device fully outdoors with a clear sky view. Buildings and windows block satellite signals. First lock on a cold module can take several minutes.
- Fix is intermittent indoors: the NEO-6M ceramic antenna needs a direct view of the sky. Near a window is usually not enough for a stable lock.
- Serial Monitor shows no GPS data: double-check that NEO-6M TX goes to GPIO16 (not GPIO17). The board name printed on the module can be confusing — TX on the module feeds RX on the ESP32.
- ESP32 resets when the LED is first driven: confirm GPIO2 is not shorted and the resistor value is 220 Ω or higher.
- Trip distance accumulates at a standstill: the 2-metre jitter filter handles small GPS noise, but very sensitive modules can still drift. The Reset Trip Distance button resets
tripDistanceMvia the/resetendpoint.
Going further
Once the single-device dashboard is reliable, you can add a small SSD1306 OLED directly to the ESP32 so stats are visible without a phone. A second UART or SoftwareSerial port can carry telemetry to a data logger for track recording. If you want to store routes, an SD card module wired over SPI and a periodic write of lat/lon/speed/timestamp to a CSV file is a natural next step.
Wiring diagram
Components needed
| Component | Type | Qty | Buy |
|---|---|---|---|
| ESP32 DevKit v1 | board | 1 | INR 385.00 |
| GY-NEO6MV2 GPS Module | sensor | 1 | €5.40 |
| TP4056 LiPo Battery Charger Module with Protection | other | 1 | €3.10 |
| Adafruit Micro Lipo - USB LiIon/LiPoly charger [v2] | other | 1 | $5.95 |
| 3mm LED Pack (50 PCS) | actuator | 1 | $3.45 |
Supplier links, prices, and availability are shown as a guide and may change. Schematik may earn a commission from purchases made through affiliate links.
Assembly
Wire the NEO-6M GPS module
Connect the NEO-6M VCC pin to the ESP32 3.3 V rail and GND to the common ground rail. Connect the NEO-6M TX pin to ESP32 GPIO16 (the UART1 RX line) and the NEO-6M RX pin to ESP32 GPIO17 (the UART1 TX line).
- The NEO-6M module sold on breakout boards is already 3.3 V compatible; power it from the 3V3 pin, not 5 V.
- The wiring is from the ESP32's perspective: GPIO16 is RX (receives GPS data), GPIO17 is TX (rarely used here).
- GPS RX on the module carries NMEA sentences out to the ESP32 — double-check TX→GPIO16 before powering on.
- Do not connect the NEO-6M VCC to the 5 V pin — the module logic is 3.3 V.
Wire the status LED
Connect the LED anode (longer leg) through a 330 Ω resistor to GPIO2 on the ESP32. Connect the LED cathode (shorter leg) directly to the GND rail.
- GPIO2 is the built-in LED pin on most ESP32 DevKit v1 boards — if your board has an on-board LED already connected to GPIO2 you can skip adding an external LED and it will work the same way.
- A 220–470 Ω resistor keeps current safe for both the ESP32 GPIO and a standard 3 mm or 5 mm LED.
Assemble the LiPo charging circuit
Connect the TP4056 charger module OUT+ to the ESP32 VIN (or a 3.7–4.2 V rail) and OUT− to GND. Connect the LiPo battery BAT+ lead to the TP4056 BAT+ pad and BAT− to BAT−. The TP4056 charges the LiPo from USB and powers the ESP32 from its OUT pads — no GPIO connections are needed.
- The TP4056 with protection (the version with two ICs on the board) is preferred — it adds over-discharge and short-circuit protection.
- Many ESP32 DevKit boards also have a USB micro/Type-C port that can supply 5 V directly; you can power the board that way during development and use the TP4056+LiPo for portable use.
- Never leave a LiPo charging unattended for long periods without protection circuitry in place.
- Verify the LiPo connector polarity before plugging in — reversed polarity will damage the battery and the charger.
Upload the firmware
Open the project in Schematik (or PlatformIO), then build and upload the firmware to the ESP32. Open the Serial Monitor at 115200 baud. You should see the access point name and the URL http://192.168.4.1 printed within a second or two of boot.
- If the upload fails, hold the BOOT button on the ESP32 DevKit while the upload starts, then release it once the progress bar begins.
Connect your phone and open the dashboard
On your phone, open Wi-Fi settings and connect to the network named 'GPS-Dashboard' (no password). Open a browser and navigate to http://192.168.4.1. The dashboard loads and starts polling every 2 seconds. The status LED blinks while the NEO-6M is searching for satellites and goes solid when a fix is acquired.
- First satellite lock typically takes 30–90 seconds outdoors with a clear sky view, and longer on a first cold start (up to 5 minutes). The dashboard shows 'Searching…' in red until lock is obtained.
- If your phone shows 'No internet connection' on the GPS-Dashboard network, tap 'Use this network anyway' — the access point is intentionally offline.
Test the GPS fix and trip distance
Take the device outdoors. Once the status LED goes solid, the dashboard shows latitude, longitude, satellites, and speed. Walk or ride to accumulate trip distance. Tap 'Reset Trip Distance' to zero the counter for a new trip.
- GPS jitter at a standstill can show tiny movements. The firmware ignores position updates smaller than 2 metres to keep the trip counter stable while stationary.
- Speed updates come directly from the NEO-6M's built-in NMEA speed field (km/h) so it tracks correctly even at walking pace.
Pin assignments
| Pin | Connection | Type |
|---|---|---|
| 3V3 | neo6m-gps VCC | POWER |
| GND | neo6m-gps GND | GROUND |
| GPIO 16 | neo6m-gps TX | UART |
| GPIO 17 | neo6m-gps RX | UART |
| 3V3 | tp4056-charger OUT+ | POWER |
| GND | tp4056-charger OUT- | GROUND |
| 3V3 | tp4056-charger BAT+ → LiPo BAT+ | POWER |
| GND | tp4056-charger BAT- → LiPo BAT- | GROUND |
| GPIO 2 | status-led ANODE | DIGITAL |
| GND | status-led CATHODE | GROUND |
Code
/**
* Pocket GPS Dashboard
*
* ESP32 hosts a Wi-Fi access point ("GPS-Dashboard") and serves a
* phone-friendly dashboard at 192.168.4.1. The NEO-6M GPS is read
* over HardwareSerial(1). The dashboard shows fix status, satellites,
* latitude/longitude, speed (km/h), and accumulated trip distance with
* a reset button. The status LED blinks while searching for a fix and
* goes solid once a fix is obtained.
*
* Connect your phone to the "GPS-Dashboard" Wi-Fi network (no password),
* then open http://192.168.4.1 in a browser.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <TinyGPSPlus.h>
#include <math.h>
// ---------------------------------------------------------------------------
// Pin definitions — must match pin_definitions.json
// ---------------------------------------------------------------------------
#define GPS_RX_PIN 16 // ESP32 RX1: receives data from NEO-6M TX
#define GPS_TX_PIN 17 // ESP32 TX1: sends data to NEO-6M RX (rarely used)
#define STATUS_LED_PIN 2 // Built-in LED on most ESP32 DevKit boards
// ---------------------------------------------------------------------------
// GPS serial
// ---------------------------------------------------------------------------
// NEO-6M default baud rate is 9600
#define GPS_BAUD 9600
HardwareSerial gpsSerial(1); // UART1
TinyGPSPlus gps;
// ---------------------------------------------------------------------------
// Wi-Fi access point — no password so any phone can connect
// ---------------------------------------------------------------------------
const char* AP_SSID = "GPS-Dashboard";
WebServer server(80);
// ---------------------------------------------------------------------------
// Trip distance accumulation
// ---------------------------------------------------------------------------
static double tripDistanceM = 0.0; // metres accumulated since last reset
static double lastLat = 0.0;
static double lastLon = 0.0;
static bool lastPosValid = false;
// Haversine formula — returns distance in metres between two lat/lon points
static double haversineM(double lat1, double lon1, double lat2, double lon2) {
const double R = 6371000.0; // Earth radius in metres
double dLat = radians(lat2 - lat1);
double dLon = radians(lon2 - lon1);
double a = sin(dLat / 2.0) * sin(dLat / 2.0)
+ cos(radians(lat1)) * cos(radians(lat2))
* sin(dLon / 2.0) * sin(dLon / 2.0);
double c = 2.0 * atan2(sqrt(a), sqrt(1.0 - a));
return R * c;
}
// ---------------------------------------------------------------------------
// Status LED — blink while no fix, solid once acquired
// ---------------------------------------------------------------------------
static bool ledState = false;
static uint32_t lastBlinkMs = 0;
#define BLINK_INTERVAL_MS 500
static void updateLed() {
bool hasFix = gps.location.isValid() && gps.location.age() < 3000;
if (hasFix) {
digitalWrite(STATUS_LED_PIN, HIGH);
} else {
uint32_t now = millis();
if (now - lastBlinkMs >= BLINK_INTERVAL_MS) {
lastBlinkMs = now;
ledState = !ledState;
digitalWrite(STATUS_LED_PIN, ledState ? HIGH : LOW);
}
}
}
// ---------------------------------------------------------------------------
// Dashboard HTML — returned as one raw string literal
// ---------------------------------------------------------------------------
static const char DASHBOARD_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GPS Dashboard</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 1rem 2rem;
}
h1 { font-size: 1.4rem; font-weight: 700; margin-bottom: 1.25rem; letter-spacing: 0.04em; color: #f8fafc; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
width: 100%;
max-width: 420px;
}
.card {
background: #1e293b;
border-radius: 0.75rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.card.wide { grid-column: 1 / -1; }
.label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
font-weight: 600;
}
.value {
font-size: 1.6rem;
font-weight: 700;
color: #f1f5f9;
line-height: 1.1;
}
.value.small { font-size: 1.05rem; }
.badge {
display: inline-block;
padding: 0.2rem 0.65rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 700;
}
.badge.fix { background: #166534; color: #86efac; }
.badge.nofix { background: #7c2d12; color: #fdba74; }
.reset-btn {
margin-top: 1.25rem;
width: 100%;
max-width: 420px;
padding: 0.85rem;
background: #2563eb;
color: #fff;
font-size: 1rem;
font-weight: 700;
border: none;
border-radius: 0.75rem;
cursor: pointer;
letter-spacing: 0.03em;
}
.reset-btn:active { background: #1d4ed8; }
.footer { margin-top: 1rem; font-size: 0.7rem; color: #475569; }
</style>
</head>
<body>
<h1>GPS Dashboard</h1>
<div class="grid">
<div class="card wide">
<span class="label">Fix Status</span>
<span id="fix" class="badge nofix">Searching…</span>
</div>
<div class="card">
<span class="label">Satellites</span>
<span id="sats" class="value">—</span>
</div>
<div class="card">
<span class="label">Speed</span>
<span id="speed" class="value">— <small style="font-size:0.9rem">km/h</small></span>
</div>
<div class="card wide">
<span class="label">Latitude</span>
<span id="lat" class="value small">—</span>
</div>
<div class="card wide">
<span class="label">Longitude</span>
<span id="lon" class="value small">—</span>
</div>
<div class="card wide">
<span class="label">Trip Distance</span>
<span id="dist" class="value">—</span>
</div>
</div>
<button class="reset-btn" onclick="resetTrip()">Reset Trip Distance</button>
<p class="footer">Updates every 2 s · Connect to GPS-Dashboard Wi-Fi</p>
<script>
function update() {
fetch("/data")
.then(r => r.json())
.then(d => {
const fixEl = document.getElementById("fix");
if (d.fix) {
fixEl.textContent = "Fix acquired";
fixEl.className = "badge fix";
} else {
fixEl.textContent = "Searching…";
fixEl.className = "badge nofix";
}
document.getElementById("sats").textContent = d.fix ? d.satellites : "—";
document.getElementById("speed").innerHTML = d.fix
? d.speed_kmh.toFixed(1) + ' <small style="font-size:0.9rem">km/h</small>'
: '— <small style="font-size:0.9rem">km/h</small>';
document.getElementById("lat").textContent = d.fix ? d.lat.toFixed(6) : "—";
document.getElementById("lon").textContent = d.fix ? d.lon.toFixed(6) : "—";
const distM = d.trip_m;
let distStr;
if (distM < 1000) {
distStr = distM.toFixed(0) + " m";
} else {
distStr = (distM / 1000).toFixed(2) + " km";
}
document.getElementById("dist").textContent = distStr;
})
.catch(() => {}); // silently ignore transient fetch errors
}
function resetTrip() {
fetch("/reset", { method: "POST" })
.then(() => update())
.catch(() => {});
}
update();
setInterval(update, 2000);
</script>
</body>
</html>
)rawliteral";
// ---------------------------------------------------------------------------
// HTTP handlers
// ---------------------------------------------------------------------------
static void handleRoot() {
server.send_P(200, "text/html", DASHBOARD_HTML);
}
static void handleData() {
bool hasFix = gps.location.isValid() && gps.location.age() < 3000;
// Build JSON manually — keeps the firmware dependency-free
String json = "{";
json += "\"fix\":" + String(hasFix ? "true" : "false") + ",";
json += "\"satellites\":" + String(hasFix ? (int)gps.satellites.value() : 0) + ",";
if (hasFix) {
json += "\"lat\":" + String(gps.location.lat(), 6) + ",";
json += "\"lon\":" + String(gps.location.lng(), 6) + ",";
json += "\"speed_kmh\":" + String(gps.speed.kmph(), 2) + ",";
} else {
json += "\"lat\":0,\"lon\":0,\"speed_kmh\":0,";
}
json += "\"trip_m\":" + String(tripDistanceM, 1);
json += "}";
server.sendHeader("Cache-Control", "no-cache");
server.send(200, "application/json", json);
}
static void handleReset() {
tripDistanceM = 0.0;
lastPosValid = false;
server.send(200, "application/json", "{\"ok\":true}");
Serial.println("Trip distance reset.");
}
// ---------------------------------------------------------------------------
// setup / loop
// ---------------------------------------------------------------------------
void setup() {
Serial.begin(115200);
delay(200);
Serial.println("\n=== Pocket GPS Dashboard ===");
// Status LED
pinMode(STATUS_LED_PIN, OUTPUT);
digitalWrite(STATUS_LED_PIN, LOW);
// Start GPS serial on UART1 with explicit RX/TX pins
gpsSerial.begin(GPS_BAUD, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
Serial.printf("GPS serial started at %d baud (RX=GPIO%d, TX=GPIO%d)\n",
GPS_BAUD, GPS_RX_PIN, GPS_TX_PIN);
// Start Wi-Fi access point
WiFi.softAP(AP_SSID);
Serial.print("Access point started: ");
Serial.print(AP_SSID);
Serial.print(" — dashboard at http://");
Serial.println(WiFi.softAPIP());
// Register HTTP routes
server.on("/", HTTP_GET, handleRoot);
server.on("/data", HTTP_GET, handleData);
server.on("/reset", HTTP_POST, handleReset);
server.begin();
Serial.println("HTTP server running.");
}
void loop() {
// Feed GPS parser — read all available bytes
while (gpsSerial.available()) {
gps.encode(gpsSerial.read());
}
// Accumulate trip distance when we have a valid fix
bool hasFix = gps.location.isValid() && gps.location.age() < 3000;
if (hasFix) {
double curLat = gps.location.lat();
double curLon = gps.location.lng();
if (lastPosValid) {
double delta = haversineM(lastLat, lastLon, curLat, curLon);
// Only accumulate meaningful movement (> 2 m) to filter GPS jitter
if (delta > 2.0) {
tripDistanceM += delta;
lastLat = curLat;
lastLon = curLon;
}
} else {
lastLat = curLat;
lastLon = curLon;
lastPosValid = true;
}
}
// Handle web requests
server.handleClient();
// Update status LED
updateLed();
}
// Run this and build other cool things at schematik.ioReady to build this?
Open this project in Schematik to get the full wiring diagram, pin assignments, and deployable code for the Pocket GPS Dashboard.
Open in Schematik →