How to Build a Sports GPS Hotspot

A compact ESP32-C3 tracker with its own Wi-Fi network for live field stats

ESP32VehiclesIntermediate40 minutes4 components

Updated

How to Build a Sports GPS Hotspot
For illustrative purposes only
On this page

What you'll build

This guide builds a compact GPS tracker on an ESP32-C3 Super Mini that creates its own open Wi-Fi network called SportsGPS and serves a live dashboard at 192.168.4.1. Power the tracker, connect your phone to the network, open the address in a browser, and you have real-time speed, fix status, satellite count, coordinates, and session distance — all updating every second without a page reload.

The build is smaller and lighter than the ESP32 Dev Kit version in the Pocket GPS Dashboard guide. The ESP32-C3 Super Mini runs on 3.3 V logic throughout, which matches the NEO-6M directly. There is no router involved: the board is both the access point and the web server.

The useful workflow is: power on, join SportsGPS, open 192.168.4.1, step outside until the LED stops blinking, then read stats. The guide keeps the wiring and firmware behaviour matched to that experience.

What you are building

The firmware has four main jobs:

  1. Read NMEA sentences from the NEO-6M over UART1 (GPIO20 RX, GPIO21 TX) at 9600 baud using TinyGPSPlus,
  2. Host a soft-access-point named SportsGPS and serve the dashboard HTML plus a /data JSON endpoint,
  3. Blink the status LED on GPIO8 at 500 ms while no fix is held and keep it solid once the NEO-6M locks on,
  4. Accumulate session distance using the Haversine formula and apply a 3-metre jitter filter (JITTER_FILTER_M) to suppress noise at a standstill.

Internet connectivity, cloud storage, and route replay are deliberately outside the scope of this build.

Upload and calibrate

Open the project in Schematik and click Deploy using Chrome or Edge. If flashing does not start automatically, hold the BOOT button on the ESP32-C3 while clicking Deploy, then release it once upload begins.

Open Serial Monitor at 115200 baud. You should see SportsGPS starting… followed by the soft-AP IP address within a second or two of boot.

On your phone, join the open Wi-Fi network SportsGPS. Open a browser and navigate to 192.168.4.1. The dashboard loads immediately. Take the tracker near a window or outdoors — the status LED stops blinking and the dashboard switches from "No fix" to "Fixed" once the NEO-6M locks on. First lock outdoors typically takes one to three minutes; a cold start can take longer.

Use the Reset Distance button at the start of each session to zero the session distance counter.

Troubleshooting

  • Dashboard does not load at 192.168.4.1: confirm your phone is still connected to SportsGPS. Some phones drop access points with no internet routing. Disable mobile data and retry.
  • Status LED never goes solid: move fully outdoors with a clear sky view. Buildings and windows block satellite signals. The NEO-6M needs a direct view of the sky.
  • Serial Monitor shows no output after flashing: hold the BOOT button during the first connection — the ESP32-C3 Super Mini occasionally needs this on some host machines.
  • Session distance jumps at a standstill: the JITTER_FILTER_M constant (3 metres) suppresses small GPS noise. If your module is particularly noisy, increase this value in the firmware.
  • Speed reads non-zero while stationary: TinyGPSPlus reports the NEO-6M's built-in speed field directly. Very small values below 0.5 km/h are normal GPS noise; the module reports them as valid.
  • GPIO8 LED stays low after boot: check that the resistor is correctly placed between GPIO8 and the anode. Tying GPIO8 directly to 3V3 during boot can prevent the board from starting.

Going further

Because the ESP32-C3 already has Bluetooth Low Energy, you can add a BLE characteristic alongside the Wi-Fi dashboard so a paired phone receives GPS updates without the hotspot. For battery longevity on long rides, the ESP32-C3's light-sleep mode between GPS reads can cut idle current substantially. Adding an I2C OLED to display speed without a phone is straightforward on the same board.

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 TX pin to ESP32-C3 GPIO20 and the NEO-6M RX pin to GPIO21. These are the hardware UART1 pins used by the firmware.

2

Power the GPS module

Connect NEO-6M VCC to the 3V3 rail on the ESP32-C3 Super Mini and NEO-6M GND to any GND pin.

3

Add the status LED

Connect the LED anode through a 220–470 Ω resistor to GPIO8, and the LED cathode to GND. The LED blinks while searching for a GPS fix and stays solid once a fix is acquired.

4

Connect portable power

Plug a single-cell LiPo battery into the charger board and connect its output to the ESP32-C3 VIN (5 V) and GND pins. Charge via the charger's USB port before heading out.

5

Upload the firmware

Connect the ESP32-C3 Super Mini to your computer via USB, open the Schematik Deploy panel, and click Deploy. Use Chrome or Edge for Web Serial support. If flashing does not start, hold the BOOT button while clicking Deploy, then release it once the upload begins.

6

Connect and test

Power the tracker, then on your phone join the open Wi-Fi network called SportsGPS. Open a browser and go to 192.168.4.1. Take the tracker near a window or outdoors — the status LED will stop blinking and the dashboard will show a fix once the NEO-6M locks on.

Pin assignments

PinConnectionType
GPIO 20neo6m-gps TX ESP32-C3 RXUART
GPIO 21neo6m-gps RX ESP32-C3 TXUART
3V3neo6m-gps VCCPOWER
GNDneo6m-gps GNDGROUND
GPIO 8status-led DINDIGITAL
GNDstatus-led GNDGROUND

Code

Arduino C++
// Sports GPS Hotspot — ESP32-C3 Super Mini + NEO-6M
// Creates a "SportsGPS" Wi-Fi AP and serves a phone-friendly
// live dashboard at 192.168.4.1 showing speed, fix status,
// satellites, lat/lon, and accumulated session distance.

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <TinyGPSPlus.h>

// ── Pin assignments (match pin_definitions.json) ──────────────────────────
#define GPS_RX_PIN     20   // ESP32-C3 RX ← NEO-6M TX
#define GPS_TX_PIN     21   // ESP32-C3 TX → NEO-6M RX
#define STATUS_LED_PIN  8   // Single LED: slow-blink = no fix, solid = fix

// ── Configuration ─────────────────────────────────────────────────────────
static const char* AP_SSID        = "SportsGPS";
static const uint32_t GPS_BAUD    = 9600;
// Ignore position updates smaller than this to suppress GPS jitter
static const double JITTER_FILTER_M = 3.0;

// ── Globals ───────────────────────────────────────────────────────────────
TinyGPSPlus      gps;
HardwareSerial   gpsSerial(1);
WebServer        server(80);

double  sessionDistanceM = 0.0;   // accumulated metres this session
double  prevLat = 0.0, prevLon = 0.0;
bool    prevValid = false;

unsigned long lastLedMs = 0;
bool          ledState  = false;

// ── Helpers ───────────────────────────────────────────────────────────────

// Haversine great-circle distance in metres
double haversineM(double lat1, double lon1, double lat2, double lon2) {
  const double R = 6371000.0; // Earth radius, metres
  double dLat = radians(lat2 - lat1);
  double dLon = radians(lon2 - lon1);
  double a = sin(dLat / 2) * sin(dLat / 2) +
             cos(radians(lat1)) * cos(radians(lat2)) *
             sin(dLon / 2) * sin(dLon / 2);
  return R * 2.0 * atan2(sqrt(a), sqrt(1.0 - a));
}

// Update session distance from a new valid GPS fix
void accumulateDistance() {
  if (!gps.location.isValid()) return;
  double lat = gps.location.lat();
  double lon = gps.location.lng();

  if (prevValid) {
    double d = haversineM(prevLat, prevLon, lat, lon);
    if (d >= JITTER_FILTER_M) {
      sessionDistanceM += d;
      prevLat = lat;
      prevLon = lon;
    }
  } else {
    prevLat   = lat;
    prevLon   = lon;
    prevValid = true;
  }
}

// ── HTTP handlers ─────────────────────────────────────────────────────────

// /data — polled by dashboard JS every second
void handleData() {
  bool    fix       = gps.location.isValid();
  double  lat       = fix ? gps.location.lat()   : 0.0;
  double  lon       = fix ? gps.location.lng()   : 0.0;
  double  speedKmh  = (fix && gps.speed.isValid()) ? gps.speed.kmph() : 0.0;
  int     sats      = gps.satellites.isValid() ? (int)gps.satellites.value() : 0;
  double  distKm    = sessionDistanceM / 1000.0;

  String json = "{";
  json += "\"fix\":"      + String(fix ? "true" : "false") + ",";
  json += "\"lat\":"      + String(lat, 6) + ",";
  json += "\"lon\":"      + String(lon, 6) + ",";
  json += "\"speedKmh\":" + String(speedKmh, 1) + ",";
  json += "\"sats\":"     + String(sats) + ",";
  json += "\"distKm\":"   + String(distKm, 3);
  json += "}";

  server.send(200, "application/json", json);
}

// /reset — clears session distance
void handleReset() {
  sessionDistanceM = 0.0;
  prevValid        = false;
  server.send(200, "application/json", "{\"ok\":true}");
}

// / — phone-friendly dashboard
void handleRoot() {
  // Inline HTML/CSS/JS — no external resources needed on an isolated AP
  const char* html = R"rawhtml(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SportsGPS</title>
<style>
  *{box-sizing:border-box;margin:0;padding:0}
  body{background:#0d1117;color:#e6edf3;font-family:system-ui,sans-serif;
       display:flex;flex-direction:column;align-items:center;padding:1rem}
  h1{font-size:1.4rem;margin-bottom:1rem;letter-spacing:.05em;color:#58a6ff}
  .card{background:#161b22;border:1px solid #30363d;border-radius:12px;
        padding:1rem 1.4rem;margin-bottom:.8rem;width:100%;max-width:360px;
        display:flex;flex-direction:column;gap:.2rem}
  .label{font-size:.75rem;color:#8b949e;text-transform:uppercase;
         letter-spacing:.06em}
  .value{font-size:2.4rem;font-weight:700;line-height:1}
  .unit {font-size:1rem;color:#8b949e;margin-left:.2rem}
  .fix-ok {color:#3fb950}
  .fix-no {color:#f85149}
  .small {font-size:1.3rem}
  #btn{margin-top:.4rem;padding:.6rem 1.4rem;border:none;border-radius:8px;
       background:#21262d;color:#e6edf3;font-size:.9rem;cursor:pointer;
       border:1px solid #30363d;width:100%;max-width:360px}
  #btn:active{background:#30363d}
</style>
</head>
<body>
<h1>SportsGPS</h1>

<div class="card">
  <span class="label">Speed</span>
  <span><span class="value" id="speed">--</span><span class="unit">km/h</span></span>
</div>

<div class="card">
  <span class="label">GPS Fix</span>
  <span class="value small" id="fix">Waiting...</span>
</div>

<div class="card">
  <span class="label">Satellites</span>
  <span class="value" id="sats">--</span>
</div>

<div class="card">
  <span class="label">Latitude</span>
  <span class="value small" id="lat">--</span>
</div>

<div class="card">
  <span class="label">Longitude</span>
  <span class="value small" id="lon">--</span>
</div>

<div class="card">
  <span class="label">Session Distance</span>
  <span><span class="value" id="dist">0.000</span><span class="unit">km</span></span>
</div>

<button id="btn" onclick="resetDist()">Reset Distance</button>

<script>
function update(){
  fetch('/data').then(r=>r.json()).then(d=>{
    document.getElementById('speed').textContent = d.speedKmh.toFixed(1);
    var fixEl = document.getElementById('fix');
    if(d.fix){
      fixEl.textContent='Fixed';
      fixEl.className='value small fix-ok';
    } else {
      fixEl.textContent='No fix';
      fixEl.className='value small fix-no';
    }
    document.getElementById('sats').textContent = d.sats;
    document.getElementById('lat').textContent  = d.fix ? d.lat.toFixed(6) : '--';
    document.getElementById('lon').textContent  = d.fix ? d.lon.toFixed(6) : '--';
    document.getElementById('dist').textContent = d.distKm.toFixed(3);
  }).catch(()=>{});
}
function resetDist(){
  fetch('/reset').then(()=>update());
}
setInterval(update,1000);
update();
</script>
</body>
</html>)rawhtml";

  server.send(200, "text/html", html);
}

// ── setup / loop ───────────────────────────────────────────────────────────

void setup() {
  pinMode(STATUS_LED_PIN, OUTPUT);
  digitalWrite(STATUS_LED_PIN, LOW);

  Serial.begin(115200);
  Serial.println("SportsGPS starting…");

  // GPS serial on UART1 — RX=GPIO20, TX=GPIO21
  gpsSerial.begin(GPS_BAUD, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
  Serial.println("GPS serial started on GPIO20/21 at 9600 baud");

  // Soft-AP — open network, no password
  WiFi.mode(WIFI_AP);
  WiFi.softAP(AP_SSID);
  Serial.print("AP \"");
  Serial.print(AP_SSID);
  Serial.print("\" up — dashboard at ");
  Serial.println(WiFi.softAPIP());

  server.on("/",      handleRoot);
  server.on("/data",  handleData);
  server.on("/reset", handleReset);
  server.begin();
  Serial.println("HTTP server started");
}

void loop() {
  // Feed GPS parser — non-blocking
  while (gpsSerial.available()) {
    gps.encode(gpsSerial.read());
  }

  // Accumulate distance whenever we get a new valid position
  if (gps.location.isUpdated() && gps.location.isValid()) {
    accumulateDistance();
  }

  // Status LED: solid = fix acquired, 1 Hz blink = searching
  bool hasFix = gps.location.isValid();
  if (hasFix) {
    digitalWrite(STATUS_LED_PIN, HIGH);
  } else {
    unsigned long now = millis();
    if (now - lastLedMs >= 500) {
      lastLedMs = now;
      ledState  = !ledState;
      digitalWrite(STATUS_LED_PIN, ledState ? HIGH : LOW);
    }
  }

  server.handleClient();
}

// Run this and build other cool things at schematik.io
Libraries: TinyGPSPlus

Ready to build this?

Open this project in Schematik to get the full wiring diagram, pin assignments, and deployable code for the Sports GPS Hotspot.

Open in Schematik →

Related guides