How to Build a Pocket GPS Dashboard

A portable tracker that hosts speed, distance, and fix status on its own local page

ESP32VehiclesIntermediate45 minutes5 components

Updated

How to Build a Pocket GPS Dashboard
For illustrative purposes only
On this page

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:

  1. Read NMEA sentences from the NEO-6M over UART1 (GPIO16 RX, GPIO17 TX) using TinyGPSPlus,
  2. Host a soft-access-point named GPS-Dashboard with no password and serve the dashboard at 192.168.4.1,
  3. Blink the status LED at 500 ms while no fix is held and keep it solid once one is acquired,
  4. Accumulate trip distance using the Haversine formula and expose a /reset endpoint 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 tripDistanceM via the /reset endpoint.

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

Loading diagram…
Interactive wiring diagram

Components needed

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

1

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

2

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.

3

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.

4

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.

5

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.

6

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.

Pin assignments

PinConnectionType
3V3neo6m-gps VCCPOWER
GNDneo6m-gps GNDGROUND
GPIO 16neo6m-gps TXUART
GPIO 17neo6m-gps RXUART
3V3tp4056-charger OUT+POWER
GNDtp4056-charger OUT-GROUND
3V3tp4056-charger BAT+ LiPo BAT+POWER
GNDtp4056-charger BAT- LiPo BAT-GROUND
GPIO 2status-led ANODEDIGITAL
GNDstatus-led CATHODEGROUND

Code

Arduino C++
/**
 * 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 &nbsp;·&nbsp; 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.io
Libraries: TinyGPSPlus