Skip to content

Latest commit

 

History

History
1309 lines (1050 loc) · 70.6 KB

File metadata and controls

1309 lines (1050 loc) · 70.6 KB

Wardriving — Ragnar

Overview

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.

GPS recovery during dropouts

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.


Hardware

HuginnESP (ESP32-S3)

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

Piglet Coordinator — dedicated firmware (ESP32-C5 / ESP32-S3-LCD)

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

GPS

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 ragnar

scripts/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.


Architecture

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.


HuginnESP Serial Protocol

Commands (Ragnar → ESP)

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)

Wardrive Mode (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.

Rotating Cycle (legacy)

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.

Serial Output (ESP → Ragnar)

WiFi Networks (JSON, one line per AP)

{"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.

BLE Devices (JSON, ALL mode, one line per device)

{"type":"BLE","mac":"AA:BB:CC:DD:EE:FF","name":"DeviceName","rssi":-60}

Thread / Zigbee (802.15.4) Devices (JSON, one line per sighting)

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 zigbee command (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 at GET/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 a ZIGBEE type 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 (multi-line, FILTERED + ALL mode)

AirTag found!
Tag: 1
MAC Address: D3:59:9B:E4:2C:3A
RSSI: -89

Flipper Zero (multi-line, FILTERED + ALL mode)

Found White Flipper Device:
MAC: AA:BB:CC:DD:EE:FF,
Name: Flipper-X,
RSSI: -70

Skimmer (multi-line, SKIMMER + ALL mode)

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/Evil Twin (multi-line)

Pineapple detected: NetworkName
BSSID: AA:BB:CC:DD:EE:FF
Channel: 6

BLE Spam (single line)

BLE Spam detected from AA:BB:CC:DD:EE:FF

Triggered at 20+ advertisements from the same MAC within 5 seconds.

Status (JSON, response to status command)

{"mode":"auto","wifi_count":5,"ble_count":12}

Boot Messages

[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

Ragnar Parser

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, Unsupported
  • WiFi scan, Started, Stopped, Usage:, BLE initialized, etc.
  • [BOOT] prefix (not explicitly filtered but matches no parser)

GPS

Sources

Auto-detected at startup, in priority order:

  1. gpsd on localhost:2947 — if a gpsd instance is running it owns the serial device; Ragnar reads its JSON stream (TPV / SKY).
  2. Direct NMEA serial — /dev/serial/by-id/* symlinks containing GPS keywords (gps, u-blox, ublox, nmea, gnss, bn-, vk-).
  3. NMEA probe — other by-id entries that aren't already claimed by an ESP companion are probed at 9600/4800/38400/115200 baud for $GP/$GN/$GL sentences.
  4. Raw device nodes — /dev/ttyACM*, /dev/ttyUSB*, /dev/ttyS*, /dev/ttyAMA*, /dev/serial0, /dev/serial1 — probed the same way.

gpsd setup

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 DEVICES to 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, so satellites_in_view / snr_max update while still searching for a fix.
  • Runtime ensure. On start(), wardriving best-effort re-runs setup_gpsd.sh if gpsd isn't active or a different GPS has been swapped in, then gps_manager consumes the gpsd socket (source: gpsd). If gpsd isn't installed it silently falls back to direct serial.
  • Verify live. cgps or gpsmon show 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.

NMEA Parser

  • 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_update and satellite counters move as soon as any NMEA is received — not only after first fix.
  • GSV parsing. Per-constellation $xxGSV sentences are aggregated; the API exposes satellites_in_view (sum across all reporting constellations) and snr_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 as gps.sky for 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_sentence updates on any recognized NMEA line (including GSV / GSA / VTG / GLL / TXT and pre-fix GGA/RMC). last_update continues to mean "last positional/fix update". Together they distinguish "GPS is alive but has no fix yet" from "GPS isn't transmitting at all".

Active scans pause during a drive

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.

Assisted start (position / time / orbit pre-load)

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 to data/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 status aid_saved counts 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.

Clock from GPS (offline boots)

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, GPSManager sets 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_seen and last_seen in networks, observations, Bluetooth, cells and Zigbee, the GPS track, and the session start. session_info.clock_steps records 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.

Status Fields (/api/wardriving/gps)

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

Wardriving GPS Card (UI)

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, lon once 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, kmh or mph) 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.

Diagnostics Panel (UI)

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_enable is 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 dmesg for 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 with 0 used and 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 whether under-voltage appears 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.

Network Position Preservation

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.

GPS Backfill

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 returns 403 while the flag is off. Any row backfilled this way is flagged gps_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:

  1. bisect the track to find the two trackpoints bracketing the row's timestamp.
  2. Both within 5 minutes: interpolate position between them.
  3. One side within 5 minutes: use the nearest single trackpoint.
  4. 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.


API Endpoints

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.


Status Object

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…".


Data Storage

Each session creates a SQLite database at data/wardriving/session_<id>.db.

Table: networks

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

Table: bluetooth_devices

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

Table: gps_track

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

Export

WiGLE CSV

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.

KML

Google Earth format with network positions as markers.

Uploading sessions — WiGLE, WDGWars, Wardrift

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_HOOKS from stop(), 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 skipped
    

    The title says upload: N failed when a service rejected the drive, and not 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.json and 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.

Wardrift mesh node (Meshtastic)

Coming soon. The section is greyed out and the reporter doesn't send yet (POST /api/wardriving/wardrift/mesh returns 409). Setting WARDRIFT_MESH_LIVE = True in webapp_modern.py switches 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:

  1. 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.

  2. 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.

Survey report (HTML → PDF)

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.


Setup

1. Pick (and flash) a companion — optional

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

2. Connect to Ragnar

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.

3. GPS (optional)

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.

4. Start Wardriving

Click Start Wardriving in the web UI. Ragnar begins scanning with all active wlan interfaces and ingesting whatever is on the serial port.

Adapter detection — "I plugged in a third adapter but only see wlan0/wlan1"

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-in

If 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.

"Adding a second dongle crashes the whole box"

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.


Camera Recognition

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 Integration

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.

Supported Piglet Hardware

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.

How It Connects to Ragnar

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

Live USB serial (mode 3)

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.

CSV import (offline)

Same end result, file-based:

  1. Take Piglet out wardriving — it logs WiFi networks + GPS to SD card
  2. When home, download the CSV files via Piglet's web UI (connects to your WiFi) or remove the SD card
  3. Upload the CSV file(s) to Ragnar via Import CSV in the wardriving section (POST /api/wardriving/import)
  4. Ragnar imports all networks with GPS coordinates into the active session
  5. View the imported data on the map and in the network table

What Gets Imported

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.

Companion comparison

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 ESP-Now Mesh Network

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 core mode via meshModeOnBoot=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.

Architecture

                  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  │
└────────────┘

Setup

1. Core (Coordinator) Piglet

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.

2. Node Piglets

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

3. Import to Ragnar

After the wardriving session:

  1. Power down the Nodes (or exit Mesh mode with a button press)
  2. On the Core Piglet, connect to its WiFi AP or your home network
  3. Download the CSV files from the Core's web UI — they contain data from all nodes, GPS-stamped by the Core
  4. Upload to Ragnar via Import CSV (POST /api/wardriving/import)
  5. All networks appear on the map with GPS coordinates

Node Display

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

Compatible Hardware

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

Piglet Coordinator firmware (mode 4) — dedicated coordinator

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_REPORT frames 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 to Piglet Coordinator and 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.

Flashing

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.

Status integration

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.

Tips

  • 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