/* * 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 #include #include #include #include #include #include #include // ===================== 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(); 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(); 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(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(); } }