Build a dual-band ESP32-C5 rig
An ESP32-C5 and a GPS module cost less than a takeaway, sit in a car window scanning all day, and — unlike every ESP32 before it — hear 5 GHz as well as 2.4. This build drives itself: power it and it is already collecting, press one button when you get home and the whole drive goes to the map.
Parts
- Board
- Seeed Studio XIAO ESP32-C5. Thumbnail-sized, 8 MB PSRAM, U.FL antenna in the box. Any ESP32-C5 board works — the DevKitC is bigger and cheaper.
- GPS
- u-blox NEO-8M with an active antenna. A NEO-6M works and costs less; the 8M gets a lock noticeably faster and holds it under trees.
- Power
- A USB-C car charger, or a power bank for walking. It draws well under half a watt.
- Optional
- An SSD1306 128×64 OLED. Worth it: you can see the fix and the buffer without a laptop.
Do not skip the antenna, and fit it before you apply power. Transmitting on 5 GHz into an empty U.FL connector damages the radio, and the board has no way to warn you.
Why the C5 rather than the C3 or an old ESP32
Because half the radio landscape is now above 2.4 GHz. A 2.4-only rig drives past a modern 5 GHz-only SSID and records nothing, which does not look like a gap in your data — it looks like an empty street. The C5 is the first ESP32 that scans both bands, so it is the first one that can honestly claim to have surveyed a road.
If you already own a 2.4 GHz board, it still makes a perfectly good rig; the firmware below works on an S3 or a C6 once you drop the band switching, which is exactly what the T-Dongle-S3 build does.
Wiring
| From | To | GPIO | Why |
|---|---|---|---|
GPS VCC | XIAO 3V3 | — | 3.3 V, not the 5 V pin |
GPS GND | XIAO GND | — | common ground |
GPS TX | XIAO D7 | GPIO12 | NMEA into the board's RX |
GPS RX | XIAO D6 | GPIO11 | only needed to configure the module |
OLED SDA | XIAO D4 | GPIO23 | optional display |
OLED SCL | XIAO D5 | GPIO24 | optional display |
Most NEO-8M breakouts tolerate 5 V on VCC, but their logic is
3.3 V and the XIAO's pads are not 5 V tolerant — so take power from
3V3 and there is nothing to think about. Get the GPS antenna as
close to the windscreen as you can; GPS through a car roof is poor.
How it behaves
Wardrive mode is the default, so there is nothing to start.
It alternates a 2.4 GHz scan and a 5 GHz scan — the ESP32-C5 scans one band at
a time, so the sketch flips WiFi.setBandMode() between cycles
rather than trusting a single call to cover both — stamps everything it hears
with the current fix, and buffers it. It never associates with anything it
finds: it is listening to beacons that are being broadcast at it, and that is
the whole activity.
Two records are deliberately never kept. One with no GPS fix,
because the API rejects it and 0,0 is worse than nothing — it
looks like data. And one from a spot the rig has not moved 15 m
from, because a car parked at a red light does not need sixty copies
of the same corner. That gate is MOVE_GATE_M at the top of the
sketch; lower it if you walk rather than drive.
Press BOOT and it uploads. Scanning stops, the radio joins the access point in your config, and the buffer goes out in batches of 200 with your bearer token. On a 2xx the buffer is cleared, every remembered BSSID is forgotten and it goes back to scanning with a clean slate. On a failure it keeps the buffer and goes back to scanning anyway, so a hotspot that would not join has cost you nothing but the walk to the car.
What the LED is doing
The patterns are a bitmask over 100 ms slots, so nothing in the firmware
blocks to blink. That matters more than it sounds: a delay() in
the scan loop is a stretch of road you did not survey.
If you fit a display
0x3C on boot. No display, no problem — the same lines go to the USB serial log.The buffer figure is the one to watch. It is how much driving you are carrying around unsent, and the LED starts triple-blinking when it passes 90%.
Get a token
Sign in and create one on the API tokens page, labelled for this rig. Copy it immediately — it is stored hashed and cannot be shown again. If the rig goes missing, revoke the token and it stops being able to post; nothing else of yours is affected. One token per device is the rule, for exactly that reason.
Build settings
- IDE
- Arduino IDE 2.x, or
arduino-cli - Board package
esp32by Espressif, 3.3.5 or newer — earlier ones have no C5- Board
XIAO_ESP32C5- PSRAM
- Enabled, so the buffer holds a long drive
- Libraries
TinyGPSPlus,ArduinoJsonv7,U8g2
U8g2 is needed to compile even if you never fit a display. ArduinoJson must be
v7 — the v6 API (StaticJsonDocument) will not build against this
sketch.
The firmware
Four lines to change, all at the top: your access point, its password, and the token. Then flash it and put it in the car.
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 whole sketch is below and the listing is generated from the file itself, so what you read is what you get: thugs-wardrive-esp32c5.ino (20.0 KB).
/*
* THUGS(red) Wardrive - dual-band wardriving firmware for the ESP32-C5
* ---------------------------------------------------------------------
* Target : Seeed Studio XIAO ESP32-C5 (any ESP32-C5 board works; see PINS)
* GPS : u-blox NEO-8M on UART, 9600 8N1
* Display: optional SSD1306 128x64 on I2C - probed at boot
* API : https://wardrive.thugs.red/api/v1/ingest
*
* Behaviour
* Power on and it wardrives. It alternates a 2.4 GHz scan and a 5 GHz scan,
* stamps everything it hears with the current GPS fix, and buffers it. The
* radio never associates while scanning, so nothing you pass is touched.
*
* Press BOOT while it is running and it stops scanning, joins the access
* point you configured below, and POSTs the buffer to the API in batches.
* On success the buffer is cleared and it returns to scanning with a clean
* slate. On failure the buffer is kept and it returns to scanning anyway,
* so a bad hotspot costs you nothing.
*
* Build
* Arduino IDE, esp32 board package 3.3.5 or newer, board "XIAO_ESP32C5".
* Libraries: TinyGPSPlus, ArduinoJson v7, U8g2 (needed to compile even
* if you never fit a display).
*
* Fit the U.FL antenna before you power the board. Transmitting on 5 GHz into
* an empty connector damages the radio.
*
* The T-Dongle-S3 build is thugs-wardrive-tdongle-s3.ino - same structure,
* single band, RGB LED and an LCD instead.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <TinyGPSPlus.h>
#include <ArduinoJson.h>
#include <U8g2lib.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
// How many observations to hold before the oldest are dropped. 8000 * 60 bytes
// is under 500 KB, which the C5's PSRAM swallows without noticing.
static const uint16_t BUFFER_SIZE = 8000;
// Observations per HTTP request. The API accepts 500 and 1 MB; 200 keeps each
// request small enough to retry cheaply over a phone hotspot.
static const uint16_t BATCH_SIZE = 200;
// Do not log the same spot twice: a scan is only kept if the rig has moved this
// far since the last kept scan. Stops a parked car filling the buffer with one
// street corner.
static const float MOVE_GATE_M = 15.0f;
static const uint32_t SCAN_MS_PER_CHANNEL = 300; // dwell per channel
static const uint32_t SCAN_INTERVAL_MS = 1500; // gap between scan cycles
// ===================== pins ================================================
#define GPS_RX_PIN 12 // D7 - GPS TX goes here
#define GPS_TX_PIN 11 // D6 - goes to GPS RX
#define I2C_SDA 23 // D4
#define I2C_SCL 24 // D5
#ifndef BOOT_PIN
#define BOOT_PIN 28 // ESP32-C5 boot strapping pin, internally pulled up
#endif
// The XIAO's L LED sinks to ground: LOW is lit. If yours is inverted, flip this.
static const bool LED_ACTIVE_LOW = true;
// ===================== state ==============================================
struct Observation {
uint8_t bssid[6];
char ssid[33];
int8_t rssi;
uint8_t channel;
uint8_t band; // 2 or 5
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; // what was actually allocated, not what was asked for
static uint16_t buffered = 0;
static uint32_t dropped = 0; // lost to a full buffer, reported on screen
// Cheap open-addressed set of BSSID hashes, only to count unique networks.
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 lastFound2g = 0, lastFound5g = 0;
static uint32_t lastScanAt = 0;
static uint8_t nextBand = 2;
static U8G2_SSD1306_128X64_NONAME_F_HW_I2C oled(U8G2_R0, U8X8_PIN_NONE);
static bool hasDisplay = false;
enum LedState { LED_WAIT_FIX, LED_WARDRIVE, LED_BUFFER_HIGH, LED_UPLOAD, LED_OK, LED_FAIL };
static LedState ledState = LED_WAIT_FIX;
static uint32_t ledStateSince = 0;
static char lastHttp[24] = "-";
static uint32_t lastAccepted = 0, lastRejected = 0;
// ===================== LED ================================================
//
// Each pattern is a bitmask over equal slots. Bit set = lit. This keeps every
// pattern non-blocking, which matters because a delay() in the scan loop is a
// delay in the drive.
struct LedPattern { uint16_t slotMs; uint8_t slots; uint32_t mask; };
static LedPattern patternFor(LedState state) {
switch (state) {
case LED_WAIT_FIX: return { 100, 20, 0b1 }; // one blink / 2 s
case LED_WARDRIVE: return { 100, 20, 0b101 }; // two blinks / 2 s
case LED_BUFFER_HIGH: return { 100, 20, 0b10101 }; // three blinks / 2 s
case LED_UPLOAD: return { 100, 2, 0b01 }; // 5 Hz
case LED_OK: return { 100, 20, 0xFFFFF }; // solid
case LED_FAIL: return { 60, 10, 0b10101 }; // rapid stutter
}
return { 100, 20, 0b1 };
}
static void ledWrite(bool lit) {
digitalWrite(LED_BUILTIN, LED_ACTIVE_LOW ? (lit ? LOW : HIGH) : (lit ? HIGH : LOW));
}
static void setLed(LedState state) {
if (state != ledState) {
ledState = state;
ledStateSince = millis();
}
}
static void updateLed() {
LedPattern p = patternFor(ledState);
uint32_t slot = ((millis() - ledStateSince) / p.slotMs) % p.slots;
ledWrite((p.mask >> slot) & 1);
}
// A short pattern the rider is meant to see, so this one does block - but only
// for as long as the confirmation lasts, and only after the drive has stopped.
static void ledHold(LedState state, uint32_t ms) {
setLed(state);
uint32_t until = millis() + ms;
while (millis() < until) {
updateLed();
delay(5);
}
}
// ===================== display ============================================
static void displayProbe() {
Wire.begin(I2C_SDA, I2C_SCL);
Wire.beginTransmission(0x3C);
hasDisplay = (Wire.endTransmission() == 0);
if (!hasDisplay) return;
oled.setI2CAddress(0x3C << 1);
oled.begin();
oled.setFont(u8g2_font_6x12_tf);
}
static void displayBanner(const char *line) {
if (!hasDisplay) return;
oled.clearBuffer();
oled.drawBox(0, 0, 128, 12);
oled.setDrawColor(0);
oled.drawStr(2, 10, "THUGS(red) WARDRIVE");
oled.setDrawColor(1);
oled.drawStr(2, 26, line);
oled.sendBuffer();
}
static void displayWardrive() {
if (!hasDisplay) return;
char line[26];
oled.clearBuffer();
oled.drawBox(0, 0, 128, 12);
oled.setDrawColor(0);
oled.drawStr(2, 10, "THUGS(red) WARDRIVE");
oled.setDrawColor(1);
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());
}
oled.drawStr(2, 24, line);
snprintf(line, sizeof(line), "2.4G %-4u 5G %u", lastFound2g, lastFound5g);
oled.drawStr(2, 35, line);
snprintf(line, sizeof(line), "buf %u / %u", buffered, capacity);
oled.drawStr(2, 46, line);
snprintf(line, sizeof(line), "uniq %u", seenCount);
oled.drawStr(2, 57, line);
oled.sendBuffer();
}
static void displayUpload(uint16_t batch, uint16_t batches) {
if (!hasDisplay) return;
char line[26];
oled.clearBuffer();
oled.drawBox(0, 0, 128, 12);
oled.setDrawColor(0);
oled.drawStr(2, 10, "UPLOADING");
oled.setDrawColor(1);
snprintf(line, sizeof(line), "AP %.16s", AP_SSID);
oled.drawStr(2, 24, line);
snprintf(line, sizeof(line), "batch %u / %u", batch, batches);
oled.drawStr(2, 35, line);
snprintf(line, sizeof(line), "acc %lu rej %lu", (unsigned long) lastAccepted, (unsigned long) lastRejected);
oled.drawStr(2, 46, line);
snprintf(line, sizeof(line), "HTTP %s", lastHttp);
oled.drawStr(2, 57, line);
oled.sendBuffer();
}
// ===================== helpers ============================================
// Metres between two fixes. Equirectangular, which is accurate to well under a
// metre at the distances a move gate cares about and costs one cos().
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 using mktime(), which
// is local-time based and would silently shift every timestamp if a future
// build ever set a timezone.
static uint32_t gpsEpoch() {
if (!gps.date.isValid() || !gps.time.isValid() || gps.date.year() < 2020) {
return 0; // the API falls back to its own clock, which is what we want
}
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; // includes the enterprise modes
}
}
static uint16_t frequencyFor(uint8_t band, uint8_t channel) {
if (band == 5) return 5000 + 5 * channel;
if (channel == 14) return 2484;
return 2407 + 5 * channel;
}
static uint32_t bssidHash(const uint8_t *b) {
uint32_t h = 2166136261u; // FNV-1a
for (int i = 0; i < 6; i++) { h ^= b[i]; h *= 16777619u; }
return h ? h : 1; // 0 marks an empty slot
}
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; // already counted
if (seen[slot] == 0) { seen[slot] = h; seenCount++; return; }
}
// Table is crowded; the count is a display nicety, so give up quietly.
}
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 uint16_t scanBand(uint8_t band, double lat, double lon, uint32_t ts) {
WiFi.setBandMode(band == 5 ? WIFI_BAND_MODE_5G_ONLY : WIFI_BAND_MODE_2G_ONLY);
// async=false, show_hidden=true, passive=false, per-channel dwell.
int found = WiFi.scanNetworks(false, true, false, SCAN_MS_PER_CHANNEL);
if (found < 0) found = 0;
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.band = band;
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();
return (uint16_t) found;
}
static void scanCycle() {
if (!gps.location.isValid()) {
// No fix, no point: the API rejects a record without one, and 0,0 is worse
// than nothing because it looks like data.
setLed(LED_WAIT_FIX);
displayWardrive();
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; // standing still; nothing new to say
}
uint8_t band = nextBand;
uint32_t ts = gpsEpoch();
uint16_t found = scanBand(band, lat, lon, ts);
if (band == 5) lastFound5g = found; else lastFound2g = found;
nextBand = (band == 2) ? 5 : 2; // alternate, so both bands get air time
lastKeptLat = lat;
lastKeptLon = lon;
hasKeptFix = true;
lastScanAt = millis();
setLed(buffered > (uint32_t) capacity * 9 / 10 ? LED_BUFFER_HIGH : LED_WARDRIVE);
displayWardrive();
Serial.printf("[scan] %u GHz found=%u buffered=%u/%u uniq=%u dropped=%lu\n",
band, 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.band, o.channel);
r["lat"] = serialized(String(o.lat, 6)); // 6 dp ~ 0.1 m; floats print long otherwise
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;
// Cloudflare terminates TLS in front of the site and this rig has no clock or
// CA bundle at boot, so the certificate is not verified. The token is the only
// thing at risk and it is revocable from /tokens; if that trade is not for
// you, pin the root with client.setCACert(...) and keep the time in sync.
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-c5/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) {
// A bad token or a batch the server will never like. Retrying is pointless.
return POST_ABORT;
}
if (code == 503) return POST_ABORT; // ingest off or maintenance: later
return POST_RETRY; // 5xx, timeouts, dropped hotspot
}
static bool uploadBuffer() {
if (buffered == 0) return true;
setLed(LED_UPLOAD);
lastAccepted = lastRejected = 0;
strncpy(lastHttp, "...", sizeof(lastHttp));
WiFi.scanDelete();
WiFi.setBandMode(WIFI_BAND_MODE_AUTO); // let it find the AP on either band
WiFi.mode(WIFI_STA);
WiFi.begin(AP_SSID, AP_PASS);
displayBanner("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; // records confirmed stored
uint16_t batchNo = 0;
while (sentTo < buffered) {
uint16_t count = min<uint16_t>(BATCH_SIZE, buffered - sentTo);
batchNo++;
displayUpload(batchNo, batches);
PostResult result = POST_RETRY;
for (uint8_t attempt = 0; attempt < 4 && result == POST_RETRY; attempt++) {
if (attempt > 0) {
uint32_t backoff = 1000u << attempt; // 2s, 4s, 8s
uint32_t wake = millis() + backoff;
while (millis() < wake) { updateLed(); delay(10); }
}
result = postRange(sentTo, count);
if (result == POST_RETRY && count > 1) {
// 413 or a body the link cannot carry: halve and try the smaller half.
count = count / 2;
}
}
if (result != POST_SENT) {
Serial.println("[upload] giving up; keeping what is left");
return false;
}
sentTo += count;
}
return true;
}
// A press is a press: 80 ms of held-low, then wait for release so one push does
// one upload however long it is held.
static bool bootPressed() {
if (digitalRead(BOOT_PIN) != LOW) return false;
delay(80);
if (digitalRead(BOOT_PIN) != LOW) return false;
while (digitalRead(BOOT_PIN) == LOW) { updateLed(); delay(10); }
return true;
}
static void enterWardriveMode() {
WiFi.disconnect(true, true);
WiFi.mode(WIFI_STA); // station mode, never associated
WiFi.setBandMode(WIFI_BAND_MODE_2G_ONLY);
nextBand = 2;
lastScanAt = 0;
setLed(gps.location.isValid() ? LED_WARDRIVE : LED_WAIT_FIX);
}
// ===================== setup / loop =======================================
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
ledWrite(false);
pinMode(BOOT_PIN, INPUT_PULLUP);
Serial.begin(115200);
Serial1.begin(9600, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
displayProbe();
displayBanner(hasDisplay ? "booting..." : "");
// PSRAM first, heap as a fallback with a smaller buffer.
capacity = BUFFER_SIZE;
buffer = (Observation *) ps_malloc(sizeof(Observation) * capacity);
if (buffer == nullptr) {
// No PSRAM, or it is not enabled in the board menu. Shrink rather than fail,
// and track the real size: bounds checks read `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");
while (true) { ledHold(LED_FAIL, 1000); }
}
Serial.println("THUGS(red) Wardrive - ESP32-C5");
Serial.printf("[boot] buffer=%u records display=%s\n", capacity, hasDisplay ? "yes" : "no");
enterWardriveMode();
}
void loop() {
feedGps(200);
if (bootPressed()) {
Serial.printf("[boot-btn] upload requested with %u buffered\n", buffered);
bool ok = uploadBuffer();
if (ok) {
// Clean slate: nothing buffered, no BSSID remembered, no stale fix.
bufferReset();
displayBanner("uploaded. clean.");
ledHold(LED_OK, 2000);
} else {
displayBanner("upload failed");
ledHold(LED_FAIL, 2000);
}
enterWardriveMode();
return;
}
if (millis() - lastScanAt >= SCAN_INTERVAL_MS) {
scanCycle();
}
}
Check it works
Open the serial monitor at 115200. A healthy rig says this, once per scan cycle:
THUGS(red) Wardrive - ESP32-C5
[boot] buffer=8000 records display=yes
[scan] 2 GHz found=19 buffered=19/8000 uniq=19 dropped=0
[scan] 5 GHz found=7 buffered=26/8000 uniq=26 dropped=0
[scan] 2 GHz found=21 buffered=47/8000 uniq=44 dropped=0
Then press BOOT:
[boot-btn] upload requested with 47 buffered
[post] 47 records -> HTTP 202 {"accepted":45,"rejected":2,
"errors":[{"index":5,"reason":"unusable_gps_fix"},
{"index":9,"reason":"invalid_bssid"}]}
A partial rejection is normal and is not a failure — read
accepted. Then go and look at the
map: the drive should be there, and
the API reference explains every field and code the
rig can get back.
When it does not work
- LED blinks once every 2 s forever
- No GPS fix. Check
TXandRXare not swapped, that the module is on 9600, and give it two minutes of clear sky on a cold start. - 5 GHz always finds 0
- Antenna not fitted, or there genuinely is no 5 GHz nearby — it is much shorter range than 2.4. Confirm with a phone before blaming the board.
HTTP 401- Token wrong, revoked, or the account is not active. Issue a fresh one from /tokens.
HTTP 429- Posting too often. The sketch waits out
Retry-Afterby itself; if you see it repeatedly, raiseBATCH_SIZE. HTTP 413- Batch over 500 records or 1 MB. The sketch halves and retries, so this should self-correct.
- LED is inverted
- Some boards source rather than sink. Flip
LED_ACTIVE_LOW. [boot] no PSRAM- PSRAM is off in the board menu. It still runs, on a 1500-record buffer.
- Cannot get into download mode
- On the C5,
GPIO27andGPIO28are strapping pins and also the LED and the BOOT button. Unplug, hold BOOT, plug in, release — rather than juggling BOOT and RESET while the sketch is driving the LED.
Notes from experience
- Heat matters. A dark board in a windscreen in July will brown out; keep it out of direct sun and the radio will thank you too.
- The 5 GHz scan is slower per channel than 2.4 and hears less far. Alternating rather than favouring one is the honest compromise, and it is why the sketch reports the two counts separately.
- 8000 records is roughly two hours of dense suburb. If you fill it, upload at the halfway coffee stop — the LED will have been triple-blinking to tell you.
- The certificate is not verified (
setInsecure()): this rig has no clock and no CA bundle at boot. The token is the only secret on the wire and it is revocable, which is the trade being made. Pin the root withsetCACert()if you would rather not make it. - Revoke the token if the rig leaves your possession. It is a credential, not a serial number.
Want the same thing in a USB stick with a screen already on it? See the T-Dongle-S3 build. Rolling your own client instead? Do it yourself via API. Built something better? Post it in gear and rigs.