Skip to content

SinnDevelopment/hackrf-logger

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

hackrf-logger

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.

Requirements

  • Go 1.26+
  • A C toolchain (cgo) and libhackrf:
    • macOS: brew install hackrf
    • Debian/Ubuntu: sudo apt-get install libhackrf-dev pkg-config
  • 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.

Build & run

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 :8080

Open 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)

Usage

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-interval is written to SQLite, so the database stays manageable during long runs.

Gain (-autogain and manual)

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.

  • -autogain measures 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 -lna and 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).

Web API

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.

Integration: capturing RF around events (e.g. FTC matches)

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-interval and writes every sweep, so the window is densely recorded even though routine logging is throttled.
  • Scheduled post-roll. Closing with postRollMs keeps 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; pass full=1 to retrieve all of it.

Example: FTC match listener

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.

Dashboard controls

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.

Data storage

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's freqs. 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].

Deployment

Run it as a service on whichever host the HackRF is attached to. Sample units are in deploy/:

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.

How it works

  1. libhackrf is programmed for INTERLEAVED sweep 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-byte 0x7F 0x7F | uint64 LE frequency header.
  2. Blocks are copied out of the libusb callback thread onto a bounded channel (overflow is dropped and counted, never blocking USB).
  3. 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.
  4. 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.

Project layout

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

Troubleshooting

  • hackrf_open: HACKRF_ERROR_NOT_FOUND — device not seen. Check hackrf_info, the USB cable, and (Linux) udev permissions / plugdev membership.
  • 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 dropped count — the consumer is behind. Increase -w (fewer bins) or reduce other load; dropped transfers only thin the data, they don't corrupt it.

About

Record and datalog the RF environment using a HackRF

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors