Continuously sweep the 5 GHz Wi-Fi spectrum (U-NII bands, ~5150–5895 MHz) with a HackRF One, log the power spectra to SQLite, and watch them live in a built-in web dashboard (spectrum trace + scrolling waterfall).
Technically there is no limitation on what band is selected, I just wrote this caring most about the 5GHz Wi-Fi use
case. The frequency range is configurable with -f.
The 5 GHz band is far wider than the HackRF's ~20 MHz instantaneous bandwidth, so this tool
drives the radio's hardware sweep mode via libhackrf (cgo — no shelling out to the
hackrf_sweep CLI) and computes the FFT in-process. The frequency→bin mapping is a faithful
port of upstream hackrf_sweep.c, so recorded spectra line up with the reference tool.
- Go 1.26+
- A C toolchain (cgo) and libhackrf:
- macOS:
brew install hackrf - Debian/Ubuntu:
sudo apt-get install libhackrf-dev pkg-config
- macOS:
- A HackRF One on USB. Confirm it's visible:
hackrf_info.
cgo build flags are wired per-OS: macOS uses the Homebrew prefix
(/opt/homebrew/include, /opt/homebrew/lib); Linux uses pkg-config: libhackrf. Override
with CGO_CFLAGS / CGO_LDFLAGS if your install lives elsewhere.
hackrf-logger (cmd/hackrf-logger) is a single self-contained binary: the dashboard HTML/JS
is embedded, so the program needs nothing on disk but its SQLite database. It is
domain-agnostic — it just sweeps, logs, and exposes a generic capture API.
make build # -> ./hackrf-logger
./hackrf-logger # sweep 5150-5895 MHz, log to ./data/hackrf.db, serve :8080Open http://localhost:8080. Stop with Ctrl-C — the device is released cleanly (no orphaned USB transfers).
make all # gofmt check + vet + test + build
make test # unit tests (hardware tests self-skip if no HackRF)hackrf-logger [flags]
-f string frequency range MIN:MAX in MHz (default "5150:5895")
-w int target FFT bin width in Hz; snapped to nearest power-of-two
FFT size (default 100000)
-autogain probe gain settings at startup and pick the best for the
connected antenna (overrides -lna/-vga/-amp)
-lna int RX LNA (IF) gain, 0-40 dB in 8 dB steps (default 32)
-vga int RX VGA (baseband) gain, 0-62 dB in 2 dB steps (default 30)
-amp enable the front-end RF amplifier ~11 dB (default true; use -amp=false)
-antenna-power enable antenna-port DC power (active antennas/amps)
-min-db float fix the waterfall color floor in dB (with -max-db; else auto)
-max-db float fix the waterfall color ceiling in dB (with -min-db; else auto)
-addr string HTTP listen address (default ":8080")
-db string SQLite path; empty disables persistence (default "./data/hackrf.db")
-log-interval d minimum spacing between persisted frames (default 2s)
-history int recent frames kept in memory for the live waterfall (default 600)
-device string HackRF serial suffix; empty selects the first device
-api-token string require a bearer token on capture create/close/delete (empty = open)
-cors-origin str Access-Control-Allow-Origin for the API (default "*")
Notes:
- Resolution. The HackRF samples at 20 MHz; the FFT size is
20 MHz / -w, rounded to a power of two.-w 100000→ 256 bins (~78 kHz). For a coarser/faster sweep use a larger-w(e.g.-w 1000000→ 16 bins, 1.25 MHz). The effective resolution is logged at startup and shown in the dashboard. - Throttled logging. Every sweep is shown live, but only one frame per
-log-intervalis written to SQLite, so the database stays manageable during long runs.
5 GHz signal level depends heavily on the antenna and how close you are to the transmitter, so gain matters a lot. Too little and signals sit in the noise; too much overloads the front end — the noise floor climbs, internal spurs ("birdies") multiply, and real channels stop standing out.
-autogainmeasures a short capture at several LNA/VGA/amp settings and picks the one with the best usable dynamic range for your antenna. It takes ~30 s at startup and logs a comparison table. Recommended whenever the antenna or environment changes.- Manual: start from the defaults (
-lna 32 -vga 30, amp on). If you see a high noise floor and lots of spurs near strong APs, back off —-amp=false, then lower-vga, then-lna. If the floor is flat and signals are faint (small/distant antenna), add-lnaand enable the amp. - The dashboard's Subtract baseline + Peak-hold toggles flatten static spurs and paint busy channels' occupied bandwidth — use them to read traffic shape (see below).
All endpoints are under /api/v1 and return JSON. Errors use {"error": "..."}. CORS is
enabled (-cors-origin, default *). A bearer token can be required on mutating endpoints
with -api-token <token> (sent as Authorization: Bearer <token>); read endpoints stay open.
| Endpoint | Description |
|---|---|
GET / |
Dashboard (self-contained HTML/JS/canvas, no external deps). |
GET /api/v1/health |
Liveness + whether persistence is enabled. |
GET /api/v1/status |
Device serial/board/firmware, active config, sweep rate, dropped count. |
GET /api/v1/frame |
Latest spectrum frame (freqs, powers). |
GET /api/v1/waterfall?since=<seq> |
Freq axis, dynamic range, and frames newer than seq (incremental). |
GET /api/v1/history?from=<ms>&to=<ms>&step=<n>&session=<id> |
Decimated past frames from SQLite. |
POST /api/v1/captures |
Create a capture window (auth). |
GET /api/v1/captures?code=&status=&limit= |
List capture windows. |
GET /api/v1/captures/{id} |
Get one capture. |
POST /api/v1/captures/{id}/close |
Finalize / schedule end of a capture (auth). |
DELETE /api/v1/captures/{id} |
Delete a capture marker, keeping its frames (auth). |
GET /api/v1/captures/{id}/frames?step=<n>&full=<0|1> |
Frames inside the window; restricted to the capture's focus bands unless full=1. |
GET /api/v1/captures/{id}/export?step=<n>&full=<0|1> |
Self-contained downloadable bundle of the above. |
The logger runs continuously; a separate application marks the time windows it cares about
and pulls the RF data back out — the two stay fully decoupled. For FTC specifically, the
hackrf-ftc-logger project (its own repo)
already implements this end to end (subscribe to the Scorekeeper stream → look up the match's
teams and their 5 GHz channels → mark a channel-focused window per match) and serves the
capture review dashboard. The rest of this section describes the underlying API it uses.
A capture is a labeled time window with arbitrary metadata. The mechanics that make it useful:
- Pre-roll backfill. On create, the window's start can be in the recent past
(
preRollMs); the logger backfills that span from its in-memory ring, so you get full-resolution data from before you knew the event would happen. (Limited by-history, the ring size — raise it if you need a long pre-roll.) - Full-rate recording. While any capture is recording, persistence ignores
-log-intervaland writes every sweep, so the window is densely recorded even though routine logging is throttled. - Scheduled post-roll. Closing with
postRollMskeeps recording that much longer past the end before the window is sealed by the background finalizer — fire-and-forget. - Frequency focus.
focusRanges(a list of{lowHz, highHz}) restricts what the frames/export endpoints return to just those bands — e.g. only the Wi-Fi channels of the teams in a match. The full band is still recorded; passfull=1to retrieve all of it.
A companion app subscribes to the FTC Scorekeeper stream
(ws://<host>/api/v2/stream/?code=<eventCode>) and drives the capture API on match
transitions:
on MATCH_START (or SHOW_MATCH):
POST /api/v1/captures
{ "label": "Q1", "code": "<eventCode>", "matchNumber": 12, "field": 1,
"preRollMs": 3000, "metadata": { "alliance": "red" } }
-> { "id": 42, "status": "open", ... } # remember id by match
on match end (timer, or next MATCH_LOAD):
POST /api/v1/captures/42/close { "postRollMs": 5000 }
# window is sealed ~5s later; pull it when convenient:
GET /api/v1/captures/42/export?step=1 -> capture + freqs + frames bundle
curl quickstart:
ID=$(curl -s -X POST localhost:8080/api/v1/captures \
-d '{"label":"Q1","code":"USCMP","matchNumber":12,"preRollMs":3000}' | jq .id)
# ... match runs ...
curl -s -X POST localhost:8080/api/v1/captures/$ID/close -d '{"postRollMs":5000}'
curl -s "localhost:8080/api/v1/captures/$ID/frames?step=1" | jq '.frames | length'The capture model is domain-agnostic — the logger knows nothing about FTC. Create-request
fields (all optional): label and code (freeform tags), startMs (explicit start instead
of now-preRollMs), endMs (one-shot window), preRollMs, postRollMs, metadata
(arbitrary caller JSON, echoed back — the companion stuffs match number/teams/channels here),
and focusRanges (list of {lowHz, highHz} to focus retrieval on). Close-request fields:
endMs (default now), postRollMs.
Listing and reviewing captures is left to the client: the logger only exposes the generic
GET /api/v1/captures and frames endpoints. The FTC-aware list / display / review UI lives
in the separate hackrf-ftc-logger (its own
dashboard), not in the logger's.
The dashboard processes frames client-side (raw dB is always what's logged):
- Subtract baseline — tracks a per-bin running minimum (noise floor + constant spurs)
and shows
power − baseline, so static birdies flatten and only time-varying traffic remains. Best for revealing which channels are active. - Peak-hold — slow-decay max envelope (yellow trace); a busy channel paints its full occupied bandwidth instead of flickering. Combine with baseline subtraction to see the shape of constant traffic.
- Averaging — EMA smoothing for a calmer outline.
- Reset baseline / peak — re-learn after moving the antenna or changing gain.
SQLite via GORM on the pure-Go glebarez/sqlite driver (wraps
modernc.org/sqlite — no cgo). The schema is managed by GORM AutoMigrate:
sessions(id, started_at_ms, freq_min_hz, freq_max_hz, bin_width_hz,
n_bins, lna, vga, amp, freqs BLOB)
frames(id, session_id, ts_ms, powers BLOB) -- UNIQUE(session_id, ts_ms)
captures(id, session_id, label, code, status, start_ms, end_ms,
metadata, focus, created_ms)
Large numeric arrays stay as packed little-endian blobs (freqs = float64[n_bins],
powers = float32[n_bins]) rather than ORM-serialized JSON, keeping frame rows compact at
high sweep rates. Capture metadata and focus are small and stored as JSON text.
sessions.freqs— bin center frequencies (Hz), little-endian float64[n_bins].frames.powers— power per bin (dB), little-endian float32[n_bins], parallel to the session'sfreqs. Each new run creates a new session row.
Quick look with the sqlite3 CLI:
sqlite3 data/hackrf.db 'SELECT id, datetime(started_at_ms/1000,"unixepoch"), n_bins FROM sessions;'
sqlite3 data/hackrf.db 'SELECT count(*) FROM frames;'To decode a powers BLOB elsewhere, read it as little-endian float32 values; map index i
to sessions.freqs[i].
Run it as a service on whichever host the HackRF is attached to. Sample units are in
deploy/:
- Linux:
deploy/hackrf-logger.service(systemd). Needs HackRF USB permissions — install the hackrf udev rules and add the service user toplugdev(or run as root). - macOS:
deploy/com.sinndev.hackrf-logger.plist(launchd agent).
Each file has install instructions in its header comment. The service restarts automatically; the program itself also restarts the sweep with backoff if the stream stalls.
libhackrfis programmed forINTERLEAVEDsweep mode (hackrf_init_sweep/hackrf_start_rx_sweep) across the requested range; the firmware retunes itself and streams 16384-byte blocks, each prefixed with a 10-byte0x7F 0x7F | uint64 LE frequencyheader.- Blocks are copied out of the libusb callback thread onto a bounded channel (overflow is dropped and counted, never blocking USB).
- A worker windows (Hann) and FFTs each block with a dependency-free radix-2 FFT, then
scatters the two usable quarter-bands into a per-sweep accumulator using the same bin
indices as
hackrf_sweep.c. - When the sweep wraps back to its start frequency, the assembled frame is pushed to an in-memory ring (for the live view) and throttled into SQLite.
cmd/hackrf-logger/ logger main: flags, sweep supervisor, auto-gain, shutdown
internal/dsp/ radix-2 FFT, block decode, frame assembly (no hardware)
internal/hackrf/ libhackrf cgo bindings + sweep callback
internal/autogain/ per-antenna gain calibration (scoring + selection)
internal/store/ in-memory ring + SQLite persistence + captures
internal/server/ Gin-based /api/v1 server, middleware, embedded dashboard
deploy/ systemd + launchd sample units
hackrf_open: HACKRF_ERROR_NOT_FOUND— device not seen. Checkhackrf_info, the USB cable, and (Linux) udev permissions /plugdevmembership.- Dashboard shows "signal stalled" — no sweep data for >4 s. The supervisor restarts the
sweep automatically; check the logs and that nothing else (e.g.
hackrf_sweep, GQRX) is holding the device. - High
droppedcount — the consumer is behind. Increase-w(fewer bins) or reduce other load; dropped transfers only thin the data, they don't corrupt it.