Ragnar's wardriving engine collects WiFi networks, BLE devices, Zigbee / 802.15.4 devices, cell towers, and GPS positions while driving. Data is stored in SQLite per session and can be exported to WiGLE CSV, KML, or a printable HTML survey report.
Ragnar supports five operating modes, in any combination — all five can run at the same time and merge into one session DB:
| # | Mode | Hardware | What it adds |
|---|---|---|---|
| 1 | Standalone wardriver | Raspberry Pi (or PC) with its built-in Wi-Fi and/or one or more USB Wi-Fi adapters | WiFi scanning via iw, multi-adapter antenna coverage |
| 2 | + HuginnESP (USB) | ESP32-S3-Touch-LCD-4B or ESP32-C5 running HuginnESP | Real-time WiFi + BLE + AirTag/Flipper/skimmer/pineapple detection (+ Zigbee/802.15.4 on ESP32-C5) |
| 3 | + Piglet (USB) | Any Piglet board (XIAO ESP32-S3/C5/C6, LilyGo T-Dongle C5) Works only with my fork till tested and aprooved by Hamspiced, flash here | Live WiGLE-CSV stream over serial |
| 4 | + Piglet Coordinator (USB) | Dedicated Coordinator firmware on a Waveshare ESP32-C5 or ESP32-S3-Touch-LCD-4B | Receives records from a fleet of Piglet mesh nodes over ESP-Now and forwards them to Ragnar live |
| 5 | + Piglet Core (USB) | A regular Piglet board running mesh core mode, tethered to Ragnar |
Same idea as #4 but on a standard Piglet — the Core scans locally and aggregates its mesh nodes, streaming the combined feed to Ragnar |
All modes can be combined simultaneously — including multiples of the same type. Ragnar scans all /dev/ttyACM* and /dev/ttyUSB* ports at startup, starts a dedicated thread for every Espressif device it finds, and identifies each one from its boot banner. Example fleet: 2 USB WiFi antennas + 1 HuginnESP + 1 Piglet + 1 Piglet Core with 5 mesh nodes — all streams merge into the same session DB.
Companion identification banner (emitted at boot over USB-serial):
| Companion | Banner key | Banner value |
|---|---|---|
| HuginnESP | {"device":"HuginnESP",...} |
Detected via device field |
| Piglet (standard) | {"device":"Piglet",...} |
Falls back to WiGLE CSV header |
| Piglet Core (T-Dongle C5, mesh-core mode) | {"device":"PigletCore",...} |
Upgraded after config load |
| Piglet Coordinator (dedicated FW) | {"device":"RagnarCoord",...} |
Dedicated coordinator firmware |
Everything logged automatically receives GPS coordinates if a GPS receiver is connected.
Most wardrivers log observations with GPS-at-scan-time and discard the rest. Ragnar logs a GPS breadcrumb track during the session and runs a post-pass that backfills missing positions for any observation seen within 5 minutes of a real GPS point. The interpolation is speed-aware — when endpoint speeds differ (slowing for a tunnel, accelerating out the far side), it uses constant-acceleration math instead of constant-velocity, shifting positions toward whichever endpoint the device actually spent more time near.
Details and the math are in the GPS section below.
| Property | Value |
|---|---|
| Board | Waveshare ESP32-S3 Smart 86 Box |
| Display | 4" 480×480 RGB IPS, GT911 touch (I2C) |
| Processor | ESP32-S3, 240 MHz |
| Flash | 16 MB |
| PSRAM | 8 MB OPI |
| Serial | USB CDC, 460800 baud |
| Firmware | HuginnESP (PlatformIO Arduino) |
| Libraries | LovyanGFX 1.1.16, NimBLE-Arduino 1.4.1 |
Purpose-built ESP-Now mesh coordinator for Piglet nodes. Doesn't scan WiFi itself — it just listens to the mesh and forwards everything to Ragnar over USB as one-object-per-line JSON. Two boards are supported with the same firmware image; flash either one from the browser at the GitHub Pages flasher (no toolchain required).
| Property | C5 (headless) | S3-LCD (with display) |
|---|---|---|
| Board | Waveshare ESP32-C5-WIFI6-KIT | Waveshare ESP32-S3-Touch-LCD-4B |
| Processor | RISC-V @240 MHz | LX7 dual-core @240 MHz |
| Flash | 16 MB | 16 MB |
| PSRAM | 4 MB | 8 MB OPI |
| Display | none | 4″ 480×480 RGB IPS touch |
| Radio | Wi-Fi 6 dual-band, BLE 5 | Wi-Fi 4, BLE 5 |
| Serial | USB CDC, 460800 baud | USB CDC, 460800 baud |
| ESP-Now channel | 6 | 6 |
| Announce banner | {"device":"RagnarCoord","fw":"c5-1","board":"ESP32-C5",...} |
{"device":"RagnarCoord","fw":"s3-lcd-1","board":"ESP32-S3-LCD",...} |
| Source | espnow_bridge_firmware/ |
same |
Optional USB GPS receiver (NMEA via pyserial). Auto-detected at startup.
u-blox receivers stuck in UBX binary mode. Some u-blox 7 USB pucks
(VID 1546) come up emitting only UBX binary frames instead of NMEA, which
looks like a healthy receiver that never finds a position ("Searching..."
forever). Ragnar detects this and reconfigures the receiver in place over
the same serial port — no action needed; the journal logs
sent the NMEA-enable config (attempt N/3) when it happens. The cheap
pucks have no battery-backed RAM or flash, so the fix cannot be persisted
on them and is reapplied automatically after every power cycle or replug.
If the automatic recovery fails, the manual path is:
sudo systemctl stop ragnar
sudo python3 scripts/gps_set_nmea.py /dev/ttyACM0 --verify
sudo systemctl restart ragnarscripts/gps_diag.sh answers "is it the receiver or is it us" — it reports
whether the port is emitting NMEA, UBX binary, or nothing at all.
Ragnar is the host. WiFi adapters scan locally; one (optional) serial companion adds a second feed; an optional GPS receiver stamps everything. All sources write into the same per-session SQLite DB.
┌───────────────────────────┐
┌────►│ HuginnESP │ ← mode 2
│ │ WiFi + BLE + threats │
│ │ (480×480 touch) │
│ └───────────────────────────┘
│
│ ┌───────────────────────────┐
├────►│ Piglet (plain) │ ← mode 3
┌─────────────────────────────┐ │ │ WigleWifi-1.4 CSV stream │
│ Raspberry Pi / PC │ │ └───────────────────────────┘
│ │ USB CDC │
│ Ragnar │◄──────────┤ ┌───────────────────────────┐
│ ├─ wardriving.py │ 460800 ├────►│ Piglet Coordinator │ ← mode 4
│ ├─ webapp_modern.py │ │ │ (dedicated C5 / S3-LCD) │
│ └─ web UI │ │ │ receives mesh via ESPNow │
│ │ │ └───────────────────────────┘
│ wlan0..N ←─ iw scan │ │ ▲
│ (built-in + USB antennas) │ ← mode 1 │ │ ESP-Now ch 6
│ │ │ ▼
│ GPS (USB NMEA, opt.) │ │ ┌───────────────────────────┐
│ │ │ │ Piglet mesh nodes │
│ SQLite session DB │ │ └───────────────────────────┘
└─────────────────────────────┘ │
│ ┌───────────────────────────┐
└────►│ Piglet Core (regular │ ← mode 5
│ Piglet, mesh-core mode) │
│ scans + aggregates mesh │
└───────────────────────────┘
▲
│ ESP-Now ch 6
▼
┌───────────────────────────┐
│ Piglet mesh nodes │
└───────────────────────────┘
One serial companion at a time — they all share /dev/ttyACM*. Modes 4 and 5
are different implementations of "coordinator for a Piglet mesh"; you'd pick
one based on which hardware you have. The HuginnESP protocol below is the most
elaborate of the four; Piglet variants speak a simpler one-line-per-record
protocol described later.
| Command | Description |
|---|---|
scanap |
Start WiFi scanning |
blescan -f |
BLE filtered (Flipper + AirTag) |
blescan -a |
BLE all (all devices + threat detection) |
capture -skimmer |
BLE skimmer detection |
pineap |
Pineapple/Evil Twin detection |
stop |
Stop active scan, return to auto-cycle |
capture -stop |
Stop BLE capture |
status |
Get current mode (JSON) |
wardrive |
Enter the fast wardrive loop (default for HuginnESP) |
When a HuginnESP companion is detected, Ragnar issues wardrive once at handshake and reads the resulting stream continuously. The firmware then alternates two exclusive radio phases:
| Phase | Duration | Activity | Mode label |
|---|---|---|---|
| WiFi | ~2–5 s | Per-channel active scans walking a weighted channel list | wardrive |
| BLE | 1.5 s | BLE_MODE_ALL — every advertisement emitted as JSON |
wardrive |
The WiFi phase walks a fixed channel schedule that visits high-traffic channels (1/6/11 on 2.4 GHz, the non-DFS UNII subset on 5 GHz) several times per pass and the rarer/DFS channels once. Each per-channel scan emits results immediately on completion, so observations stream in throughout the phase rather than batching at the end of a full sweep. On the C5 dual-band build the full schedule is ~50 channels; on the S3 (2.4 GHz only) it's ~20.
The firmware also keeps a bounded on-device set of recently-emitted BSSIDs and suppresses duplicate emissions within a wardrive session — the same AP scanned on the same channel four times per cycle is only sent once. The set resets every time the host issues wardrive, so stopping and starting a session re-emits every visible BSSID. Ragnar's upsert_network still dedupes on receive; on-device dedup primarily saves serial bytes and host parse time.
Flipper / AirTag / skimmer / BLE-spam alerts still fire passively from the same BLE phase. Dedicated evil-twin/pineapple scan windows are not included in the wardrive loop — run pineap manually if a one-shot evil-twin check is needed.
Used when the companion identifies as something other than HuginnESP — Ragnar drives a manual rotation of scanap / blescan -f / blescan -a / capture -skimmer / pineap commands (94 s total cycle). The rotation is preserved for compatibility but is not the active path during normal HuginnESP operation.
{"type":"WIFI","mac":"00:00:00:00:00:00","ssid":"wifiSSID","rssi":-84,"channel":1,"auth":"WPA2"}Band split. Companion counts are bucketed into 2.4 GHz and 5 GHz. The band
is taken from an explicit "band" field when the firmware sends one (the
multi-line text format's Band: line also counts), otherwise inferred from the
channel — 1–14 is 2.4 GHz, 32+ is 5 GHz. A record with an unknown channel still
counts toward the companion total but lands in neither bucket, so the two never
over-report. The companion status bar shows 2.4G / 5G alongside the total
only once a 5 GHz network has actually been seen, so a 2.4-only board
(plain ESP32-S3) keeps its compact single-total display.
{"type":"BLE","mac":"AA:BB:CC:DD:EE:FF","name":"DeviceName","rssi":-60}Emitted by HuginnESP builds with an IEEE 802.15.4 radio (ESP32-C5). Zigbee and
Thread share the same 802.15.4 radio and channels (11–26), so the companion
sniffs both and tags each sighting with a proto field — zigbee, thread, or
802.15.4 (unknown). Matter-over-Thread is ordinary Thread traffic at the
radio layer, so it reports as thread. Ragnar stores these in a dedicated
zigbee_devices table (with the proto column) and counts them separately from
BLE — the live status bar and displays show a Thread / Zigbee total
alongside BT and Cell, the "Thread / Zigbee" table view lists them with a
Proto column, and /api/wardriving/zigbee returns the full list.
One C5 can't do WiFi and Zigbee at once. WiFi, BLE and 802.15.4 share a single 2.4 GHz radio on the ESP32-C5 — once 802.15.4 starts receiving, the WiFi scan can't reclaim the radio, so the wardrive cycle is WiFi + BLE only. To capture Zigbee/Thread simultaneously, add a second Huginn and give it the Zigbee role: on the wardrive dashboard, each Huginn companion bar has a Role selector — set the dedicated node to "Zigbee / Thread only" and Ragnar sends it the
zigbeecommand (parks WiFi/BLE, continuous 802.15.4 sniff) while the other keeps wardriving WiFi. The role is stored per serial port (wardriving_companion_roles), applied live, and exposed atGET/POST /api/wardriving/companion_role.
WiGLE export is opt-in. WiGLE has no standard 802.15.4 record type, so Zigbee devices are excluded from WiGLE CSV export by default. Enable Config → Wardriving → Include Zigbee in WiGLE CSV (
wardriving_wigle_include_zigbee) to append them with aZIGBEEtype token — intended for your own tooling, not for submitting to WiGLE. Applies to both manual export and Auto Export on Stop.
{"type":"ZIGBEE","panid":"0x1A2B","addr":"AABBCCDDEEFF0011","short":"0x1234","channel":15,"rssi":-70,"lqi":180,"ftype":"beacon","proto":"thread"}Each device is identified by its 64-bit extended address (addr / EUI-64) when
the frame carries one; otherwise Ragnar falls back to "<panid>:<short>" as the
identity so a device is still counted once. lqi (link quality) and the
802.15.4 channel (11–26) are retained; ftype (e.g. beacon / data /
cmd) is stored as the device type. GPS is stamped from Ragnar's fix when the
frame has none, exactly like WiFi/BLE rows.
AirTag found!
Tag: 1
MAC Address: D3:59:9B:E4:2C:3A
RSSI: -89
Found White Flipper Device:
MAC: AA:BB:CC:DD:EE:FF,
Name: Flipper-X,
RSSI: -70
POTENTIAL SKIMMER DETECTED!
Device Name: HC-05
MAC Address: 11:22:33:44:55:66
RSSI: -55
Reason: Suspicious BLE module near payment terminal
Known skimmer names: HC-05, HC-06, HC-08, BT05, BT06, JDY-30, JDY-31, JDY-33, SPP-CA
Pineapple detected: NetworkName
BSSID: AA:BB:CC:DD:EE:FF
Channel: 6
BLE Spam detected from AA:BB:CC:DD:EE:FF
Triggered at 20+ advertisements from the same MAC within 5 seconds.
{"mode":"auto","wifi_count":5,"ble_count":12}[BOOT] HuginnESP starting...
[BOOT] Free heap: 234567
[BOOT] PSRAM: 8388608
[BOOT] Init WiFi...
[BOOT] WiFi OK
[BOOT] Init BLE...
[BOOT] BLE OK
[BOOT] All tasks started — entering main loop
wardriving.py → _parse_serial_line() handles all output:
| Data type | Detection | Storage | GPS |
|---|---|---|---|
| WiFi JSON | line.startswith('{') → type == WIFI |
upsert_network() |
✅ |
| BLE JSON | line.startswith('{') → type == BLE |
upsert_bluetooth() |
✅ |
| AirTag | line.startswith('AirTag found') → buffer |
upsert_bluetooth('AirTag') |
✅ |
| Flipper | re.match('Found .* Flipper') → buffer |
upsert_bluetooth('Flipper') |
✅ |
| Skimmer | 'POTENTIAL SKIMMER' in line → buffer |
upsert_bluetooth('Skimmer') |
✅ |
| Pineapple | 'Pineapple detected' in line |
_esp_alerts list |
✅ |
| BLE Spam | 'BLE Spam detected' in line |
_esp_alerts list |
✅ |
| WiGLE CSV | Comma-separated with MAC format | upsert_network() / upsert_bluetooth() |
✅ |
| Multi-line WiFi | [N] SSID: ... → buffer |
upsert_network() |
✅ |
Ignored lines:
huginn>,Wardrive:,Registered,UnsupportedWiFi scan,Started,Stopped,Usage:,BLE initialized, etc.[BOOT]prefix (not explicitly filtered but matches no parser)
Auto-detected at startup, in priority order:
- gpsd on
localhost:2947— if agpsdinstance is running it owns the serial device; Ragnar reads its JSON stream (TPV/SKY). - Direct NMEA serial —
/dev/serial/by-id/*symlinks containing GPS keywords (gps,u-blox,ublox,nmea,gnss,bn-,vk-). - NMEA probe — other
by-identries that aren't already claimed by an ESP companion are probed at 9600/4800/38400/115200 baud for$GP/$GN/$GLsentences. - Raw device nodes —
/dev/ttyACM*,/dev/ttyUSB*,/dev/ttyS*,/dev/ttyAMA*,/dev/serial0,/dev/serial1— probed the same way.
gpsd + gpsd-clients are installed by the wardriving installer (install_wifi_management.sh), which then runs scripts/setup_gpsd.sh:
- Generic detection. The script pins
DEVICESto whatever USB GPS the standard detector (gps_manager.detect_gps_device) finds — any NMEA puck, not a single VID:PID — preferring a stable/dev/serial/by-id/*symlink so the pin survives a replug. USBAUTO="false"(deliberate). This stops gpsd's udev hotplug from seizing a companion ESP32 (Piglet/Huginn)/dev/ttyACM*port. Pinning + USBAUTO-off is what keeps gpsd and the companion serial readers from fighting over the same device.GPSD_OPTIONS="-n". gpsd polls the receiver before any client connects, sosatellites_in_view/snr_maxupdate while still searching for a fix.- Runtime ensure. On
start(), wardriving best-effort re-runssetup_gpsd.shif gpsd isn't active or a different GPS has been swapped in, thengps_managerconsumes the gpsd socket (source: gpsd). If gpsd isn't installed it silently falls back to direct serial. - Verify live.
cgpsorgpsmonshow per-satellite SNR in real time — useful for antenna placement / sky-test checks.
Re-run sudo scripts/setup_gpsd.sh manually after swapping to a different GPS receiver.
- Permissive talker IDs. The GGA/RMC/GSV regexes accept any two-letter talker prefix (GP, GN, GL, GA, GB, GI, GQ, …) so multi-GNSS modules are covered.
- Optional time field. Pre-fix receivers emit GGA/RMC with empty time and position. The parser accepts these so
last_updateand satellite counters move as soon as any NMEA is received — not only after first fix. - GSV parsing. Per-constellation
$xxGSVsentences are aggregated; the API exposessatellites_in_view(sum across all reporting constellations) andsnr_max(highest reported SNR in dB-Hz). The multi-message GSV sweep is also stitched back into a per-satellite list (PRN, elevation, azimuth, SNR) per constellation, which the diagnostics endpoint surfaces asgps.skyfor the sky-view plot; the NMEA 4.10+ trailing signal-ID field is ignored. Entries that haven't been heard from in 30 s are pruned so a constellation that stops reporting doesn't inflate the total. - Liveness signal.
last_sentenceupdates on any recognized NMEA line (including GSV / GSA / VTG / GLL / TXT and pre-fix GGA/RMC).last_updatecontinues to mean "last positional/fix update". Together they distinguish "GPS is alive but has no fix yet" from "GPS isn't transmitting at all".
The orchestrator's active scans — nmap port/vulnerability scans and attack
actions — are heavy. On a small board (Pi Zero 2 W especially) they thrash RAM
and CPU and starve gpsd of the continuous serial reads a cold start needs, so
the GPS shows satellites but never demodulates the ephemeris and never fixes.
That is why a receiver fixes when booted alone but not once the orchestrator is
also hammering hosts.
Starting a wardriving session sets shared_data.wardriving_session_active, and
the orchestrator pauses its whole active-scan/attack cycle (status
PAUSED_WARDRIVE) for the duration — including aborting any per-host
vulnerability scan already in flight. The nmap vulnerability scanner itself also
checks the flag, so a manually triggered scan (from the Adv Scan tab) is
skipped during a drive too, not only the orchestrator's automatic ones.
Passive wardriving capture keeps running the entire time; only the active
scans stop. They resume automatically when the session stops, with an immediate
refresh rather than waiting out the old interval. This frees the CPU so
cold-start GPS can complete during the drive.
Note: this is separate from the existing wardriving-on-boot behaviour, which
sets manual_mode so the orchestrator never starts in the first place. The flag
above covers the case where wardriving is started after the orchestrator is
already running (or scans are triggered manually). Scans launched directly from
a shell (nmap, lynis over SSH) are outside Ragnar and are not affected.
The common u-blox 7 USB pucks (VK-172 class) have no battery-backed RAM, so every power-up is a full cold start: the receiver doesn't know where it is, what time it is, or where any satellite is. Before it can fix it has to download ~30 s of uninterrupted ephemeris per satellite, and without an almanac it also has to search the whole sky blind. On a marginal sky (a window, a dashboard, a Wi-Fi adapter right next to the puck) that often never completes. The same receiver fixes fine once it has started, because tracking needs far less signal than acquisition.
Ragnar already knows most of what the receiver is missing, so
gps_assist.py hands it over at start (toggle:
Config → Wardriving → GPS Assisted Start, wardriving_gps_assist, default on):
- Position — the persisted last-known fix (
data/last_gps.json), declared as ±100 km so a fix from another town doesn't mislead it. - Time — the system clock, only when the kernel reports it NTP-synced
(
adjtimex). A Pi has no RTC; booted offline it runs on fake-hwclock, and a wrong time is worse than none, so an unsynced boot sends no time. - Orbit data — 5 s after the session's first fix, again 1 min later, then
every 5 min, Ragnar polls the receiver's own almanac (
AID-ALM), ephemeris (AID-EPH) and health/UTC/iono (AID-HUI) and saves them todata/gps_aid.json. The schedule runs from the first fix, whether or not the fix is still held: the receiver keeps what it decoded when a marginal fix flickers. A poll that returns nothing is retried a minute later. Saves merge per satellite, so a later poll that reports fewer satellites never shrinks the saved set. Each ephemeris entry keeps its own timestamp. At the next start the data is re-injected if fresh: ephemeris entries ≤ 4 h old (a reboot mid-drive becomes a hot start), almanac/HUI ≤ 30 days (a warm start). The statusaid_savedcounts show what is on disk in total. Orbit data is injected even on an offline boot (no NTP), which is the normal wardriving case. The receiver learns GPS time from the first satellite within seconds and checks each ephemeris against its own reference time, ignoring expired ones. fake-hwclock only runs behind real time, so data that is already too old by the local clock is still skipped.
It runs once per reader start, 3 s in, and only if there's no fix yet, so a
receiver that is still tracking (service restart mid-drive) is left alone.
Frames are standard UBX: AID-INI/HUI/ALM/EPH for u-blox 6/7/M8, plus
MGA-INI-POS_LLH/TIME_UTC for M8/M9/M10 (a receiver ignores the class it
doesn't implement). With gpsd they are written through gpsd's control socket
(/run/gpsd.sock, &<device>=<hex>) and replies are read from a raw
("raw":2) watch, so Ragnar never fights gpsd for the port; on direct serial
they go down the open port. The journal logs e.g.
GPS assist: pre-loaded position 59.3066,18.0256 (±100 km), time (NTP), almanac 31 SV via gpsd,
and the Diagnostics panel shows an Assisted start row.
Validated on a u-blox 7 (PROTVER 14.00) via gpsd: after injection the receiver's
own AID-INI readback showed the injected GPS week/TOW and position with
100 km accuracy (before: firmware defaults, week 1691, 6 496 km).
This speeds up the start; it doesn't create signal. A puck that can't hear satellites (behind coated glass, next to the Alfa) still needs a better spot: put it on a 1–2 m USB extension, face up, away from the Pi/hub/Wi-Fi adapter.
A Raspberry Pi has no real-time clock. Booted away from Wi-Fi it starts at the last saved time (systemd-timesyncd's clock file) and keeps that wrong time until NTP can reach a server, so every sighting in a session is stamped hours off. In the field a whole walk was recorded 2 h 38 min early and looked "missing" from the session list.
With Config → Wardriving → Set Clock from GPS (wardriving_gps_set_clock,
default on):
- GPS sets the clock. Once GPS time agrees with itself over 3 fixes and
differs from the system clock by more than 2 s, and only while the kernel
reports the clock not NTP-synced,
GPSManagersets the system clock from GPS time (once per reader start). This works for gpsd (TPV.time) and direct serial (RMC time + date). NTP stays in charge whenever it is synced. The Diagnostics panel shows a Clock from GPS row (gps.clock_set:{at, delta, changed}). - The running session is repaired. A 1 Hz watch compares wall-clock time
with monotonic time. When the clock steps forward by 10 s or more during a
session (the GPS set above, or NTP syncing once Wi-Fi is back), every
timestamp recorded before the step is shifted by it:
first_seenandlast_seenin networks, observations, Bluetooth, cells and Zigbee, the GPS track, and the session start.session_info.clock_stepsrecords each repair. Backward steps are only logged, because old and new stamps would overlap.
Data recorded before a fix, while the clock was still wrong, is corrected by the same repair when the step happens.
| Field | Meaning |
|---|---|
connected |
Port open and reader thread alive |
source |
gpsd or serial |
port |
Device path |
has_fix |
fix_quality > 0 and lat/lon set and last_update within 10 s |
fix_quality |
0 no fix, 1 GPS, 2 DGPS |
satellites |
Used in fix (from GGA) |
satellites_in_view |
Total visible across constellations (from GSV) |
snr_max |
Highest current SNR, dB-Hz |
hdop |
Horizontal dilution of precision |
latitude / longitude / altitude |
Most recent position |
speed_kmh / course |
Velocity / heading |
last_update |
Epoch of last GGA/RMC with position info |
last_sentence |
Epoch of last any parsed NMEA |
assist |
What the assisted start pre-loaded: {at, items[], via, frames}, or null |
aid_saved |
Last orbit-data save: {at, alm, eph} (SV counts), or null |
clock_set |
Clock set from GPS: {at, delta, changed} (changed: false = clock was already right), or null |
error |
Last error string, or null |
Shows the most actionable signals at a glance:
- Status line —
GPS-Fix OK/Searching (N visible)/Connected/No GPS. The visible count appears when there's no fix but the antenna is seeing satellites — it tells you whether you're antenna-limited or signal-limited. - Coords line —
lat, lononce a fix is established. - Sats line —
Sats: used/in-view · SNR N dB · HDOP H. HDOP is hidden while it's still the pre-fix 99.99 placeholder. - Speed line — velocity in the configured unit. Set Config → Wardriving → Speed Unit (
wardriving_speed_unit,kmhormph) to choose km/h or mph. This is display-only and applies everywhere speed is shown — GPS card, live map marker, kiosk readout, and the hardware display. Recorded data (speed_kmh) is always stored in km/h regardless of the setting.
Full reference: Diagnostics Panel Guide — the endpoint, every group, the Radios exclusion reasons, the Power/throttle fields, and the feed-stall correlation logic.
At the bottom of the Wardriving tab (and of the phone-access AP page,
web/wardrive_mobile.html) sits a Diagnostics panel, collapsed by default.
It is a native <details> element, so the toggle keeps working even if a script
errors — which is precisely when the panel gets opened.
Its summary always shows a live hint (GPS fix / GPS searching / no GPS,
with · error appended when the engine or GPS reports one), so a glance is
often enough without expanding. Expanded, it lists everything the
Status Object exposes, grouped:
The GPS, Session, Scanning, Coverage, Companions and
Device groups come from the status object the panel already polls. The
GPS constellations, GPS sky view, Radios, Power and Errors
groups come from a second endpoint, GET /api/wardriving/diagnostics, which the
panel fetches only while it is expanded (and at most every 8 s; the backend
caches 5 s).
That walk touches sysfs and shells out to vcgencmd, so it deliberately does
not ride the 3-second status poll.
| Group | Contents |
|---|---|
| GPS | fix + quality, satellites used/in-view, SNR max, HDOP, lat/lon/altitude, speed, course, source, port, age of last update and last NMEA sentence, time-to-first-fix (or how long it has been searching), error |
| GPS constellations | per-constellation satellites in view and peak SNR (GPS / GLONASS / Galileo / BeiDou / QZSS / NavIC) |
| GPS sky view | polar plot of every satellite by azimuth/elevation, coloured per constellation — North up, zenith at centre, horizon at the rim. Fill opacity tracks SNR (untracked satellites render hollow); hover a dot for PRN / elevation / azimuth / SNR. This is the graphical half of the same GSV data u-center draws |
| Radios | every wireless interface present, whether it is scanning, its driver / mode / link state, the USB adapter behind it — and when it is not scanning, the reason |
| Power | per-USB-device declared draw and which interface it backs, summed USB budget, usb_max_current_enable, supply throttle/under-voltage flags (now and since boot), core voltage, temperature, and Pi 5 PMIC board power |
| Errors | everything currently complaining — engine, GPS, radios, companions, supply and stalled feeds — gathered into one list |
Declared, not measured. The per-device milliamps come from the USB descriptor's
bMaxPower. No Pi meters per-port current, and the figure is frequently understated — a tri-band adapter that really pulls several hundred milliamps may declare 100 mA. Treat the total as the budget the host thinks it has handed out, not as consumption.usb_max_current_enableis a Pi 5 setting and is only shown on a Pi 5.
Stalled feeds. A feed that has stopped looks identical to a weak one in the summary numbers — the last-known satellite count and SNR simply sit at their final value. So Last NMEA and Last scan turn red once they go stale (30 s and 60 s), and the Errors group says so in words. When GPS and scanning go quiet within a minute of each other, it adds a note that both hang off USB, so a bus/hub glitch or a dip on the USB rail fits better than an RF or per-device fault — check
dmesgfor USB resets. That correlation is invisible if you only read the satellite counts. | Session | id, duration, network totals, open/WEP/WPA, per-band, Bluetooth, cell towers, Zigbee devices, cameras, trackpoints, strongest AP, DB path | | Scanning | running, band mode, scans completed, networks last scan, last scan age, interfaces, plus per-adapter driver / bands / USB / manufacturer / network count | | Coverage | BSSIDs seen by 2+ adapters, and per adapter its unique count, only-here count and median best RSSI — the antenna-comparison view (dashboard only) | | Companions | per-device up/down, network counts, 2.4/5 split, ESP mode, BLE count, Zigbee count (802.15.4 boards), mesh nodes, coordinator board/firmware, recent alerts | | Device | device name, Bluetooth/cell totals, GPS-backfill setting |
Fields with no value are omitted rather than rendered blank, and the panel skips its DOM work entirely while collapsed (re-rendering from the last status when expanded), so the polling loop costs nothing extra when it is closed.
Diagnosing "sees satellites but never gets a fix": open GPS and read SNR max together with Satellites (
used / in view) and Searching for. A cold start must demodulate the ephemeris — roughly 30 s of continuous reception at ≥30 dB-Hz — while an already-established fix tracks down to ~20 dB-Hz. So a receiver showing satellites in view with0 usedand a low SNR is signal-limited (antenna placement, or RF desense from an adapter sat next to the puck), whereas comparable SNR with the fix repeatedly resetting points at power instead. Power settles that second half: compare the summed USB draw against what the board allows, and check whetherunder-voltageappears under Right now or Since boot — the "since boot" flags are what catch a brownout that has already passed.
Diagnosing "only wlan0 is scanning": open Radios. Every wireless interface the kernel knows about is listed, and any radio that is not in the scan set carries a why not line —
rfkill-blocked(with the unblock command),held as the uplink / management radio(wardriving never claims the interface carrying Ragnar's own connectivity — see_management_ifaces),in AP mode (lent to the phone-access AP), a monitor child, or simply present but not claimed. A radio that does not appear at all was never enumerated by the kernel, which points at the adapter, cable or power rather than at Ragnar.
upsert_network uses COALESCE(?, col) for latitude, longitude, altitude, best_lat, best_lon, speed_kmh, and hdop on both the stronger-RSSI and weaker-RSSI update paths. Concretely: an existing row's GPS columns are never overwritten with NULL. A re-scan with a stronger signal but no current GPS fix keeps the previously-recorded position instead of erasing it.
Each session writes one row to gps_track every 5 s while GPS has a fix:
gps_track (timestamp, latitude, longitude, altitude, speed_kmh, satellites, hdop)
Opt-in only. The "Backfill GPS" map button is hidden by default; enable it under Config → Wardriving → Allow GPS Backfill (sets
wardriving_allow_backfill). The endpoint returns403while the flag is off. Any row backfilled this way is flaggedgps_backfilled = 1.
POST /api/wardriving/backfill_gps (or the "Backfill GPS" button) fills in missing positions on networks, bluetooth_devices, and cells rows by looking up each row's first_seen against the breadcrumb track:
bisectthe track to find the two trackpoints bracketing the row's timestamp.- Both within 5 minutes: interpolate position between them.
- One side within 5 minutes: use the nearest single trackpoint.
- Neither within 5 minutes: leave the row's coords as NULL.
The interpolation is speed-aware. When both bracketing trackpoints have a non-zero speed_kmh, the position fraction along the chord uses a constant-acceleration model instead of constant-velocity:
v(f_time) = v1 + (v2 - v1) · f_time
f_pos = (2·v1·f_time + (v2 − v1)·f_time²) / (v1 + v2)
lat = lat1 + (lat2 − lat1) · f_pos (same for lon, alt)
For symmetric speeds the formula reduces to linear — steady cruising is unaffected. For asymmetric speeds (slowing into a tunnel mouth then accelerating out the far side, for example) the placement shifts toward whichever endpoint was moving slower, where the device actually spent more time. On a 1 km gap with 20→60 km/h endpoints, a time-midpoint sample moves from 50 % chord (linear) to 37.5 % chord — a 125 m correction.
Falls back to linear when either endpoint speed is NULL or both are zero. The chord assumption itself isn't corrected — backfill cannot recover curve geometry from speed alone.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/wardriving/status |
Full status incl. GPS, serial, counters |
| GET | /api/wardriving/diagnostics |
Deep diagnostics: radios + exclusion reasons, USB power budget, supply health, GPS constellations, errors (panel) |
| POST | /api/wardriving/start |
Start wardriving session |
| POST | /api/wardriving/stop |
Stop session |
| GET | /api/wardriving/networks |
List captured WiFi networks |
| GET | /api/wardriving/bluetooth |
List BLE devices |
| GET | /api/wardriving/cells |
List cell towers |
| GET | /api/wardriving/sessions |
List sessions |
| GET | /api/wardriving/export/<id> |
Export session (?format=wigle CSV / kml / report HTML survey) |
| POST | /api/wardriving/import |
Import WiGLE CSV |
| GET | /api/wardriving/gps |
GPS status |
| GET | /api/wardriving/interfaces |
Available WiFi interfaces |
| GET | /api/wardriving/serial/detect |
Auto-detect ESP32 port |
| GET/POST | /api/wardriving/serial |
Serial status / start/stop listener |
| GET | /api/wardriving/track |
GPS track (lat/lon history) |
| POST | /api/wardriving/backfill_gps |
Fill missing GPS on observations from the track |
| GET/POST | /api/wardriving/huginn_config |
Read / push HuginnESP runtime knobs |
| POST | /api/wardriving/device_name |
Set device name |
| GET/POST | /api/wardriving/on_boot |
Auto-start on boot |
| GET/POST | /api/wardriving/upload-config |
Upload creds status (never reveals secrets), auto-upload settings (auto_upload, auto_upload_targets list) and recent upload outcomes / save them |
| POST | /api/wardriving/upload/<id> |
Upload a session — body {"target": "wigle"|"wdgwars"|"wardrift"|"both"|"all"|"a,b", "force": false} |
| POST | /api/wardriving/wardrift/signin |
Sign in to Wardrift (username, password); stores only the session token |
| POST | /api/wardriving/wardrift/signout |
Forget the Wardrift session token |
| GET | /api/wardriving/wardrift/dashboard |
Proxy of Wardrift GET /v1/dashboard (character level, EXP, currency, route stats) |
| GET/POST | /api/wardriving/wardrift/mesh |
Mesh-node reporter status (node, link, ports, last report) / settings: enabled, key, conn (usb|wifi), port, host, interval, report_now |
Mutually exclusive with On-Screen Network Diagnostic mode. Both take over the e-Paper panel and HAT keys, so starting wardriving turns Network Diagnostic mode off (this includes
on_boot— it is cleared at boot if it was left on), and enabling Network Diagnostic mode stops a live wardriving session.
GET /api/wardriving/status returns:
{
"running": true,
"starting": false,
"session_id": "20260520_122919",
"interfaces": ["wlan0"],
"scans_completed": 42,
"total_networks": 156,
"gps": {
"connected": true,
"source": "serial",
"port": "/dev/ttyACM1",
"has_fix": true,
"fix_quality": 1,
"satellites": 5,
"satellites_in_view": 9,
"snr_max": 41,
"hdop": 1.3,
"latitude": 59.3293,
"longitude": 18.0686,
"altitude": 28.0,
"speed_kmh": 47.2,
"course": 184.0,
"last_update": 1779272835.24,
"last_sentence": 1779272835.24,
"error": null
},
"companion_name": "Huginn",
"serial_connected": true,
"serial_port": "/dev/ttyACM0",
"serial_networks": 75,
"serial_unique": 62,
"serial_seen_unique": 70,
"esp_mode": "wardrive",
"esp_ble_count": 87,
"esp_alerts": [
{"time": 1683456789, "alert": "AirTag found!"}
],
"bluetooth_count": 87,
"cell_count": 3,
"stats": { ... }
}starting is true during a session's bring-up window — the few seconds start() spends unblocking radios, bringing interfaces up, and initialising GPS before the scan loop goes live and running flips to true. The hardware display treats starting the same as running and switches to the wardriving "Starting…" screen immediately, so a manually started session takes over the panel just as fast as wardriving_on_boot does — you no longer stare at the normal dashboard while the radios spin up. It auto-clears after 45 s if a start never completes, so a failed start can't strand the panel on "Starting…".
Each session creates a SQLite database at data/wardriving/session_<id>.db.
| Column | Type | Description |
|---|---|---|
| bssid | TEXT | MAC address (primary key) |
| ssid | TEXT | Network name |
| security | TEXT | WPA2, WPA3, Open, etc. |
| channel | INT | WiFi channel |
| frequency | INT | Frequency in MHz |
| band | TEXT | 2.4GHz, 5GHz, 6GHz |
| rssi | INT | Most recent signal strength (dBm) |
| best_rssi | INT | Strongest signal ever observed |
| latitude / longitude / altitude | REAL | Most recent observed position |
| best_lat / best_lon | REAL | Position at strongest-signal observation |
| speed_kmh / hdop | REAL | Velocity / DOP at last observation |
| first_seen / last_seen | TEXT | ISO timestamps |
| scan_count | INT | Number of times observed |
| interface | TEXT | wlan0, wlan1, esp32-serial, import, etc. |
| is_camera | INT | 1 if MAC OUI or SSID matches a known camera pattern |
| Column | Type | Description |
|---|---|---|
| mac | TEXT | BLE MAC address |
| name | TEXT | Device name |
| rssi | INT | Signal strength |
| device_type | TEXT | BLE, AirTag, Flipper, Skimmer |
| latitude / longitude / altitude | REAL | GPS position |
| first_seen / last_seen | TEXT | ISO timestamps |
GPS breadcrumb trail — one row every 5 s during a session, only while GPS has a fix. Used by backfill_gps_from_track to assign positions to observations made during GPS dropouts.
| Column | Type | Description |
|---|---|---|
| timestamp | REAL | Unix epoch when the point was logged |
| latitude / longitude / altitude | REAL | Position |
| speed_kmh | REAL | Velocity at that point (used for constant-accel interpolation) |
| satellites | INT | Sats used in fix |
| hdop | REAL | Horizontal dilution of precision |
Standard format for uploading to wigle.net. Contains MAC, SSID, AuthMode, channel, RSSI, GPS coordinates.
The export (and every upload, which uses the same file) contains only
GPS-pinned rows: sightings whose position lies within 50 m of the
session's GPS track. All row types are written as one time-ordered list, each
row's FirstSeen is the moment the drive was at that position, interpolated
between the track's fixes (logged every ~9 s), so rows heard between two fixes
don't share one timestamp while sitting 100 m apart (UTC,
YYYY-MM-DD HH:MM:SS), and fields are standard CSV-quoted (an SSID with a
comma is "name,with,comma"). Services that rebuild the drive route from the
file, such as Wardrift, otherwise reject it ("The GPS trail has a few jumps").
A session with no recorded track, such as an imported CSV, exports its
located rows as they are.
Google Earth format with network positions as markers.
Each saved session has ↑ WDGWars, ↑ WiGLE and ↑ Wardrift buttons, and the upload cards in the wardriving tab store credentials on the device only (they are excluded from fleet config export). Every upload reuses the exact WiGLE CSV the export produces, and a session with no GPS-located rows is refused unless you force it.
Auto-upload has its own card in the wardriving tab: switch it on and tick the services to upload to (WiGLE, WDGWars, Wardrift). Unconfigured services are marked (not set up).
-
Every stop queues the drive. The engine runs
SESSION_FINISHED_HOOKSfromstop(), so it doesn't matter whether the web UI, the CYD, the display keys or On-Screen Network Diagnostic mode stopped the drive, or whether the boot-time engine ran it. -
Power cuts. A drive cut off by a power loss has no end time. About two minutes after start-up, such drives that began after auto-upload was switched on (
wardriving_auto_upload_since) are queued, unless one was already uploaded, is already queued, or is still being written. -
Offline. The queue (
data/pending_uploads.json) is retried every 60 s until the box is online, and only for the services that failed. A drive with no GPS-pinned rows is given up after 30 tries. -
Wardrift verdicts. Wardrift answers a route upload with pending and decides later, so Ragnar follows each upload (
GET /v1/wardrive/uploads/{id}) to succeeded or failed for up to 45 minutes. -
Pushover summary. With Pushover set up (Settings → Notifications), each auto-uploaded drive sends one notification once every service has a final result, including Wardrift's verdict. It gives the drive (start time, duration, networks, GPS-pinned rows) and each service's response, e.g.:
Ragnar — Wardrive uploaded Drive 23 Sep 18:37 · 21 min · 3579 networks · 3244 GPS-pinned ✓ WDGWars: new 2911, updated 333 ✓ Wardrift: 3033 readings saved, +1794 XP, 102 skippedThe title says
upload: N failedwhen a service rejected the drive, andnot uploaded (no GPS)when it had nothing to send. A service that is offline is retried, and the summary waits until it gets through. A 4xx rejection (bad key, unconfigured service) is final and reported right away. The switch is Pushover summary after each upload in the Auto-upload card, or Wardrive Auto-upload in Settings → Notifications (pushover_notify_wardrive_upload, on by default). Manual ↑ uploads don't notify. -
Recent uploads. Every outcome, manual or automatic, is kept in
data/upload_history.jsonand shown under Recent uploads in the card: ✓ uploaded (e.g. "431 readings saved, +285 XP"), ✕ failed with the reason, … still processing, – skipped.
Wardrift (faction / territory wardriving game, API at
https://wardrift.net/v1, overridable via the wardrift_base_url config key)
has two auth paths and Ragnar supports both:
| Mode | How to set it up | What an upload does |
|---|---|---|
| Signed in (preferred) | Enter your Wardrift username + password and hit Sign in. Ragnar keeps only the session token; the password is never stored. | POST /v1/wardrive/logs with the WiGLE CSV. The session becomes an archived route (distance, AP count, streak), public if Public routes is ticked. A re-upload returns 409 duplicate_route. Wardrift also answers that while an identical earlier upload is still pending (processing a large drive can take ~10 minutes), and that one can still be rejected, so Ragnar shows it as already has this file — check wardrift.net rather than a success. The card shows your character level, EXP to next level, currency and lifetime routes/APs. |
| API key | Paste a character-bound Wardrift key (wdk_…). |
POST /v1/ingest/signals in batches of 250. Each row becomes a signal item (wifi / bluetooth / cellular / other) with SSID and BSSID sent only as SHA-256 hashes, timestamps converted to UTC. |
If both are set, the signed-in route upload is used, and it falls back to the API
key when the session has expired. For the API-key path, sequence_no and pending
idempotency_keys persist in data/wardriving/wardrift_state.json. The
idempotency key comes from the batch content, so a retry or a re-upload of the
same session returns duplicate: true and doesn't award twice. A retry resends
the same sequence_no. Sequences are seeded from wall-clock seconds, so losing
the state file can't replay a lower number, and a server "replay" rejection gets
one retry with a fresh, higher sequence. Envelopes are sent with an empty
signature, so a key with a registered Ed25519 device key server-side will be
rejected.
Coming soon. The section is greyed out and the reporter doesn't send yet (
POST /api/wardriving/wardrift/meshreturns409). SettingWARDRIFT_MESH_LIVE = Trueinwebapp_modern.pyswitches it on. Wardrift also rewards running Meshtastic nodes (Mesh XP, currency and hex influence), but stock Meshtastic firmware can't make HTTP calls, so something has to report on the node's behalf. The Mesh node section of the Wardrift card makes Ragnar that uplink:
-
Connect the node: USB cable into the Pi (auto-detected, or pick the port), or WiFi. For WiFi, turn on the node's WiFi and enter its IP, e.g.
192.168.1.50; the Meshtastic API is on TCP 4403. -
Paste the node's node-bound Wardrift key (a character key is rejected), pick an interval (2–30 min) and tick Report this node.
Your Wardrift dashboard's API keys panel lists each key with its device kind and can reveal or rotate it, but it can't create one. If you only have a character key, Wardrift answers
mesh endpoint requires a node device. The card then says so and waits for the next interval instead of retrying. Ask Wardrift to register the node and issue its key.Press Reveal before copying a key. A masked key (
●●●●) or pasted page text is refused on Save, because API keys must be plain ASCII to travel in an HTTP header.
Every interval Ragnar reads the node's own telemetry: uptime, battery/voltage,
channel utilisation, TX airtime, nodes heard, and LocalStats packet counters
(TX/RX/bad/relayed/relay-cancelled/dupes). It POSTs that to
/v1/ingest/mesh, with the node id, name, hardware model and firmware in
metadata. The counters are cumulative on the node, so Ragnar sends the
difference since the previous report. The first report after setup has no
baseline and omits them; a node reboot restarts the baseline. A failed report
is retried with the same idempotency_key + sequence_no. The card shows
the node, the connection, the last report and the XP/currency earned.
The connection is the same one the Mesh Nodes page uses (one node, one link). If you connected the node there first, the reporter shares it instead of opening a second one.
Serial port safety. Every component that opens a USB serial port
publishes what it holds in serial_claims.py, and every auto-detect skips the
others' ports:
| Component | Holds | Skips |
|---|---|---|
| Wardriving GPS (and the device gpsd is pinned to) | the GPS | CYD, Meshtastic |
| Wardriving companion monitor (HuginnESP/Piglet) | companion ports | CYD, Meshtastic |
| CYD serial bridge | its CYD port | GPS, Meshtastic (wardriving yields to it) |
| Meshtastic link | the node's port | GPS, CYD, companions |
A Heltec V4 is an ESP32-S3 like a HuginnESP. Auto-detect therefore only picks a Meshtastic port when exactly one free candidate exists, and asks you to pick one otherwise. Picking a port a wardriving companion listener holds makes the wardriving monitor release it; the GPS and CYD ports are never taken.
A self-contained, printable Wi-Fi survey report — the "Report" link on each
saved session (or ?format=report on the export endpoint). It opens in the
browser and prints/saves to PDF as a shareable deliverable, generated
automatically from the session instead of assembled by hand. It includes:
- Security posture grade (A–F) based on the share of open + WEP networks, with a per-scheme breakdown (Open / OWE / WEP / WPA / WPA2 / WPA3-SAE).
- Executive summary stat cards (networks, exposure %, WPA3 count, cameras).
- Band distribution (2.4 / 5 / 6 GHz) and a channel-usage histogram.
- Networks of concern — every open and WEP network called out in a table.
- Cameras & surveillance devices (when the heuristic flagged any).
- Strongest networks, per-adapter antenna coverage, and a GPS coverage summary (track points, distance, bounding box).
- Other radios counts (Bluetooth/BLE, cell towers, Zigbee/802.15.4).
All CSS is inlined and a print stylesheet is included, so it renders identically offline and prints cleanly to PDF. The report is informal — not a certified assessment.
You only need a companion for modes 2–5. Standalone mode (1) works without one.
| Mode | Companion | Flash with |
|---|---|---|
| 2 | HuginnESP | cd HuginnESP && pio run --target upload (COM8 on Windows, /dev/ttyACM* on Linux) |
| 3 | Piglet (plain) | Piglet's own flasher / Arduino IDE — see the Piglet repo |
| 4 | Piglet Coordinator (dedicated) | Browser-flash from pierregode.github.io/Ragnar/ |
| 5 | Piglet Core | Flash Piglet as in mode 3, then set meshModeOnBoot=core in wardriver.cfg |
Auto-detect (Linux):
Click 🔍 Search in the web UI — finds the ESP32 automatically via udevadm,
regardless of which companion firmware is on it.
Manual:
Enter the port (/dev/ttyACM0 or COM8) in the serial field and click Connect.
The serial card's companion label updates from Companion → Huginn /
Piglet / Piglet Coordinator once the boot banner is parsed.
Connect a USB GPS receiver. Ragnar auto-detects NMEA devices.
In modes 3 and 5 the Piglet board itself has a GPS module — those positions ride along inside the WigleWifi CSV rows, so Ragnar's own GPS is optional but recommended (it backfills network observations made during Piglet dropouts). In modes 2 and 4 the companion has no GPS, so Ragnar's GPS is the only source.
Click Start Wardriving in the web UI. Ragnar begins scanning with all active wlan interfaces and ingesting whatever is on the serial port.
Radios are enumerated from both nmcli and /sys/class/net, and the two
lists are unioned. This matters because NetworkManager omits a radio
entirely when it is unmanaged (Ragnar marks its own scan adapters unmanaged so
NM doesn't add competing routes), left in monitor mode, or its driver loaded
after NM started — so nmcli alone is never authoritative. Monitor child vifs
(wlan1mon, mon0) are filtered out so they don't double up with their parent.
On start, the log lists exactly what was found:
Wardriving detected 3 WiFi interface(s): wlan0, wlan1, wlan2
If an adapter is still missing, check in this order:
ls /sys/class/net # is the radio enumerating at all?
rfkill list # soft/hard blocked? -> sudo rfkill unblock all
dmesg | tail -30 # driver/power errors on plug-inIf the interface is absent from /sys/class/net it is not a Ragnar problem —
the adapter isn't enumerating (driver, power, or a hard rfkill block). A
freshly-plugged USB dongle commonly comes up soft-blocked: it appears in
/sys/class/net and in the adapter list but scans zero networks. Ragnar logs a
warning naming any blocked radio; clear it with sudo rfkill unblock all.
Hot-plug is live — no restart needed. The scan set is reconciled with the radios actually present every ~12 s, so an adapter plugged in after wardriving started (e.g. booting without the Alfa, then connecting it) joins the sweep on its own, and a yanked one drops out. The scan-set change is logged:
Wardriving: scan set changed (added=['wlan1'], removed=—); now scanning ['wlan0', 'wlan1']
The rescan deliberately leaves alone the radio currently hosting the phone-access
AP and any radio in AP/monitor mode (e.g. WiFi Defense), and re-checks a
soft-blocked adapter on the next pass once you rfkill unblock it.
Two distinct causes, and they look identical from the outside.
1. Power (most common). A USB Wi-Fi adapter is the heaviest load you can add — an Alfa draws roughly 0.5–1 A. On a Pi whose 5 V rail is already marginal, the second dongle browns out the board and it resets. Nothing appears in the logs, because the OS never got the chance to write any. Check the SoC's throttle register:
vcgencmd get_throttled # 0x0 is healthy
dmesg | grep -i voltage # "Undervoltage detected!"Any non-zero value means the supply has no headroom. Ragnar now reads this at
wardriving start and logs a Wardriving: POWER — … warning naming the
condition. The fix is hardware: a stronger PSU, or run the dongles from a
powered USB hub so they don't draw off the Pi.
2. Losing the uplink (looks like a crash, box is actually still running).
Wardriving claims each scan adapter from NetworkManager (managed no) so NM's
autoscan doesn't race the scan trigger. That operation is unrecoverable — NM
will not reconnect a device it has been told not to manage. If it were applied
to the radio carrying Ragnar's own connectivity, the box would drop off the
network permanently while still running happily headless.
The uplink is therefore protected by stable identity — the interface holding the default route, plus the configured/auto-detected management interface — and never by association state alone, which is a point-in-time check that loses the race while roaming, during the boot race, or when a scan knocks the link off. The protected radio is still scanned; only the NM claim and the mode reset are skipped. The log names it on start:
Wardriving: wlan0 is the management/uplink radio — scanning it but leaving
NetworkManager and its mode alone
If a box ever does end up unmanaged, sudo nmcli dev set wlan0 managed yes
restores it.
Ragnar identifies surveillance cameras based on MAC OUI prefixes (manufacturers): Axis, Hikvision, Dahua, Vivotek, Bosch, Samsung, Reolink, Amcrest, Foscam, and more.
Cameras are marked in the network list with type and manufacturer.
Piglet is an open-source ESP32-based wardriving platform by Hamspiced. It scans WiFi networks with GPS positioning and logs WiGLE-compatible CSV files to its SD card.
| Board | Notes |
|---|---|
| Seeed XIAO ESP32-S3 | 2.4 GHz only |
| Seeed XIAO ESP32-C5 | 2.4 + 5 GHz |
| Seeed XIAO ESP32-C6 | 2.4 GHz only |
| LilyGo T-Dongle C5 | Standalone variant with built-in TFT |
Piglet peripherals: I2C GPS (ATGM336H), SSD1306 OLED, SPI SD card module.
Piglet can talk to Ragnar three ways — all three coexist with each other and with HuginnESP:
| Path | When to use | Live? |
|---|---|---|
| CSV import (file upload) | After a standalone field trip where Piglet logged to its SD card | ❌ Offline |
| Live USB serial (mode 3) | Piglet plugged into Ragnar — streams WigleWifi-1.4 CSV rows as it scans | ✅ Yes |
| Mesh Core via USB (mode 5) | Piglet running in mesh core mode, plugged into Ragnar — relays its own scans plus every record received from mesh nodes |
✅ Yes |
A regular Piglet that's tethered to Ragnar via USB just emits its normal WiGLE
CSV output over the serial port — first a WigleWifi-1.4,… banner, then the
column header row, then one CSV row per AP. Ragnar reads the header to build a
column-name → index map (so format bumps like 1.4 → 1.6 don't break anything)
and inserts each row live into the session DB with interface='esp32-serial'.
Detection signal: the boot banner contains Piglet, [CORE], or WigleWifi-.
Once identified, no commands are sent — the parser just listens. The status bar
chip reads Piglet · /dev/ttyACM0.
Same end result, file-based:
- Take Piglet out wardriving — it logs WiFi networks + GPS to SD card
- When home, download the CSV files via Piglet's web UI (connects to your WiFi) or remove the SD card
- Upload the CSV file(s) to Ragnar via Import CSV in the wardriving section (
POST /api/wardriving/import) - Ragnar imports all networks with GPS coordinates into the active session
- View the imported data on the map and in the network table
| Piglet CSV Column | Ragnar Mapping | Status |
|---|---|---|
| MAC | bssid |
✅ |
| SSID | ssid |
✅ |
| AuthMode | security |
✅ |
| Channel | channel + frequency |
✅ |
| RSSI | rssi |
✅ |
| CurrentLatitude | lat |
✅ |
| CurrentLongitude | lon |
✅ |
| AltitudeMeters | alt |
✅ |
| Type | WiFi / BT routing | ✅ |
The importer handles Piglet's WigleWifi-1.4 metadata header line automatically.
| Feature | HuginnESP (mode 2) | Piglet — live USB (mode 3) | Piglet Coordinator (mode 4) | Piglet Core via USB (mode 5) |
|---|---|---|---|---|
| Hardware | ESP32-S3-Touch-LCD-4B | Any Piglet board | Waveshare C5 or S3-LCD | Any Piglet board |
| Firmware | HuginnESP | Piglet (stock) | espnow_bridge_* (this repo) |
Piglet, meshModeOnBoot=core |
| Connection | USB serial (live) | USB serial (live) | USB serial (live) | USB serial (live) |
| Companion name in UI | Huginn |
Piglet |
Piglet Coordinator |
Piglet |
| Local WiFi scan | ✅ Active per-channel | ✅ | ❌ (no radio scan) | ✅ |
| BLE / threats | ✅ Full suite | ❌ | ❌ | ❌ |
| ESP-Now mesh aggregation | ❌ | ❌ | ✅ Receives from N nodes | ✅ Receives from N nodes |
| Built-in GPS | ❌ (uses Ragnar's) | ✅ Own GPS | ❌ (uses Ragnar's) | ✅ Own GPS |
| SD-card logging | ❌ | ✅ | ❌ | ✅ |
| Wire protocol | JSON + multi-line | WigleWifi-1.4 CSV stream | One-line-per-record JSON | WigleWifi-1.4 CSV stream |
| Display | 480×480 RGB touch | 128×64 OLED | none / 480×480 (S3-LCD) | 128×64 OLED |
All four companions can be used together with the standalone wardriver mode (mode 1, Ragnar's own Wi-Fi adapters) — they're additive, not exclusive. The only constraint is that there's just one serial port at a time, so only one companion can be wired up per Ragnar.
Piglet supports ESP-Now mesh networking for multi-node wardriving. One device acts as the coordinator while one or more Piglets act as Nodes, forwarding their WiFi scan results over ESP-Now on channel 6.
You can run the coordinator role two ways, and Ragnar treats them as separate operating modes:
- Piglet Core (mode 5) — a regular Piglet board flipped into mesh
coremode viameshModeOnBoot=core. Same hardware as a node, just promoted. It scans WiFi and aggregates the mesh, and can either log everything to its own SD card or stream live to Ragnar over USB. - Piglet Coordinator (mode 4) — the dedicated
espnow_bridge_*firmware in this repo, flashed onto a Waveshare ESP32-C5 or ESP32-S3-Touch-LCD-4B. Purpose-built for the coordinator role — it doesn't scan WiFi itself, just receives mesh records and forwards them to Ragnar live as JSON.
Both expose the same end result (mesh-wide records hitting Ragnar's session DB) with different ergonomics — pick by hardware availability.
ESP-Now (ch 6)
┌────────────┐ ─────────────► ┌────────────────┐
│ Piglet │ │ Piglet Core │
│ Node #1 │ │ (coordinator) │
│ ESP32-C5 │ │ ESP32 + GPS │ USB
│ No GPS/SD │ │ + SD card │ ────────────────► Ragnar
└────────────┘ │ │ CSV import
ESP-Now (ch 6) │ Logs ALL nodes │
┌────────────┐ ─────────────► │ to WiGLE CSV │
│ Piglet │ └────────────────┘
│ Node #2 │
│ ESP32-S3 │
│ No GPS/SD │
└────────────┘
The Core needs GPS + SD card. Set meshModeOnBoot in /wardriver.cfg:
meshModeOnBoot=core
Or navigate to the Mesh Node page on the Core and it enters Core mode automatically.
When set to core, the SoftAP window is skipped on boot — ESP-Now owns the WiFi stack. The Core receives scan results from all connected Nodes and logs them to SD card with GPS coordinates.
Nodes are lightweight — no SD card or GPS required. Set:
meshModeOnBoot=node
Or press the button to cycle to the Mesh Node page (page 5, after the pig animation).
Each Node:
- Automatically searches for a Core on ESP-Now channel 6
- Receives a channel range assignment from the Core
- Begins scanning WiFi and forwarding results to the Core
- OLED shows link status, coordinator MAC, assigned channels, and records forwarded
After the wardriving session:
- Power down the Nodes (or exit Mesh mode with a button press)
- On the Core Piglet, connect to its WiFi AP or your home network
- Download the CSV files from the Core's web UI — they contain data from all nodes, GPS-stamped by the Core
- Upload to Ragnar via Import CSV (
POST /api/wardriving/import) - All networks appear on the map with GPS coordinates
While in Mesh Node mode the OLED shows:
| Field | Description |
|---|---|
| Link status | Searching or Core linked |
| Coordinator MAC | MAC address of the Core |
| Channel range | Assigned WiFi channels to scan |
| Networks found | Total unique networks discovered |
| Records forwarded | Records sent to the Core |
| Role | Recommended Board | Notes |
|---|---|---|
| Core (mode 5) | XIAO ESP32-C5 | 2.4 + 5 GHz, needs GPS + SD |
| Core (mode 5) | XIAO ESP32-S3 | 2.4 GHz only, needs GPS + SD |
| Coordinator (mode 4) | Waveshare ESP32-C5-WIFI6-KIT | Headless, uses Ragnar's GPS — no SD card needed |
| Coordinator (mode 4) | Waveshare ESP32-S3-Touch-LCD-4B | 480×480 display showing live mesh stats |
| Node | Any supported XIAO | No GPS or SD required |
| Node | LilyGo T-Dongle C5 | Compact node with built-in TFT |
The espnow_bridge_firmware/ directory ships two builds of a purpose-built
coordinator firmware (one for ESP32-C5, one for ESP32-S3-Touch-LCD-4B). It
replaces Piglet's Core role with a thinner, USB-tethered bridge:
- No local WiFi scan, no SD card, no GPS dependency on the ESP — Ragnar handles all of that.
- Receives
MSG_NODE_REPORTframes from every paired Piglet on ESP-Now channel 6, distributes the 40-entry scan-channel table evenly across the nodes, and forwards each record to Ragnar over USB CDC at 460800 baud. - Announces itself on boot with
{"device":"RagnarCoord","fw":"<build>","board":"<board>","caps":["espnow","piglet-core"]}so Ragnar can flip the companion identity toPiglet Coordinatorand adjust the UI accordingly. - Emits one
{"type":"WIFI",...}JSON line per record and one{"type":"NODE",...}row per active mesh node every ~10 s (used by Ragnar to render the per-node breakdown bar with each node's MAC, records-rx, and age-since-last-update). - The S3-LCD build also draws live mesh stats on its 480×480 panel.
Browser-flash either board at
pierregode.github.io/Ragnar/ — pick the
matching board, plug it into Ragnar over USB-C, click Forge. The GitHub Actions
workflow rebuilds both binaries on every main push and redeploys the pages
site.
When a Piglet Coordinator is connected, the /api/wardriving/status payload
includes:
"companion_name": "Piglet Coordinator",
"coordinator_board": "ESP32-C5",
"coordinator_fw": "c5-1",
"mesh_node_count": 1,
"coordinator_nodes": [
{"mac": "AA:BB:CC:DD:EE:FF", "idx": 0, "records_rx": 4637, "age_s": 3}
]The wardriving card shows:
Piglet Coordinator · /dev/ttyACM0 · Records: 4637 | WiFi: 70 · Unique: 8 | Mesh: 1 nodes
where Records is total mesh records relayed (sum of every node's
records_rx), WiFi is the count of distinct BSSIDs the mesh side has
ever observed (serial_seen_unique), Unique is BSSIDs only the mesh
saw — never picked up by Ragnar's local wlan adapters (serial_unique), and
Mesh is the live node count.
- Channel coverage: The Core assigns different channel ranges to each Node, so more Nodes = better frequency coverage
- Range: ESP-Now has ~200m line-of-sight range; nodes can be spread across a building or vehicle convoy
- Battery: Nodes without GPS/SD draw less power — ideal for small LiPo-powered Piglet builds (~$14 BOM)
- 5 GHz: Use ESP32-C5 boards for 5 GHz scanning capability
- Exit mesh: Press the button on a Node to leave mesh mode and return to normal standalone wardriving