Community project

Portable GPS Tracking Dashboard

Photo of Portable GPS Tracking Dashboard
Generated with AI

Gafur ŞENCAN

Published November 12, 2026

This portable GPS tracker turns an ESP32 into a self-contained navigation device that broadcasts its own Wi-Fi network and serves a live dashboard to any smartphone. The tracker reads GPS coordinates, speed, and satellite lock status from an Adafruit PA1616D module and displays real-time position data, trip distance, and fix quality through a phone-friendly web interface—no internet connection required.

The guide provides a complete parts list, wiring diagram, firmware, and step-by-step assembly instructions for integrating the GPS module, status LED, and LiPo power system with the boost converter. Builders will learn how to configure the ESP32 as a Wi-Fi access point, communicate with the GPS receiver over serial, and create an interactive dashboard that tracks location and accumulated distance with a single reset button.

Wiring diagram

Wiring diagram for Portable GPS Tracking Dashboard
Loading diagram…

Gather all the parts

QtyComponent
1

ESP32 development board

ESP32 development board

1

Adafruit PA1616D Ultimate GPS breakout

NEO-6M GPS module

1

TP4056 LiPo charger module

TP4056 LiPo charger module

1

3.7 V 1000 mAh protected LiPo battery

1000 mAh LiPo battery

1

Status LED

Status LED

1

MT3608 boost converter, adjusted to 5.0 V

ESP32 development board

1

2-pin JST-SM plug and receptacle cable set

Keyed disconnect between TP4056 OUT and MT3608 IN so the tracker load is physically removed while charging.

1

220 Ω status LED resistor

Status LED

Assemble it in 7 steps

1. Wire the PA1616D GPS module

Connect the PA1616D VCC pin to the ESP32 3.3 V rail and GND to the common ground rail. Connect the PA1616D TX pin to ESP32 GPIO16 (the UART1 RX line) and the PA1616D RX pin to ESP32 GPIO17 (the UART1 TX line).

  • The PA1616D 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 PA1616D VCC to the 5 V pin — the module logic is 3.3 V.

2. Wire the status LED

Connect the LED anode through the 220 Ω resistor to GPIO2, and connect the cathode to GND. The LED blinks while searching for a GPS fix and stays solid after lock.

  • GPIO2 matches STATUS_LED_PIN in the firmware.
  • The 220 Ω resistor limits LED current.

3. Assemble and adjust the protected LiPo power chain

Connect the LiPo to TP4056 BAT+ and BAT− with verified polarity. Connect TP4056 OUT+ and OUT− to the MT3608 input. Before connecting the ESP32, power the chain and adjust the MT3608 output to exactly 5.0 V with a multimeter. Then connect boost OUT+ to board VIN/5V and OUT− to GND.

  • Use the protected TP4056 variant with separate BAT and OUT pads.
  • Insulate the boost converter after adjustment so its trimmer cannot be knocked.
  • Never connect the raw 3.0–4.2 V charger output directly to board VIN/5V.
  • Verify battery polarity and the 5.0 V boost output before connecting the board.
  • Do not charge a hot, damaged, swollen, or enclosed LiPo.

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.

  • If the upload fails, hold the BOOT button on the ESP32 DevKit while the upload starts, then release it once the progress bar begins.

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

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.

  • 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 PA1616D's built-in NMEA speed field (km/h) so it tracks correctly even at walking pace.

7. Charge with the tracker load disconnected

Switch the tracker off and disconnect the MT3608 input from TP4056 OUT before connecting the TP4056 charger. Reconnect the boost converter only after the charge-complete LED indicates termination and USB charging power is removed.

  • Use a keyed two-pin connector between TP4056 OUT and MT3608 IN so this disconnection is unambiguous.
  • The protected TP4056 module is not a load-sharing charger. Running the ESP32 while charging can prevent correct charge termination.

Review all connections

1. Connections between "neo6m-gps" and "ESP32"

Functionneo6m-gpsESP32
powerVCC3V3
groundGNDGND
uartTXGPIO 16
uartRXGPIO 17

2. Connections between "tp4056-charger" and "ESP32"

Functiontp4056-chargerESP32
powerB+ → lipo-battery:BAT+EXT
groundB- → lipo-battery:BAT-EXT
powerOUT+ → charge-disconnect:OUT+EXT
groundOUT- → charge-disconnect:OUT-EXT

3. Connections between "lipo-battery" and "ESP32"

Functionlipo-batteryESP32
powerBAT+ → tp4056-charger:B+EXT
groundBAT- → tp4056-charger:B-EXT

4. Connections between "status-led" and "ESP32"

Functionstatus-ledESP32
digitalANODE → status-led-resistor:OUTEXT
groundCATHODEGND

5. Connections between "mt3608-boost" and "ESP32"

Functionmt3608-boostESP32
powerIN+ → charge-disconnect:LOAD+EXT
groundIN- → charge-disconnect:LOAD-EXT
powerOUT+VCC
groundOUT-GND

6. Connections between "pocket-gps-dashboard-charge-disconnect-1" and "ESP32"

Functionpocket-gps-dashboard-charge-disconnect-1ESP32
powerOUT+ → tp4056-charger:OUT+EXT
groundOUT- → tp4056-charger:OUT-EXT
powerLOAD+ → mt3608-boost:IN+EXT
groundLOAD- → mt3608-boost:IN-EXT

7. Connections between "status-led-resistor" and "ESP32"

Functionstatus-led-resistorESP32
digitalINGPIO 2
digitalOUT → status-led:ANODEEXT

Deploy the firmware

schematik_esp32.inoOpen in Schematik
/**
 * 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();
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik