WiFi & BLE wardriving firmware for ESP32. The device performs the radio scanning and pushes a live stream of detections out over USB serial — any host can consume the stream. The reference consumer is Ragnar's wardriving engine, but the protocol is plain newline-delimited JSON so anything that can read a serial port will work.
| Board | MCU | Radio | Display |
|---|---|---|---|
| Waveshare ESP32-S3-Touch-LCD-4B | ESP32-S3-WROOM-1-N16R8 (16 MB flash, 8 MB PSRAM) | WiFi 2.4 GHz + BLE 5 | 4" 480×480 RGB touch (GT911) |
| Waveshare ESP32-C5-WIFI6-KIT | ESP32-C5-WROOM-1 N16R4 (16 MB flash, 4 MB PSRAM, RISC-V) | Dual-band WiFi 6 (2.4 / 5 GHz) + BLE 5 + 802.15.4 (Zigbee) | none (headless) |
| Seeed XIAO ESP32-C5 | ESP32-C5 (8 MB flash, 8 MB PSRAM, RISC-V) | Dual-band WiFi 6 (2.4 / 5 GHz) + BLE 5 + 802.15.4 (Zigbee) | none (headless) |
| Generic ESP32-C5-WROOM-1 / WROOM-1U | Any WROOM-1/1U module with ≥ 8 MB flash (N8R4 / N8R8 / N16R4 / N16R8, 32 MB third-party boards), chip rev v1.0 or v1.2 | Dual-band WiFi 6 (2.4 / 5 GHz) + BLE 5 + 802.15.4 (Zigbee) | none (headless) |
All boards run the same firmware behavior; the C5 builds skip display code (HUGINN_HAS_DISPLAY=0). The C5 boards share the same ESP32-C5 chip but differ in flash size and toolchain — see Flashing the firmware.
| Feature | Description |
|---|---|
| WiFi Scan | Scan WiFi networks (SSID, BSSID, RSSI, channel, security) — 2.4 GHz on S3, dual-band on C5 |
| BLE Scan | Scan BLE devices (MAC, name, RSSI) — Flipper / AirTag / skimmer classification is emitted as separate alert lines |
| 802.15.4 Scan (Zigbee / Thread) | ESP32-C5 only. Promiscuous IEEE 802.15.4 sniff across channels 11–26; every frame with a source address is emitted (PAN ID, EUI-64 / short address, channel, RSSI, LQI) with a proto tag classifying it as zigbee / thread / 802.15.4. Matter-over-Thread reports as thread. Runs on demand via the zigbee command (parks WiFi/BLE so 802.15.4 gets the radio exclusively). It is not part of the wardrive cycle: WiFi, BLE and 802.15.4 share one 2.4 GHz radio on the C5, and once 802.15.4 receives, the WiFi scan can't reclaim the radio without a WiFi re-init that exhausts the ~116 KB heap — so wardrive stays WiFi + BLE. Not available on the S3 / classic ESP32 (no 802.15.4 radio) |
| Flipper Zero Detection | Identify Flipper Zero devices via BLE advertisement data |
| AirTag / Find My Detection | Flag Apple Find My trackers via BLE manufacturer data. Matches only the full offline-finding "separated" beacon (Apple company 0x004C, type 0x12, length 0x19) — the high-confidence standalone-tracker signal — so it does not fire on the ambient Find My chatter every nearby iPhone/Mac/AirPods relays. Each unique tracker MAC alerts once (AirTag MACs rotate ~15 min, so a tag reappears under a fresh anonymous identity) |
| BLE Spam Detection | Detect BLE advertising spam attacks |
| Skimmer Detection | Identify potential skimmer devices (HC-05/HC-06 BLE modules) |
| Proximity Alert LED | Optional — onboard RGB LED blinks faster the closer a flagged device gets (RSSI-driven). Colors identify the alert: skimmer = red⇄white, Flipper Zero = blue⇄white. Enabled on C5 builds |
| Mode Button | Optional — long-press the onboard BOOT button to toggle wardrive ⇄ skimmer-only scanning. LED confirms: 3 purple blinks = skimmer, 3 green = wardrive. C5 builds boot into wardrive |
| Touch Display | Live status, touch buttons, alert panel with color coding (S3 only) |
| Session Tally | Display-side running totals (unique WiFi BSSIDs, BLE / Flipper / AirTag / skimmer MACs) since power-on; resets on reboot, S3 only |
| Auto Scan Cycle | Automatic rotation through all scan modes |
| GPS tagging | Optional — when a NMEA GPS module is wired and has a fix, lat/lon are appended to every WIFI JSON line |
Any NMEA module that outputs RMC sentences ($GPRMC, $GNRMC, $BDRMC, …) at 9600 baud works (GT-U7, NEO-6M, L76K, ATGM336H, etc.).
| GPS pin | ESP32 pin | Notes |
|---|---|---|
| VCC | 3.3 V | Most breakouts are 3.3 V — check your module |
| GND | GND | |
| TX (GPS out) | GPIO 17 by default (Waveshare C5/S3/generic) | This is the data line into the ESP32 |
| RX (GPS in) | GPIO 18 by default (Waveshare C5/S3/generic) | Leave unconnected if module is receive-only |
For Seeed XIAO ESP32-C5 builds produced by scripts/build-xiao.sh, Soldred GPS defaults are:
GPS_RX_PIN=12(D7) ← GPS TXGPS_TX_PIN=11(D6) → GPS RXGPS_UART_NUM=1
To use different pins, override in platformio.ini:
build_flags =
...
-DHUGINN_HAS_GPS=1
-DGPS_RX_PIN=16
-DGPS_TX_PIN=15
-DGPS_UART_NUM=1Build with one of the GPS-enabled environments:
pio run -e esp32s3box-gps
pio run -e esp32c5-gps
pio run -e esp32c5-wroom-gps
pio run -e esp32-gps
Headless C5 builds (esp32c5, esp32c5-gps) drive the board's onboard
addressable RGB LED (the WS2812B on RGB_BUILTIN) as a "hotter/colder" locator.
Whenever a flagged device is seen during a BLE scan the LED blinks, and the
blink rate tracks signal strength — the closer you get (stronger RSSI), the
faster it blinks. The blink colors tell you what it found:
| Alert | Blink colors |
|---|---|
| Potential skimmer (suspicious BLE module — see Skimmer Detection) | red ⇄ white |
| Flipper Zero | blue ⇄ white |
It's a single LED, so if both are nearby at once the most recently seen device wins. The LED turns itself off a few seconds after the device drops out of range.
This is enabled by default on the C5 environments via -DHUGINN_HAS_SKIMMER_LED=1.
The pin defaults to the board's RGB_BUILTIN. Tunables (override in
platformio.ini build_flags):
| Flag | Default | Meaning |
|---|---|---|
SKIMMER_LED_PIN |
RGB_BUILTIN |
GPIO driving the addressable LED |
SKIMMER_LED_BRIGHTNESS |
40 |
Per-channel brightness (0–255) of the blink colors |
SKIMMER_LED_RSSI_NEAR |
-45 |
RSSI at/above which it blinks fastest |
SKIMMER_LED_RSSI_FAR |
-95 |
RSSI at/below which it blinks slowest |
SKIMMER_LED_FAST_MS / SKIMMER_LED_SLOW_MS |
70 / 1000 |
Blink half-period at closest / farthest range |
SKIMMER_LED_HOLD_MS |
10000 |
How long to keep blinking after the last sighting (bridges the WiFi-only gaps between BLE scans, e.g. in wardrive) |
RSSI is a coarse proximity proxy — readings jump around with orientation and obstacles, so treat the blink rate as "warmer/colder," not a distance meter.
To enable it on another board with an addressable LED, add
-DHUGINN_HAS_SKIMMER_LED=1 (and -DSKIMMER_LED_PIN=<gpio> if needed) to that
environment's build_flags.
On C5 builds the onboard BOOT button toggles the scan mode with a long-press (~1 s) — no host or serial command needed:
- The device boots into wardrive mode.
- Long-press → switches to skimmer-only scanning; the LED confirms with 3 purple blinks.
- Long-press again → switches back to wardrive; the LED confirms with 3 green blinks.
Skimmer-only mode runs the BLE skimmer scan continuously (WiFi off) so the
proximity LED stays responsive; wardrive is the normal WiFi + BLE wardriving
cycle. The serial commands (wardrive, capture -skimmer, stop, …) still
work and stay in sync with the button.
Enabled by default on the C5 environments via -DHUGINN_HAS_MODE_BUTTON=1.
Tunables (override in platformio.ini build_flags):
| Flag | Default | Meaning |
|---|---|---|
MODE_BTN_PIN |
BOOT_PIN |
GPIO of the toggle button (active-low, internal pull-up) |
MODE_BTN_LONGPRESS_MS |
1000 |
How long to hold before the mode toggles |
The BOOT button is also a boot strapping pin — holding it while resetting puts the chip into download mode. That only matters at reset; pressing it during normal operation just toggles the scan mode.
The ESP32-C5 is dual-band (2.4 + 5 GHz), and the firmware is set up so a single build captures 5 GHz in any region — no per-country flashing. Two things make that work:
Per-channel scan type. 5 GHz regulation splits into DFS and non-DFS bands, and they must be scanned differently. The firmware picks the scan type from the channel number:
| Channels | Band | Scan type | Why |
|---|---|---|---|
| 1–14 | 2.4 GHz | Active (probe) | Universal; fast. |
| 36–48 | 5 GHz UNII-1 (non-DFS) | Active (probe) | Common everywhere. |
| 52–64, 100–144 | 5 GHz UNII-2A/2C (DFS) | Passive (listen) | DFS is passive-only by law — the radio must hear a beacon, not probe. Heavily used in the EU. |
| 149–165 | 5 GHz UNII-3 (non-DFS) | Active (probe) | The common US home band; probing catches it regardless of regulatory domain. |
Active probes finish fast (~30–120 ms); passive DFS channels dwell ~120 ms (longer than one ~100 ms beacon interval) so at least one beacon is caught, and the per-channel cap is widened to ~300 ms for 5 GHz so that dwell completes.
Regulatory domain. At init the C5 pins the FCC/US domain with a manual
policy (esp_wifi_set_country, 802.11d off). The reason: the ITU world code
01 does not include the 5 GHz UNII-3 band (channels 149–165), and 802.11d
only adapts after a station associates — which a scanner never does — so under
01 the US networks on 149–165 were never scanned. The US domain exposes the
widest common 5 GHz allocation (UNII-1 36–48, UNII-2A/2C 52–144, UNII-3
149–165), a superset of the EU set (36–140), so one build reaches every
5 GHz channel a nearby AP might use in either region. schan/nchan still cover
2.4 GHz 1–13, keeping the EU-only channels 12/13. A manual policy stops a beacon
advertising a narrower domain from shrinking the channel set mid-scan.
Why this matters: earlier the C5 active-scanned every channel, which returns nothing on DFS (passive-only) and left 5 GHz effectively empty in the EU. The classic symptom was works in the US, not the EU — US home WiFi favours non-DFS UNII-1/UNII-3, while the EU leans on DFS channels that an active-only scan can't see.
Regional note: active-probing 149–165 where those channels are prohibited (e.g. the EU) is harmless — there are no APs there — but the driver may log a
[WIFI] scan_start FAILEDfor them. That line is expected and does not affect the rest of the sweep.
The S3 is 2.4 GHz-only, so none of this applies to it — it just sweeps channels 1–14 actively.
The fastest way to flash a stock build is the browser-based installer at https://pierregode.github.io/HuginnESP/. It drives esptool-js v0.7.0 directly (not esp-web-tools) and serves prebuilt merged images for every supported board (Waveshare S3, Waveshare C5, Seeed XIAO C5, and generic ESP32-C5-WROOM-1 / WROOM-1U).
Why not esp-web-tools? esp-web-tools is pinned to esptool-js v0.5.x, which lacks the ESP32-C5 native-USB (USB-Serial-JTAG) fixes added in esptool-js v0.6.0. The Seeed XIAO ESP32-C5 has no external UART bridge, so it can only be web-flashed over that native USB interface. HuginnESP therefore ships a lightweight flasher built on esptool-js (see docs/js/flasher.js). The page also includes a built-in Serial Monitor for watching the scan stream.
ESP32-C5 revision v1.2: esptool-js v0.7.0 or newer is required. Newer C5 silicon (rev v1.2, e.g. recent ESP32-C5-WROOM-1U modules) reports a chip-detect magic value that v0.6.x doesn't recognize, which surfaced as
can't access property "getChipDescription", this.chip is null.
Requirements:
- A Chromium-based browser on desktop (Chrome, Edge, or Opera). Web Serial is required and is not available in Firefox or Safari.
- Page must be served over HTTPS (the GitHub Pages site already is).
- USB-C cable plugged into the USB port of the board (the native USB / USB-Serial-JTAG port — not a separate UART port if your board has one).
Steps: open the page → click the Bind button for your board (Waveshare S3, Waveshare C5, Seeed XIAO C5, or C5-WROOM-1 / 1U) → pick the serial port → confirm install. Each button flashes a board-specific merged image; the installer refuses to flash if the connected chip doesn't match the board you picked, so choose the right button. Note the C5 buttons are all ESP32-C5 chips but flash different images (16 MB Waveshare vs 8 MB XIAO vs 8 MB generic WROOM) — pick the one matching your physical board. For any other ESP32-C5-WROOM-1 or WROOM-1U devkit, use C5-WROOM-1 / 1U: its 8 MB layout boots on every WROOM variant. If flashing stalls, hold BOOT, tap RESET, release BOOT and retry.
GPS on D6/D7 (e.g. XIAO C5 on a Piglet PCB): those pads are also the C5's UART0, which the ROM bootloader listens on. While a GPS is streaming NMEA into GPIO12, the flasher stub can fail to start (
Failed to start stub flasher). Either disconnect the GPS while flashing, or flash with esptool's ROM loader:esptool --chip esp32c5 --no-stub write-flash 0x0 huginn-xiao-esp32c5.bin.
Required for development or custom builds. This is a PlatformIO project using pioarduino — Arduino core 3.x / ESP-IDF 5.3 on the S3 env, 5.5 on the C5 env. The platform is downloaded automatically on first build.
Note: After flashing, the USB port re-enumerates. The combined upload+monitor command handles this automatically.
Why pioarduino? The stock PlatformIO espressif32 platform ships Arduino core 2.x (ESP-IDF 4.4), which has broken BLE on ESP32-S3 and no ESP32-C5 board definitions at all. pioarduino provides the newer cores where both work.
The Seeed XIAO ESP32-C5 uses a separate pipeline because its board definition (XIAO_ESP32C5) lives only in the official Espressif esp32 Arduino core — pioarduino ships only the C5 devkit/Waveshare board. So this target is built with arduino-cli instead of PlatformIO.
One-time setup:
arduino-cli config init
arduino-cli config add board_manager.additional_urls \
https://espressif.github.io/arduino-esp32/package_esp32_dev_index.json
arduino-cli core update-index
arduino-cli core install esp32:esp32Build (assembles a throwaway sketch from src/ and compiles for XIAO_ESP32C5):
bash scripts/build-xiao.shThe merged web-flasher image is produced by CI; to flash a local build directly, point esptool at the binaries in build-sketch/HuginnESP/build/esp32.esp32.XIAO_ESP32C5/. The XIAO C5 has 8 MB flash, so the partition scheme defaults to default_8MB (3 MB app) — override with e.g. XIAO_PARTITION=huge_app bash scripts/build-xiao.sh if needed. The same firmware (no display) runs as on the Waveshare C5.
Once flashed, the device starts auto-cycling through scan modes and emits results to USB serial at 460800 baud, 8N1.
The very first line on every boot is a device announce so a host can tell HuginnESP apart from other ESP32 firmware sharing the same USB bus:
{"device":"HuginnESP","fw":"1.0","board":"esp32-s3","caps":["wifi","ble","display"]}board is esp32-s3 or esp32-c5; caps lists the compiled-in capabilities (display is S3-only, zigbee appears only on 802.15.4-capable C5 builds, gps appears only in GPS-enabled builds). Hosts that connect to an already-running device can probe with status to confirm they're talking to HuginnESP, since no other firmware will respond with the same JSON shape.
GPS telemetry (GPS builds). While the receiver holds a fix, the firmware emits one position line per second, independent of any scan results:
{"type":"GPS","lat":59.3293000,"lon":18.0686000,"speed_kmh":12.3,"sats":9,"hdop":0.9,"alt":31.2}It has no mac, so hosts treat it as position-only telemetry — Ragnar feeds it to its GPS manager as an external fix, which keeps its position (and the records from its own adapters) current even with no local USB GPS and when no networks are being seen. sats / hdop / alt come from GGA and are omitted when the receiver doesn't report them. Nothing is sent without a fix. The gps command reports {"gps":"no_fix","rmc":N} before a fix, where rmc counts RMC sentences received (0 = no NMEA arriving — check wiring/power).
The ESP32-C5's radio also speaks IEEE 802.15.4, so C5 builds add an 802.15.4
sniffer (-DHUGINN_HAS_ZIGBEE=1, on by default in the esp32c5 /
esp32c5-gps environments and the XIAO build). It listens promiscuously and
hops across the 2.4 GHz channels (11–26), emitting one JSON line per frame that
carries a source address:
{"type":"ZIGBEE","panid":"0x1A2B","addr":"AABBCCDDEEFF0011","channel":15,"rssi":-70,"lqi":180,"ftype":"beacon","proto":"thread"}addris the 64-bit extended source address (EUI-64, canonical MSB-first). Frames that only carry a 16-bit short address emit"short":"0x1234"instead; the host keys those bypanid:short.ftypeisbeacon/data/cmd. ACK frames (no addresses) are ignored.protoclassifies the network layer riding on 802.15.4:zigbee,thread, or802.15.4(unknown). It's inferred from the beacon protocol ID and the payload dispatch above the MAC header — Zigbee's NWK header vs Thread's 6LoWPAN dispatch. Link-layer-encrypted data frames are opaque, so those report802.15.4; the network's beacons still classify it. Matter-over-Thread is ordinary Thread traffic at this layer, so it reportsthread. (typestaysZIGBEEas the record/transport tag for backward compatibility;protocarries the real protocol.)lat/lonare appended when a GPS module has a fix, same asWIFIlines.
Because WiFi, BLE and 802.15.4 share the single 2.4 GHz radio on the C5, the
scan cycle parks WiFi and BLE while the Zigbee phase runs (mirroring the
pineapple check). During wardrive a short Zigbee sweep (ZIGBEE_WARDRIVE_MS,
default 3 s) runs after each WiFi+BLE round; the zigbee serial command
switches to continuous Zigbee-only sniffing, and stop returns to auto-cycle.
Tunables (override in platformio.ini build_flags):
| Flag | Default | Meaning |
|---|---|---|
ZIGBEE_CHANNEL_MIN / ZIGBEE_CHANNEL_MAX |
11 / 26 |
802.15.4 channel range to sweep |
ZIGBEE_CHANNEL_DWELL_MS |
300 |
Listen time per channel before hopping |
ZIGBEE_WARDRIVE_MS |
3000 |
Zigbee sniff time per wardrive cycle |
Setting
-DHUGINN_HAS_ZIGBEE=1on a board without an 802.15.4 radio (S3, classic ESP32) compiles cleanly but the scanner disables itself at boot with a one-line notice — the radio simply isn't there.
After the announce line, the stream is a mix of:
- Newline-delimited JSON for raw scan results, one detection per line:
In GPS-enabled builds with a valid fix, both
{"type":"WIFI","mac":"AA:BB:CC:DD:EE:FF","ssid":"MyNetwork","rssi":-62,"channel":6,"auth":"WPA2"} {"type":"BLE","mac":"11:22:33:44:55:66","name":"AirPods","rssi":-71}WIFIandBLEJSON lines also include:lat,lon,speed_kph, andspeed_mps. (authis one ofOpen,WEP,WPA,WPA2,WPA/WPA2,WPA2-Enterprise,WPA3,Unknown.) - Plaintext alert blocks for high-signal events (Flipper Zero, AirTag, skimmer, pineapple/evil-twin), plus
[BOOT]startup logs and[CYCLE]/[WIFI]progress logs.
A compact JSON status line is printed only in response to the status command — it is not streamed continuously:
{"mode":"wifi","wifi_count":12,"ble_count":0}The device also accepts commands on the same serial line (one per \n-terminated line):
| Command | Action |
|---|---|
scanap |
Start WiFi AP scan |
blescan -f |
BLE scan with Flipper/AirTag filter |
blescan -a |
BLE scan all devices |
capture -skimmer |
Start skimmer detection |
pineap |
Start pineapple / evil-twin detection |
wardrive |
Tight WiFi+BLE alternation tuned for moving captures (see below) |
stop / capture -stop |
Stop current scan, resume auto cycle |
status |
Print a JSON status line |
gps |
Print current GPS fix ({"gps":"fix","lat":...,"lon":...,"speed_kph":...,"speed_mps":...} or {"gps":"no_fix"}); GPS-enabled builds only |
In the default auto-cycle each WiFi scan runs for wifi_scan_duration_ms (8 s by default), with a pineapple/evil-twin check every eighth scan — each radio is sampled more frequently than before. Engaging wardrive switches to a tight 2-phase loop tuned for movement:
| Phase | Default | Effect |
|---|---|---|
WiFi (wardrive_wifi_ms) |
8000 ms | A weighted per-channel sweep, capped at this value. High-traffic channels (2.4 GHz 1/6/11, plus the 5 GHz channels on the C5) are visited first and repeated, then the rest are swept once. On the C5 the scan type is chosen per channel (active for 2.4 GHz + non-DFS 5 GHz, passive for DFS) so 5 GHz is captured in any region — see Dual-band WiFi scanning (C5). The S3's 2.4 GHz list finishes well inside the cap (~3–4 s); the C5's longer dual-band list can use the full window. WiFi is de-duplicated within a sweep (the channel list revisits 1/6/11 and the 5 GHz channels), but each AP is re-emitted once per cycle with a fresh GPS fix — so a host that connects mid-session still receives every AP, and a moving capture gets repeated GPS-tagged sightings for triangulation |
BLE all (wardrive_ble_ms) |
1500 ms | Covers all 3 BLE advertising channels with margin; Flipper / AirTag / skimmer detections fire passively from the same stream |
The WiFi phase revisits the busy channels frequently while still covering the whole band, so a moving capture catches in-range APs several times per pass. Lower wardrive_wifi_ms (min 1000 ms) for a faster loop with shallower per-channel coverage. Pineapple/evil-twin detection is skipped in wardrive mode because it relies on comparing scans over time; run stop and then pineap when you want it.
A few internal knobs can be tuned over the same serial line — useful when you want different behavior per integration without rebuilding the firmware. State is held in RAM only (no NVS persistence), so the host should push its preferred values at startup.
set <key> <value> # update a knob
get <key> # read one knob
get all # dump all knobs
| Key | Type | Range | Effect |
|---|---|---|---|
wifi_scan_duration_ms |
uint | 500..600000 | Per-step WiFi scan time in the auto-cycle (and the pineapple scan timeout) |
ble_spam_threshold |
uint | 1..10000 | Adverts from one MAC within the spam window before a BLE Spam detected alert fires |
wardrive_wifi_ms |
uint | 1000..30000 | Ceiling on the per-channel WiFi sweep in wardrive mode (default 8000) |
wardrive_ble_ms |
uint | 500..30000 | BLE slot length in wardrive mode (default 1500 — covers all 3 ad channels with margin) |
pineapple_every_n |
uint | 0..1000 | Run periodic pineapple check every N WiFi scans (0 disables periodic checks; manual pineap still works) |
skimmer_names |
csv | — | Comma-separated BLE device names treated as suspicious (case-insensitive). Replaces the list, doesn't append |
Every set/get returns a single JSON status line, e.g.:
> set ble_spam_threshold 8
{"ok":true,"key":"ble_spam_threshold","value":8}
> set skimmer_names HC-05,HC-06,JDY-08
{"ok":true,"key":"skimmer_names","value":"HC-05,HC-06,JDY-08"}
> set wifi_scan_duration_ms abc
{"error":"bad value (range 500..600000)"}
> get all
{"ok":true,"key":"wifi_scan_duration_ms","value":8000}
{"ok":true,"key":"ble_spam_threshold","value":8}
{"ok":true,"key":"wardrive_wifi_ms","value":8000}
{"ok":true,"key":"wardrive_ble_ms","value":1500}
{"ok":true,"key":"pineapple_every_n","value":8}
{"ok":true,"key":"skimmer_names","value":"HC-05,HC-06,JDY-08"}
Unknown keys, malformed values, and out-of-range numbers all return {"error":"..."} and leave the current value untouched. Existing verbs (scanap, blescan -f, etc.) are unchanged.
Host integration pattern. Because the firmware doesn't persist these values, the recommended pattern is:
- Wait for the
{"device":"HuginnESP",...}announce line on connect (or after a Huginn reboot). - Push your saved keys with
set ...lines before relying on any specific behavior. - Optionally call
get allafterward to verify the values landed.
Ragnar implements exactly this — it persists the values host-side in shared_config.json and re-pushes them every time the device announce arrives. Any other host (Home Assistant, a CLI tool, etc.) should follow the same handshake.
Anything that can open a serial port can consume HuginnESP — Ragnar is just one example. Here's a minimal Python consumer using pyserial:
# pip install pyserial
import json
import serial
PORT = "COM8" # or "/dev/ttyACM0" on Linux/macOS
BAUD = 460800
with serial.Serial(PORT, BAUD, timeout=1) as ser:
# Optional: ask the device to start a specific scan
ser.write(b"blescan -a\n")
for raw in ser:
line = raw.decode("utf-8", errors="replace").strip()
if not line:
continue
# JSON detections look like {"type":"WIFI",...} or {"type":"BLE",...}
if line.startswith("{") and line.endswith("}"):
try:
evt = json.loads(line)
except json.JSONDecodeError:
print("raw:", line)
continue
if evt.get("type") == "WIFI":
print(f"WIFI {evt['ssid']!r:30} {evt['mac']} ch{evt['channel']:>2} {evt['rssi']} dBm {evt['auth']}")
elif evt.get("type") == "BLE":
print(f"BLE {evt.get('name','') or '<unnamed>':30} {evt['mac']} {evt['rssi']} dBm")
else:
print("status:", evt)
else:
# Plaintext alert / boot log
print("log:", line)That's the entire integration surface — open the port, read lines, parse JSON. Ragnar's wardriving engine does the same thing in wardriving.py → _parse_serial_line(); you can replace it with anything (Home Assistant, MQTT bridge, a CLI logger, etc.).
src/
├── main.cpp # Entry point, FreeRTOS task creation
├── config.h # Constants and configuration
├── wifi_scanner.h/cpp # WiFi scanning & pineapple detection
├── ble_scanner.h/cpp # BLE scanning, Flipper/AirTag/skimmer/spam detection
├── serial_cmd.h/cpp # Serial command parser
├── runtime_config.h/cpp # `set`/`get` knobs (RAM-only, host-pushed)
├── scan_cycle.h/cpp # Automatic scan rotation
├── gps_reader.h/cpp # NMEA GPS reader — compiled in only with HUGINN_HAS_GPS=1
└── display_manager.h/cpp # 480×480 touch display UI (S3 only)
docs/ # Web flasher (GitHub Pages site)
.github/workflows/ # CI: builds firmware and publishes the flasher
┌─────────────────────────────────────────────────┐
│ ESP32-S3 / ESP32-C5 │
│ │
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ │
│ │ WiFi │ │ BLE │ │ Display │ │
│ │ Scanner │ │ Scanner │ │ Manager │ │
│ │ (task) │ │ (task) │ │ (S3) │ │
│ └────┬────┘ └────┬─────┘ └────┬────┘ │
│ │ │ │ │
│ ▼ ▼ │ │
│ ┌─────────────────────┐ │ ┌─────────┐ │
│ │ Serial Output │◄───────┘ │ GPS │ │
│ │ (460800 baud) │◄──────────│ Reader │ │
│ └─────────┬───────────┘ │ (task) │ │
│ │ └────┬────┘ │
│ ┌─────────▼───────────┐ │ │
│ │ Serial Command │ UART to │
│ │ Parser (incoming) │ NMEA module │
│ └─────────────────────┘ │
└──────────────┬────────────────────────────────┘
│ USB Serial (JSON lines)
▼
┌─────────────────────────────────────────────────┐
│ Any host: Ragnar (Raspberry Pi), │
│ a Python script, Home Assistant, ... │
└─────────────────────────────────────────────────┘
MIT