THUGS(red) Wardrive
Sign in Join

LilyGO T-Dongle-S3 with an external GPS

This is the ESP32-C5 rig in a different body: same firmware, same two modes, same button. The T-Dongle-S3 arrives as a USB stick with a colour screen, a button and an RGB LED already on it, so the build is four wires to a GPS module and nothing else. It lives in a car USB socket and looks like a memory stick.

What you get, and what you give up

Screen included
160×80 colour LCD — fix, buffer and upload progress without a laptop
No power wiring
USB-A plug straight into a car socket
RGB LED
Colour and pattern, which reads better at a glance than one white LED
2.4 GHz only
The ESP32-S3 has no 5 GHz radio. This is the real cost

Be clear-eyed about that last row. A 5 GHz-only SSID is invisible to this rig, and an increasing number of access points are exactly that — so a street surveyed with a dongle is less thoroughly surveyed than the same street with a C5. It is still a very good rig, and the one you will actually leave in the car.

Parts

Dongle
LilyGO T-Dongle-S3. ESP32-S3, 16 MB flash, 8 MB PSRAM, ST7735 LCD, TF slot
GPS
NEO-8M with an active antenna. A NEO-6M is fine; anything that speaks NMEA at 9600 will do
Cable
The 4-pin JST SH pigtail LilyGO ships, or four jumpers soldered to the pads
Optional
A USB-A extension, so the dongle sits on the dashboard rather than in the footwell

Wiring

The T-Dongle-S3 breaks out 3V3, GND, GPIO43 and GPIO44 on a 4-pin JST SH connector. GPS VCC to 3V3, GPS GND to GND, GPS TX to GPIO44, GPS RX to GPIO43. The LCD, button and APA102 RGB LED are already on the board.
Everything except the GPS is already soldered on.
FromToNote
GPS VCCdongle 3V33.3 V — do not tap the USB 5 V rail instead
GPS GNDdongle GNDcommon ground
GPS TXdongle GPIO44the dongle's RX. NMEA arrives here
GPS RXdongle GPIO43the dongle's TX. Only needed to configure the module

Check the silkscreen for the pad order before you solder — it has moved between batches, and the diagram above is a wiring list rather than a photograph.

The one gotcha: GPIO43/44 are UART0

Those two pads are the chip's serial console. Handing them to the GPS costs you two things and it is better to know now than to discover it mid-flash:

How it behaves

Two modes: wardrive, which scans 2.4 GHz and buffers against the GPS fix, and upload, entered by pressing the button, which joins your access point and POSTs batches. A 2xx clears the buffer and returns to scanning; a failure keeps the buffer and returns to scanning.
The same firmware as the C5 build, with one band to scan instead of two.

Plug it in and it is wardriving. It scans, stamps each network with the current fix, and buffers — skipping any scan with no fix, and any scan taken less than 15 m from the last one it kept, so a parked car does not fill the buffer with one street corner.

Press the button and it stops scanning, joins the access point in your config, and POSTs the buffer in batches of 200 with your bearer token. On a 2xx the buffer is cleared and every remembered BSSID forgotten — a clean slate — and it returns to scanning. On a failure it keeps everything and returns to scanning anyway, so you can try again at the next stop.

The screen and the colour

Wardrive screen: fix and satellite count, last scan count, buffered against buffer size, unique BSSIDs, position. Upload screen: access point, batch number, accepted and rejected counts, HTTP status. The RGB LED is amber waiting for a fix, green wardriving, blue uploading, solid green on success and red on failure.
The screen carries the detail; the LED is what you can read at a glance.

Colour says which mode, the blink rate says how it is doing — both, because a colour alone is easy to misremember at 50 km/h. Amber with one blink is waiting for a fix, green with two is wardriving, amber with three means the buffer is past 90%, blue at 5 Hz is uploading, solid green means accepted and cleared, red means the upload failed and the buffer was kept.

Get a token

Sign in and create one on the API tokens page, labelled for this dongle. It is shown once. A dongle is small and lives in a car, which is a good argument for giving it its own token: if it disappears, revoke that one and nothing else of yours is affected.

Build settings

Board
ESP32S3 Dev Module — there is no LilyGO entry for this one
USB CDC On Boot
Enabled — the GPS has taken UART0
Flash size
16MB
PSRAM
OPI PSRAM
Libraries
TinyGPSPlus, ArduinoJson v7, Adafruit ST7735 + Adafruit GFX

Get the four config lines right before you flash — access point, password and token — and the dongle needs nothing else from you ever again:

static const char *AP_SSID   = "your-hotspot";
static const char *AP_PASS   = "your-hotspot-password";
static const char *API_URL   = "https://wardrive.thugs.red/api/v1/ingest";
static const char *API_TOKEN = "wdrv_your_token_here";

The firmware

Structurally identical to the C5 sketch, section for section, so a diff between the two is short and readable. The listing is generated from the file, so it cannot drift from the download: thugs-wardrive-tdongle-s3.ino (19.2 KB).

/*
 * THUGS(red) Wardrive - wardriving firmware for the LilyGO T-Dongle-S3
 * -------------------------------------------------------------------
 * Target : LilyGO T-Dongle-S3 (ESP32-S3, 16 MB flash, 8 MB PSRAM)
 * GPS    : u-blox NEO-8M or NEO-6M on the dongle's 4-pin JST SH pads
 * Screen : the onboard ST7735 80x160 LCD, used landscape
 * API    : https://wardrive.thugs.red/api/v1/ingest
 *
 * Same firmware as thugs-wardrive-esp32c5.ino, same two modes, three
 * differences that come from the hardware:
 *
 *   1. The S3 is 2.4 GHz only. There is no 5 GHz band to alternate with, so a
 *      scan cycle is one scan. If you want 5 GHz, that is the ESP32-C5 build.
 *   2. Status goes to the onboard RGB LED (an APA102) as colour rather than to
 *      a single LED as a blink pattern - and to the LCD, which is not optional
 *      here because it is soldered on.
 *   3. The GPS lives on GPIO43/44, which are the pads LilyGO broke out. Those
 *      are also UART0, so the serial console moves to native USB.
 *
 * Behaviour
 *   Power on - plug it into a car USB socket - and it wardrives: scan, stamp
 *   with the GPS fix, buffer. The radio never associates while scanning.
 *
 *   Press the button while it is running and it stops scanning, joins the
 *   access point you configured below, and POSTs the buffer in batches. On
 *   success the buffer is cleared and it returns to scanning with a clean
 *   slate. On failure it keeps the buffer and returns to scanning anyway.
 *
 * Build
 *   Arduino IDE, esp32 board package 3.x, board "ESP32S3 Dev Module", and:
 *     USB CDC On Boot : Enabled      <- the log goes over USB, GPIO43/44 are the GPS
 *     Flash Size      : 16MB
 *     PSRAM           : OPI PSRAM
 *   Libraries: TinyGPSPlus, ArduinoJson v7, Adafruit ST7735 + Adafruit GFX.
 *
 *   Because UART0 is now the GPS, the board cannot fall into UART download
 *   mode. To flash it: unplug, hold the button, plug it in, release. The S3's
 *   native USB does the rest.
 */

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <SPI.h>
#include <TinyGPSPlus.h>
#include <ArduinoJson.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>

// ===================== configure this ======================================

static const char *AP_SSID   = "your-hotspot";
static const char *AP_PASS   = "your-hotspot-password";
static const char *API_URL   = "https://wardrive.thugs.red/api/v1/ingest";
static const char *API_TOKEN = "wdrv_your_token_here";   // from /tokens

static const uint16_t BUFFER_SIZE = 8000;   // ~480 KB in PSRAM
static const uint16_t BATCH_SIZE  = 200;
static const float    MOVE_GATE_M = 15.0f;  // metres before a scan counts as new ground

static const uint32_t SCAN_MS_PER_CHANNEL = 300;
static const uint32_t SCAN_INTERVAL_MS    = 1500;

// ===================== pins ================================================
// The four pads on the dongle's JST SH connector are 3V3, GND, GPIO43, GPIO44.

#define GPS_RX_PIN 44   // GPS TX goes here
#define GPS_TX_PIN 43   // goes to GPS RX

#define BUTTON_PIN 0    // the dongle's button, and the S3 boot strapping pin

#define LED_DATA_PIN  40  // APA102 data
#define LED_CLOCK_PIN 39  // APA102 clock

#define TFT_CS   4
#define TFT_DC   2
#define TFT_RST  1
#define TFT_SCLK 5
#define TFT_MOSI 3
#define TFT_BL  38        // backlight. If your unit stays dark, invert BL_ON.

static const bool BL_ON = HIGH;
static const uint8_t LED_BRIGHTNESS = 6;   // APA102 global brightness, 1..31

// ===================== state ==============================================

struct Observation {
  uint8_t  bssid[6];
  char     ssid[33];
  int8_t   rssi;
  uint8_t  channel;
  uint8_t  enc;       // index into ENC_NAMES
  float    lat;
  float    lon;
  int16_t  alt;
  uint32_t ts;        // unix seconds, 0 = let the server use its own clock
};

static const char *ENC_NAMES[] = {
  "unknown", "open", "wep", "wpa", "wpa2", "wpa3", "wpa2_wpa3"
};

static Observation *buffer   = nullptr;
static uint16_t     capacity = 0;
static uint16_t     buffered = 0;
static uint32_t     dropped  = 0;

static const uint16_t SEEN_SLOTS = 4096;
static uint32_t      *seen = nullptr;
static uint16_t       seenCount = 0;

static TinyGPSPlus gps;
static double lastKeptLat = 0, lastKeptLon = 0;
static bool   hasKeptFix  = false;

static uint16_t lastFound  = 0;
static uint32_t lastScanAt = 0;

static Adafruit_ST7735 tft(TFT_CS, TFT_DC, TFT_RST);

enum RigState { RIG_WAIT_FIX, RIG_WARDRIVE, RIG_BUFFER_HIGH, RIG_UPLOAD, RIG_OK, RIG_FAIL };
static RigState rigState = RIG_WAIT_FIX;
static uint32_t rigStateSince = 0;

static char lastHttp[24] = "-";
static uint32_t lastAccepted = 0, lastRejected = 0;

// ===================== RGB LED ============================================
//
// The APA102 wants a start frame, one 32-bit frame per LED and an end frame.
// Bit-banging it is a dozen lines and saves pulling in a library for one LED.

static void apaByte(uint8_t value) {
  for (int8_t bit = 7; bit >= 0; bit--) {
    digitalWrite(LED_DATA_PIN, (value >> bit) & 1);
    digitalWrite(LED_CLOCK_PIN, HIGH);
    digitalWrite(LED_CLOCK_PIN, LOW);
  }
}

static void ledColour(uint8_t r, uint8_t g, uint8_t b) {
  apaByte(0x00); apaByte(0x00); apaByte(0x00); apaByte(0x00);   // start frame
  apaByte(0xE0 | (LED_BRIGHTNESS & 0x1F));                      // 111 + brightness
  apaByte(b); apaByte(g); apaByte(r);                           // APA102 order is BGR
  apaByte(0xFF); apaByte(0xFF); apaByte(0xFF); apaByte(0xFF);   // end frame
}

static void ledOff() { ledColour(0, 0, 0); }

// Colour says what mode it is in; the blink rate says how it is doing. Both,
// because a colour alone is easy to misremember in a moving car.
struct LedLook { uint16_t slotMs; uint8_t slots; uint32_t mask; uint8_t r, g, b; };

static LedLook lookFor(RigState state) {
  switch (state) {
    case RIG_WAIT_FIX:    return { 100, 20, 0b1,      255, 140,   0 };  // amber, one blink
    case RIG_WARDRIVE:    return { 100, 20, 0b101,      0, 255,  90 };  // green, two blinks
    case RIG_BUFFER_HIGH: return { 100, 20, 0b10101, 255, 140,   0 };  // amber, three blinks
    case RIG_UPLOAD:      return { 100,  2, 0b01,      40, 120, 255 };  // blue, 5 Hz
    case RIG_OK:          return { 100, 20, 0xFFFFF,   0, 255,  90 };  // green, solid
    case RIG_FAIL:        return {  60, 10, 0b10101, 255,  30,  40 };  // red, stutter
  }
  return { 100, 20, 0b1, 255, 255, 255 };
}

static void setState(RigState state) {
  if (state != rigState) {
    rigState = state;
    rigStateSince = millis();
  }
}

static void updateLed() {
  LedLook look = lookFor(rigState);
  uint32_t slot = ((millis() - rigStateSince) / look.slotMs) % look.slots;
  if ((look.mask >> slot) & 1) ledColour(look.r, look.g, look.b);
  else                         ledOff();
}

static void ledHold(RigState state, uint32_t ms) {
  setState(state);
  uint32_t until = millis() + ms;
  while (millis() < until) {
    updateLed();
    delay(5);
  }
}

// ===================== screen =============================================

static const uint16_t INK   = ST77XX_WHITE;
static const uint16_t PAPER = ST77XX_BLACK;
static const uint16_t BRAND = ST77XX_RED;

static void screenBegin() {
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, BL_ON);

  SPI.begin(TFT_SCLK, -1, TFT_MOSI, TFT_CS);
  tft.initR(INITR_MINI160x80);
  tft.setRotation(1);            // landscape: 160 x 80
  tft.fillScreen(PAPER);
  tft.setTextWrap(false);
}

static void screenHeader(const char *mode) {
  tft.fillRect(0, 0, 160, 12, INK);
  tft.setTextColor(PAPER);
  tft.setTextSize(1);
  tft.setCursor(2, 2);
  tft.print("THUGS");
  tft.setTextColor(BRAND, INK);
  tft.print("(red)");
  tft.setTextColor(PAPER, INK);
  tft.print(" ");
  tft.print(mode);
}

static void screenLine(uint8_t row, const char *text) {
  const int16_t y = 16 + row * 11;
  tft.fillRect(0, y, 160, 10, PAPER);
  tft.setTextColor(INK);
  tft.setCursor(2, y);
  tft.print(text);
}

static void screenWardrive() {
  char line[28];
  screenHeader("WARDRIVE");

  if (gps.location.isValid()) {
    snprintf(line, sizeof(line), "FIX 3D  sat %d", (int) gps.satellites.value());
  } else {
    snprintf(line, sizeof(line), "no fix  sat %d", (int) gps.satellites.value());
  }
  screenLine(0, line);

  snprintf(line, sizeof(line), "last scan  %u", lastFound);
  screenLine(1, line);
  snprintf(line, sizeof(line), "buf  %u / %u", buffered, capacity);
  screenLine(2, line);
  snprintf(line, sizeof(line), "uniq %u", seenCount);
  screenLine(3, line);

  if (gps.location.isValid()) {
    snprintf(line, sizeof(line), "%.5f %.5f", gps.location.lat(), gps.location.lng());
    screenLine(4, line);
  }
}

static void screenUpload(uint16_t batch, uint16_t batches) {
  char line[28];
  screenHeader("UPLOAD");

  snprintf(line, sizeof(line), "AP  %.18s", AP_SSID);
  screenLine(0, line);
  snprintf(line, sizeof(line), "batch  %u / %u", batch, batches);
  screenLine(1, line);
  snprintf(line, sizeof(line), "acc %lu rej %lu", (unsigned long) lastAccepted, (unsigned long) lastRejected);
  screenLine(2, line);
  snprintf(line, sizeof(line), "HTTP %s", lastHttp);
  screenLine(3, line);
  screenLine(4, "");
}

static void screenBanner(const char *mode, const char *text) {
  screenHeader(mode);
  screenLine(0, text);
  for (uint8_t row = 1; row < 5; row++) screenLine(row, "");
}

// ===================== helpers ============================================

static float metresBetween(double lat1, double lon1, double lat2, double lon2) {
  const double R = 6371000.0;
  double latRad = radians((lat1 + lat2) / 2.0);
  double dx = radians(lon2 - lon1) * cos(latRad);
  double dy = radians(lat2 - lat1);
  return (float) (R * sqrt(dx * dx + dy * dy));
}

// GPS date/time to unix seconds, written out rather than trusting mktime()'s
// timezone handling.
static uint32_t gpsEpoch() {
  if (!gps.date.isValid() || !gps.time.isValid() || gps.date.year() < 2020) {
    return 0;
  }
  int32_t y = gps.date.year();
  int32_t m = gps.date.month();
  int32_t d = gps.date.day();

  y -= m <= 2;
  int32_t era = (y >= 0 ? y : y - 399) / 400;
  uint32_t yoe = (uint32_t) (y - era * 400);
  uint32_t doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
  uint32_t doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
  int32_t days = era * 146097 + (int32_t) doe - 719468;

  return (uint32_t) days * 86400u
       + gps.time.hour() * 3600u + gps.time.minute() * 60u + gps.time.second();
}

static uint8_t encIndex(wifi_auth_mode_t mode) {
  switch (mode) {
    case WIFI_AUTH_OPEN:           return 1;
    case WIFI_AUTH_WEP:            return 2;
    case WIFI_AUTH_WPA_PSK:        return 3;
    case WIFI_AUTH_WPA2_PSK:
    case WIFI_AUTH_WPA_WPA2_PSK:   return 4;
    case WIFI_AUTH_WPA3_PSK:       return 5;
    case WIFI_AUTH_WPA2_WPA3_PSK:  return 6;
    default:                       return 0;
  }
}

static uint16_t frequencyFor(uint8_t channel) {
  if (channel == 14) return 2484;
  return 2407 + 5 * channel;
}

static uint32_t bssidHash(const uint8_t *b) {
  uint32_t h = 2166136261u;
  for (int i = 0; i < 6; i++) { h ^= b[i]; h *= 16777619u; }
  return h ? h : 1;
}

static void seenAdd(const uint8_t *bssid) {
  uint32_t h = bssidHash(bssid);
  for (uint16_t probe = 0; probe < 64; probe++) {
    uint16_t slot = (h + probe) & (SEEN_SLOTS - 1);
    if (seen[slot] == h) return;
    if (seen[slot] == 0) { seen[slot] = h; seenCount++; return; }
  }
}

static void bufferReset() {
  buffered = 0;
  dropped = 0;
  seenCount = 0;
  memset(seen, 0, SEEN_SLOTS * sizeof(uint32_t));
  hasKeptFix = false;
}

// ===================== wardrive mode ======================================

static void feedGps(uint32_t ms) {
  uint32_t until = millis() + ms;
  do {
    while (Serial1.available()) gps.encode(Serial1.read());
    updateLed();
  } while (millis() < until);
}

static void scanCycle() {
  if (!gps.location.isValid()) {
    setState(RIG_WAIT_FIX);
    screenWardrive();
    lastScanAt = millis();
    return;
  }

  double lat = gps.location.lat();
  double lon = gps.location.lng();

  if (hasKeptFix && metresBetween(lastKeptLat, lastKeptLon, lat, lon) < MOVE_GATE_M) {
    lastScanAt = millis();
    return;                       // parked; the same corner again is not data
  }

  uint32_t ts = gpsEpoch();
  int found = WiFi.scanNetworks(false, true, false, SCAN_MS_PER_CHANNEL);
  if (found < 0) found = 0;
  lastFound = (uint16_t) found;

  for (int i = 0; i < found; i++) {
    if (buffered >= capacity) { dropped++; break; }

    Observation &o = buffer[buffered];
    memcpy(o.bssid, WiFi.BSSID(i), 6);
    strncpy(o.ssid, WiFi.SSID(i).c_str(), sizeof(o.ssid) - 1);
    o.ssid[sizeof(o.ssid) - 1] = '\0';
    o.rssi    = (int8_t) WiFi.RSSI(i);
    o.channel = (uint8_t) WiFi.channel(i);
    o.enc     = encIndex(WiFi.encryptionType(i));
    o.lat     = (float) lat;
    o.lon     = (float) lon;
    o.alt     = (int16_t) (gps.altitude.isValid() ? gps.altitude.meters() : 0);
    o.ts      = ts;

    seenAdd(o.bssid);
    buffered++;
  }

  WiFi.scanDelete();

  lastKeptLat = lat;
  lastKeptLon = lon;
  hasKeptFix = true;
  lastScanAt = millis();

  setState(buffered > (uint32_t) capacity * 9 / 10 ? RIG_BUFFER_HIGH : RIG_WARDRIVE);
  screenWardrive();

  Serial.printf("[scan] found=%d buffered=%u/%u uniq=%u dropped=%lu\n",
                found, buffered, capacity, seenCount, (unsigned long) dropped);
}

// ===================== upload mode ========================================

enum PostResult { POST_SENT, POST_RETRY, POST_ABORT };

static PostResult postRange(uint16_t from, uint16_t count) {
  JsonDocument doc;
  JsonArray arr = doc["observations"].to<JsonArray>();

  for (uint16_t i = from; i < from + count; i++) {
    const Observation &o = buffer[i];
    char mac[18];
    snprintf(mac, sizeof(mac), "%02x:%02x:%02x:%02x:%02x:%02x",
             o.bssid[0], o.bssid[1], o.bssid[2], o.bssid[3], o.bssid[4], o.bssid[5]);

    JsonObject r = arr.add<JsonObject>();
    r["bssid"] = mac;
    if (o.ssid[0] != '\0') r["ssid"] = o.ssid;
    r["enc"]  = ENC_NAMES[o.enc];
    r["rssi"] = o.rssi;
    r["ch"]   = o.channel;
    r["freq"] = frequencyFor(o.channel);
    r["lat"]  = serialized(String(o.lat, 6));
    r["lon"]  = serialized(String(o.lon, 6));
    if (o.alt != 0) r["alt"] = o.alt;
    if (o.ts != 0)  r["ts"]  = o.ts;
  }

  String body;
  serializeJson(doc, body);

  WiFiClientSecure client;
  // Not verifying the certificate: this rig has no clock and no CA bundle. The
  // token is the only secret on the wire and it is revocable from /tokens. Pin
  // the root with setCACert() instead if you would rather not make that trade.
  client.setInsecure();

  HTTPClient http;
  http.setTimeout(20000);
  if (!http.begin(client, API_URL)) return POST_RETRY;

  const char *wanted[] = { "Retry-After" };
  http.collectHeaders(wanted, 1);
  http.addHeader("Content-Type", "application/json");
  http.addHeader("Authorization", String("Bearer ") + API_TOKEN);
  http.addHeader("User-Agent", "thugs-wardrive-tdongle/1.0");

  int code = http.POST(body);
  snprintf(lastHttp, sizeof(lastHttp), "%d", code);
  String payload = http.getString();
  String retryAfter = http.header("Retry-After");
  http.end();

  Serial.printf("[post] %u records -> HTTP %d %s\n", count, code, payload.c_str());

  if (code == 200 || code == 202) {
    JsonDocument res;
    if (deserializeJson(res, payload) == DeserializationError::Ok) {
      lastAccepted += res["accepted"] | 0;
      lastRejected += res["rejected"] | 0;
    }
    return POST_SENT;
  }

  if (code == 429) {
    uint32_t wait = retryAfter.toInt();
    if (wait == 0 || wait > 120) wait = 60;
    Serial.printf("[post] rate limited, waiting %us\n", wait);
    uint32_t until = millis() + wait * 1000;
    while (millis() < until) { updateLed(); delay(10); }
    return POST_RETRY;
  }

  if (code == 401 || code == 422) return POST_ABORT;   // token or batch is wrong
  if (code == 503) return POST_ABORT;                  // ingest off, or maintenance
  return POST_RETRY;
}

static bool uploadBuffer() {
  if (buffered == 0) return true;

  setState(RIG_UPLOAD);
  lastAccepted = lastRejected = 0;
  strncpy(lastHttp, "...", sizeof(lastHttp));

  WiFi.scanDelete();
  WiFi.mode(WIFI_STA);
  WiFi.begin(AP_SSID, AP_PASS);

  screenBanner("UPLOAD", "joining AP...");
  uint32_t until = millis() + 20000;
  while (WiFi.status() != WL_CONNECTED && millis() < until) { updateLed(); delay(50); }

  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[upload] no AP; keeping the buffer");
    return false;
  }

  uint16_t batches = (buffered + BATCH_SIZE - 1) / BATCH_SIZE;
  uint16_t sentTo = 0;
  uint16_t batchNo = 0;

  while (sentTo < buffered) {
    uint16_t count = min<uint16_t>(BATCH_SIZE, buffered - sentTo);
    batchNo++;
    screenUpload(batchNo, batches);

    PostResult result = POST_RETRY;
    for (uint8_t attempt = 0; attempt < 4 && result == POST_RETRY; attempt++) {
      if (attempt > 0) {
        uint32_t wake = millis() + (1000u << attempt);   // 2s, 4s, 8s
        while (millis() < wake) { updateLed(); delay(10); }
      }
      result = postRange(sentTo, count);
      if (result == POST_RETRY && count > 1) count /= 2;
    }

    if (result != POST_SENT) {
      Serial.println("[upload] giving up; keeping what is left");
      return false;
    }
    sentTo += count;
  }

  return true;
}

static bool buttonPressed() {
  if (digitalRead(BUTTON_PIN) != LOW) return false;
  delay(80);
  if (digitalRead(BUTTON_PIN) != LOW) return false;
  while (digitalRead(BUTTON_PIN) == LOW) { updateLed(); delay(10); }
  return true;
}

static void enterWardriveMode() {
  WiFi.disconnect(true, true);
  WiFi.mode(WIFI_STA);
  lastScanAt = 0;
  setState(gps.location.isValid() ? RIG_WARDRIVE : RIG_WAIT_FIX);
  screenWardrive();
}

// ===================== setup / loop =======================================

void setup() {
  pinMode(LED_DATA_PIN, OUTPUT);
  pinMode(LED_CLOCK_PIN, OUTPUT);
  ledOff();
  pinMode(BUTTON_PIN, INPUT_PULLUP);

  Serial.begin(115200);                  // native USB, because UART0 is the GPS
  Serial1.begin(9600, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);

  screenBegin();
  screenBanner("WARDRIVE", "booting...");

  capacity = BUFFER_SIZE;
  buffer = (Observation *) ps_malloc(sizeof(Observation) * capacity);
  if (buffer == nullptr) {
    // PSRAM missing or not enabled in the board menu. Shrink rather than fail;
    // every bounds check reads `capacity`, never BUFFER_SIZE.
    capacity = 1500;
    Serial.println("[boot] no PSRAM; falling back to a smaller buffer");
    buffer = (Observation *) malloc(sizeof(Observation) * capacity);
  }
  seen = (uint32_t *) calloc(SEEN_SLOTS, sizeof(uint32_t));

  if (buffer == nullptr || seen == nullptr) {
    Serial.println("[boot] out of memory; halting");
    screenBanner("WARDRIVE", "out of memory");
    while (true) { ledHold(RIG_FAIL, 1000); }
  }

  Serial.println("THUGS(red) Wardrive - T-Dongle-S3");
  Serial.printf("[boot] buffer=%u records\n", capacity);

  enterWardriveMode();
}

void loop() {
  feedGps(200);

  if (buttonPressed()) {
    Serial.printf("[button] upload requested with %u buffered\n", buffered);
    bool ok = uploadBuffer();

    if (ok) {
      bufferReset();                     // clean slate: no records, no BSSIDs, no fix
      screenBanner("UPLOAD", "uploaded. clean.");
      ledHold(RIG_OK, 2000);
    } else {
      screenBanner("UPLOAD", "failed - kept buffer");
      ledHold(RIG_FAIL, 2000);
    }

    enterWardriveMode();
    return;
  }

  if (millis() - lastScanAt >= SCAN_INTERVAL_MS) {
    scanCycle();
  }
}

Check it works

The serial monitor is the USB port now, at 115200:

THUGS(red) Wardrive - T-Dongle-S3
[boot] buffer=8000 records
[scan] found=23 buffered=23/8000 uniq=23 dropped=0
[scan] found=19 buffered=42/8000 uniq=38 dropped=0
[button] upload requested with 42 buffered
[post] 42 records -> HTTP 202 {"accepted":41,"rejected":1,
       "errors":[{"index":7,"reason":"unusable_gps_fix"}]}

Then check the map. Every field and status code the rig can receive is documented on the API page.

When it does not work

Screen stays black
Backlight polarity varies. Invert BL_ON in the sketch.
Screen works, no GPS
TX/RX swapped, or the module is not on 9600. Swapping them harms nothing — try it.
Nothing on the serial monitor
USB CDC On Boot is still disabled, so the log is going out of the pads the GPS is on.
Will not enter flashing mode
Unplug, hold the button, plug in, release. UART download mode is unavailable by design here.
Dongle gets hot
Normal-ish for an S3 scanning continuously in a plastic shell. Give it airflow rather than a sunny dashboard.
HTTP 401 / 429 / 413
Same as any client: bad token, too often, too big. The error table has the full list.

Notes from experience

Want 5 GHz as well? That is the ESP32-C5 build — same firmware, one more band. Writing your own client instead? Do it yourself via API.