(Disclaimer this was heavily AI assisted) A community extension of the HackerBox 0127 "Sea Five" example sketches. It picks up where the example leaves off: every network gets stamped with its GPS position at the moment it's detected, BLE scanning is filled in, and the output is WiGLE-1.4 CSV you can upload directly to wigle.net.
Two files, same names and same shape as the originals, so you can diff them side by side and see exactly what changed. No new libraries to install.
---
The example sketches that ship with Sea Five do their job really well. They prove
out every peripheral on the board — GPS, OLED, SD, both radios — and they
demonstrate the two microcontrollers talking to each other in both directions,
which is the genuinely tricky part of a dual-MCU design. If you're holding a
freshly soldered board and want to know whether you got it right, that's exactly
the code you want. The // Scan BLE / TBD comment is the folks at HackerBoxes
leaving the door open on purpose, and this is one answer to that invitation.
The step from there to a working wardriver comes down to one thing: positions need to be attached to individual networks.
Right now the output file looks like this:
Date/Time: 2026-07-15 14:22:31
Sats: 9 HDOP: 1.100000
Lat: 39.00000 Long: -85.00000
6, AA:BB:CC:DD:EE:FF MyNetwork -61 WPA2
11, 11:22:33:44:55:66 OtherNet -74 WPA2
Date/Time: 2026-07-15 14:22:46
...
It reads like the coordinates are a header for the networks below them. They're not, and the reason is a neat lesson in how concurrency shows up in embedded code even when you didn't ask for it.
MCU A's loop is read\_intercom() then ScanWiFi(). read\_intercom() drains
everything MCU B sent while A was busy and writes each line to the card as it
arrives. Then scanNetworks() blocks for several seconds and writes its own
finds. Loop, repeat. So each cycle produces a burst of B's backlog — GPS status
lines, in arrival order — followed by A's networks. B sends GPS every 3 seconds
but A only reads between scans, so several GPS lines queue up and land together.
The grouping is an emergent property of the timing, not a record structure. That means the position is offset in two directions at once: it's a snapshot of wherever B was when it last transmitted, possibly 15 seconds stale by the time it reaches the card, and every AP listed underneath was seen across the following 15 seconds. At 45 mph that's a quarter-mile of road sharing one coordinate.
Which is why this can't be solved with a clever parser after the fact. The position has to be attached at the moment of detection.
---
This is the key thing to understand about the Sea Five board, and it's why you can't simply drop in firmware from other ESP32-C5 wardriver projects:
| MCU A | MCU B | |
|---|---|---|
| SD card | ✅ SPI (sck 8, miso 9, mosi 10, cs 5) | ❌ |
| GPS | ❌ | ✅ UART (rx 5, tx 4) |
| OLED | ❌ | ✅ I²C (sda 10, scl 6) |
| Wi-Fi | 2.4 GHz | 5 GHz |
| BLE | — | ✅ |
| USB console | ✅ (FT232RL) | |
| Intercom | UART1, gpio 0/1 | UART0, gpio 0/1 |
The peripherals are split across two physically separate chips. A owns the
card. B owns the GPS. Most single-board wardriver firmware assumes SD + GPS +
display all hang off one MCU, and no amount of pin remapping crosses a chip
boundary — a #define can't reach the other processor. Sea Five's layout is its
own thing, and the software has to be shaped to fit it.
So the data flow follows the wiring:
MCU B MCU A
┌────────────────┐ ┌────────────────┐
│ GPS ──► fix │ $G,fix,lat,... │ cached fix │
│ │ │ ──────────────► │ │ │
│ ▼ │ (every 1s) │ ▼ │
│ 5GHz ──► stamp │ │ 2.4GHz ► stamp │
│ BLE ──► stamp │ $N,<csv row> │ │ │
│ │ │ ──────────────► │ ▼ │
│ ▼ │ │ SD CARD │
│ OLED ◄───────┼─── <XNNNN> ─────┼── SD status + │
└────────────────┘ (stats back) │ 2.4G count │
└────────────────┘
B stamps its own records — it holds the fix in RAM the instant it finds something, so there's zero latency. B pushes the fix to A once a second; A caches it and stamps its own 2.4 GHz finds. Every record carries its own position before it travels anywhere, and A simply writes finished lines.
Once every line is self-contained, the grouping problem evaporates. It no longer matters what order things reach the card in.
Previously A appended whatever B sent, which is how GPS status text ended up in the log alongside networks. Lines are tagged now:
| Direction | Message | Meaning |
|---|---|---|
| B → A | $G,fix,lat,lon,alt,acc,YYYY-MM-DD HH:MM:SS,sats |
GPS update, once per second |
| B → A | $N,<complete csv row> |
A finished record — just write it |
| A → B | <XNNNN> |
SD status + 2.4 GHz count, for the OLED |
The intercom now runs at 115200 baud, up from 9600. At 9600 you get ~960 bytes/sec, and WiGLE rows run about 170 bytes — roughly 5 records/sec for everything B sends, which backs up in a dense neighborhood. Both ends are ours, so it's a one-line change on each side.
WiGLE-1.4 CSV, one file per session (SeaFive\_000.csv, \_001, …), with the
version header written at file creation:
WigleWifi-1.4,appRelease=SeaFive-1.0,model=SeaFive,...
MAC,SSID,AuthMode,FirstSeen,Channel,Frequency,RSSI,CurrentLatitude,CurrentLongitude,AltitudeMeters,AccuracyMeters,Type
"AA:BB:CC:DD:EE:FF","MyNetwork","\[WPA2-PSK-CCMP]\[ESS]","2026-07-15 14:22:31",6,2437,-61,39.0000,-85.0000,231.4,2.8,WIFI
Two details that catch everyone out the first time:
- AuthMode uses Android's bracket syntax, not friendly names.
"WPA2"won't parse — it has to be\[WPA2-PSK-CCMP]\[ESS]. - Frequency is a separate column from Channel, derived from it:
2.4 GHz is
2407 + ch\*5(channel 14 is a special case at 2484), 5 GHz is5000 + ch\*5.
AccuracyMeters is estimated as HDOP × 2.5. The receiver doesn't give us a real
accuracy figure, so this is a rough but honest approximation.
If the GPS fix is missing or older than 5 seconds, nothing is written.
The check happens before the dedup check, and that ordering is deliberate. If a network were marked "seen" while we had no position, it would be suppressed for the rest of the session and never logged at all. Skipping it entirely means we catch it on the next pass. A duplicate row costs nothing — WiGLE dedups server-side, and repeat observations at different coordinates are how it triangulates. A missing record is gone for good.
The OLED shows GPS: -- when there's no usable fix, so a card that isn't filling
up tells you why.
---
A few changes here are the kind of thing that only surfaces once code moves from a bench demo to hours of continuous logging in a moving vehicle. They're worth reading through even if you're not wardriving — they're a nice tour of the classic embedded C sharp edges, and they're easy to hit in any project.
Buffer sizing on the GPS strings.
char longitude\[10];
dtostrf(((double)nmea.getLongitude() / 1000000.0), 8, 6, longitude);-85.921234 is 10 characters plus a null terminator — 11 bytes into a 10-byte
buffer. On the bench with a fixed position you may never notice; on the road it
happens on every fix. Sized to 16 here, using width 0 ("as long as it needs to
be") rather than 8, which also drops the leading spaces.
Dedup table behavior once full.
if (bssidlogcount<bssidlogmax){ ...add... }
return false; // "not seen, go log it"Past 200 entries the insert stops but the MAC is still reported unseen, so already-logged networks start re-logging on each scan. 200 is plenty for a bench test and you'd never see it; a drive through a few neighborhoods gets there quickly. Now a 512-entry ring buffer that overwrites the oldest entry, so the table keeps working no matter how long you drive.
Loop bound in the table search.
for (int c=0; c < bssidlogcount+1; c++)Reads one entry past the last valid one. Now bounded by the fill count.
Line buffer index across calls.
void read\_intercom() {
int i = 0; // local
while (intercom.available() > 0) { ... }
}If the UART runs dry mid-line, the partial line is lost and the next call restarts
at i = 0. idx is static now, so partial lines survive between calls.
GPS throughput during scans.
read\_gps() only ran between blocking scanNetworks() calls. A scan takes
seconds, and the default 256-byte UART FIFO holds less NMEA than arrives in that
window. Now setRxBufferSize(2048) before begin(), plus read\_gps()
interleaved between the blocking scans.
Unused variable. newnets is never set, so the OLED only updated on its
1500 ms timer. Removed.
---
Nothing new to install. The list is identical to the original example:
| Library | Where from | Notes |
|---|---|---|
| Adafruit SSD1306 | Library Manager | Pulls in Adafruit GFX automatically |
| MicroNMEA | Library Manager | GPS sentence parsing |
BLE support (BLEDevice.h, BLEScan.h, BLEAdvertisedDevice.h) is part of the
ESP32 Arduino core — it arrives with the board package in Boards Manager, not
Library Manager. If you already built the original example, you're ready to build
this one.
Tools → Partition Scheme → Huge APP (3MB No OTA/1MB SPIFFS)
This is the one setting that changes, and it's worth understanding rather than just clicking.
The ESP32's internal flash is carved into partitions at compile time, and your
firmware image has to fit the partition labelled app. The default 4MB scheme
reserves two app slots (ota\_0 / ota\_1) so over-the-air updates can download
into the inactive one and flip a pointer — which leaves each slot only ~1.2MB,
plus ~1.5MB of SPIFFS.
MCU B's image is roughly 900KB with Wi-Fi, and BLEDevice.h brings in Bluedroid —
Espressif's full Bluetooth host stack — pushing it to ~1.4–1.6MB. Against a 1.2MB
slot it won't link, and you'll get a "text section exceeds available space" error
at build time. Deterministic, not flaky, and easy to mistake for a broken sketch.
Huge APP drops OTA to a single 3MB slot and shrinks SPIFFS to 1MB. We give up over-the-air updates, which this design never had, and internal flash storage, which we never touch — the SD card is a separate device on the SPI bus and is completely unrelated. Both are free to us.
To be clear about what this is not: it's not RAM, not temp files, and nothing to do with the SD card.
Strictly speaking only B needs it — A has no BLE and still fits the default. Set both anyway; one instruction is easier to follow than two, and A only gives up flash it wasn't using.
**For the curious:** NimBLE is a leaner BLE host than Bluedroid and would likely squeeze under the default partition. It's an extra library install, which is the only reason it's not used here.
---
- Power on. No fix yet, OLED shows
GPS: --, nothing logs. This is correct, not a hang. - Get outside or near a window. The ATGM336H's patch antenna wants sky.
- Sats lock,
GPS:shows a count, records start flowing. - MCU A's USB console echoes every line as it hits the card.
- Pull the SD card and upload
SeaFive\_000.csvto WiGLE.
Debugging note: MCU B's intercom shares UART0 with its USB console — that's why the original has "select ONLY ONE of the next two lines." While the intercom is live, B has no serial console. Watch A's console instead; it echoes everything that reaches the card.
---
Being upfront about the parts that are best-effort rather than verified:
- The BLE row format (
Type=BLE,AuthMode=\[BLE], channel and frequency 0) is a best reading of the convention, not confirmed against a live upload. Some projects recommend keeping BLE in a separate file so the Wi-Fi CSV stays clean. If WiGLE objects, that's the fallback and it's an easy change: give B a$B,prefix and have A write those toSeaFive\_000\_BLE.csv. - Wi-Fi / BLE coexistence on the C5, with a blocking scan of each, should be
fine — but if you see resets or scan starvation, that's the first suspect.
Stretching
BLE\_SCAN\_SECSor scanning BLE every Nth loop is the lever.
Corrections and pull requests welcome on both counts.
---
Kept minimal on purpose — this should stay something you can read straight through and diff against the original example:
- No FreeRTOS tasks or async scanning. Would tighten the timing, would also double the difficulty of reading the code.
- No web UI, no direct upload. Pull the card.
- No permanent per-session dedup (hash table, or a seen-list on SD). Real complexity for a problem WiGLE already solves server-side.
Every one of these is a good next project if you want to take it further.
---
Built on the HackerBox 0127 "Sea Five" example sketches — thanks to the folks at HackerBoxes for the board, the kit, and example code that made the hardware easy to reason about. WiGLE CSV field reference cross-checked against dkyazzentwatwa/esp32-gps-wifi-wigle. Wardriving prior art worth your time: JosephHewitt/wardriver_rev3, justcallmekoko/ESP32DualBandWardriver, Hamspiced/piglet.