A browser-first signal intelligence workstation for the HackRF One + PortaPack H2
(Mayhem firmware v2.0.2). A FastAPI + WebSocket backend talks to the device over
USB-serial and drives hackrf_sweep. A single-page tactical dashboard
renders a live spectrum + waterfall, detects and labels signals against 30+ US
band allocations, and remote-controls the PortaPack.
A software-defined radio is a small USB gadget that listens to the radio spectrum. Plug in a HackRF and you can, in principle, watch any signal between 1 MHz and 6 GHz — FM stations, weather satellites, keyless-entry remotes, Wi-Fi, police radio, whatever's nearby.
The catch is that "in principle" does a lot of work in that sentence. Raw SDR tools are unforgiving. This dashboard is the friendlier face on top: a live picture of what's out there, with the known bands labelled, signals that come and go logged with timestamps, a row of quick-launch buttons for common bands, and a way to tune the attached PortaPack to listen in.
It's how the code came to exist. Before the dashboard, the interesting
work was collaborative reverse-engineering of the device itself — poking
every ChibiOS shell command on a live HackRF + PortaPack, watching what
worked, what crashed the USB stack, what the output format actually was.
HACKRF_TECHNICAL_REFERENCE.md (590 lines of device-verified
facts) is the product of that. Every feature in the dashboard traces back
to something we discovered and wrote down.
0x1D50 / PID 0x6018 — not the stock HackRF VID/PID
Because PortaPack mode changes the USB identity, the standard
hackrf_* tools can't see the device when it's running. The
dashboard's serial-control layer is what lets both sides coexist.
Browser ◀──── WebSocket ────▶ FastAPI (sigint_server.py)
│
├──▶ portapack.py ──▶ /dev/ttyACMx
│ (ChibiOS shell, 115200 8N1)
└──▶ hackrf_sweep subprocess
└── or simulate_sweep() fallback
One process. One WebSocket. Two background threads (sweep worker + PortaPack I/O, protected by a lock). One async broadcast loop pushing state at 2.5 Hz.
Auto-detect by VID/PID with a fallback /dev/ttyACM*
scan. Context-manager interface. On OSError the library
closes, rediscovers the port (the ACM number may have changed),
reopens, and retries. Strips shell echo and the trailing
ch> prompt. Structured parsers for info,
radioinfo, applist.
Configurable threshold, minimum-gap merge, a static table of 30 US
band allocations (KNOWN_BANDS) plus
identify_signal(freq_mhz) → label. Quick-scan presets
(all · fm · air · vhf · uhf · cell · ism900 · gps · wifi ·
wifi5 · lte · 5g). CSV export. CLI with an ASCII bar chart for
quick sweeps.
Sweep worker runs hackrf_sweep as a subprocess,
streams stdout, buckets bins into a per-sweep map, emits completed
sweeps and appends a down-sampled row to the waterfall. Signal
detection walks the sorted spectrum, tracks peaks by quantized
frequency, emits NEW_SIGNAL / SIGNAL_LOST
with duration. Dead sockets pruned inline. Idle self-shutdown at
60 s of zero clients.
Triggered when hackrf_sweep exits immediately (device
in PortaPack mode) or throws. Generates a noise floor plus 16 canonical
Gaussian-shaped signals (FM, 2m, NOAA, ISM…) at ~3 Hz so the UI stays
usable for dev work and demos without the radio attached.
Four regions. Top bar: brand, connection dot, sweep dot, CONNECT / SWEEP / STOP, UTC clock. Left panel: live device info, sweep config, 21 band presets, PortaPack remote (D-pad + rotary + Read Screen), 20-app quick-launch grid. Center: canvas spectrum with peak-hold markers and click-to-tune, 200-row waterfall with a 6-stop color ramp. Right: active signals table, event log, shell console. Bottom: frequency entry + 7 modulation modes + VU meter.
System-tray icon spawns the server, waits for health, opens the
browser, and tears everything down on idle. One entry point —
launch-sigint.sh — that's idempotent: kills any prior
instance before spawning.
hackrf_sweep + peak detection + band IDVanilla JS, one HTML file, one launcher. If you can't run it,
that's a 5-minute debug. If it breaks, it breaks in a place you can
read. A radio dashboard doesn't need 400 MB of node_modules.