|
3 | 3 | [](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) |
4 | 4 | [](https://www.python.org/) |
5 | 5 |
|
6 | | -## An UNOFFICIAL Python CLI + scripting wrapper for the HackRF One |
| 6 | +## AN UNOFFICIAL Python CLI + scripting wrapper for the HackRF One |
7 | 7 |
|
8 | 8 | A non-GUI Python wrapper and command-line tool for the [HackRF One](https://greatscottgadgets.com/hackrf/one/) software-defined radio. |
9 | 9 |
|
@@ -40,13 +40,16 @@ The primary GitHub: [https://github.com/LC-Linkous/hackRF_python](https://github |
40 | 40 | * [Scan-then-Capture Pipeline](#scan-then-capture-pipeline) |
41 | 41 | * [Transmitting](#transmitting) |
42 | 42 | * [Reading Recordings Back](#reading-recordings-back) |
| 43 | + * [Sample Data (no board required)](#sample-data-no-board-required) |
| 44 | + * [Runnable Examples](#runnable-examples) |
43 | 45 | * [Method Reference](#method-reference) |
44 | 46 | * [Receive / Capture](#receive--capture) |
45 | 47 | * [Sweep](#sweep) |
46 | 48 | * [Transmit](#transmit) |
47 | 49 | * [Info and Preflight](#info-and-preflight) |
48 | 50 | * [Device Management (advanced)](#device-management-advanced) |
49 | 51 | * [Decoding and File Helpers](#decoding-and-file-helpers) |
| 52 | + * [Power and Relative Calibration](#power-and-relative-calibration) |
50 | 53 | * [Mode, Validation, and Feedback](#mode-validation-and-feedback) |
51 | 54 | * [Device Capability Coverage](#device-capability-coverage) |
52 | 55 | * [CLI Reference](#cli-reference) |
@@ -107,7 +110,7 @@ cd hackrfpy |
107 | 110 | # hackrfpy. Writes uv.lock + .python-version. |
108 | 111 | uv sync |
109 | 112 |
|
110 | | -# add plotting deps (matplotlib + PyQt5) when running the examples |
| 113 | +# add plotting deps (matplotlib) when running the examples |
111 | 114 | uv sync --extra plotting |
112 | 115 | ``` |
113 | 116 |
|
@@ -148,14 +151,14 @@ If that prints a board (or at least runs), the binaries are reachable. If it is |
148 | 151 |
|
149 | 152 | The library itself depends only on **numpy**. It shells out to the `hackrf-tools` binaries (`hackrf_info`, `hackrf_transfer`, `hackrf_sweep`, and the device-management tools), which are a **system dependency**, not a pip one. There is no serial port and no `pyserial` involved — the HackRF presents as a USB device the binaries talk to. |
150 | 153 |
|
151 | | -The plotting examples additionally need matplotlib and PyQt5, grouped under the optional `[plotting]` extra: |
| 154 | +The plotting examples additionally need matplotlib, grouped under the optional `[plotting]` extra: |
152 | 155 |
|
153 | 156 | ```bash |
154 | 157 | pip install nvnapython # (illustrative) library only |
155 | 158 | pip install "hackrfpy[plotting]" # library + example plotting deps |
156 | 159 | ``` |
157 | 160 |
|
158 | | -On Linux, `PyQt5` is the matplotlib backend used to draw the example figures and must be installed to run them; on many Windows machines it is not required. Install it if in doubt. |
| 161 | +matplotlib draws the example figures using a native GUI backend (TkAgg on Windows and most platforms, which ships with Python — no extra install). If a live window doesn't appear, your matplotlib may have selected a non-interactive backend; set one explicitly with the `MPLBACKEND=TkAgg` environment variable before running. |
159 | 162 |
|
160 | 163 | Python 3.11+ is required (the library uses `tomllib` and modern typing). The examples default to common ISM/broadcast bands but take frequency arguments for other ranges. |
161 | 164 |
|
@@ -192,7 +195,15 @@ hackRF_python/ repo root (this README, CITATION, etc.) |
192 | 195 | │ │ ├── capture_to_file.py |
193 | 196 | │ │ ├── scan_then_capture.py |
194 | 197 | │ │ ├── sweep_collect.py |
195 | | -│ │ └── waterfall_realtime.py |
| 198 | +│ │ ├── waterfall_realtime.py |
| 199 | +│ │ ├── waterfall_persistent.py single-freq FFT waterfall (persistent RX) |
| 200 | +│ │ ├── device_explorer.py detect + identify + capabilities (read-only) |
| 201 | +│ │ ├── power_meter.py live dBFS meter via capture_callback |
| 202 | +│ │ ├── persistent_capture.py multi-segment capture, one process |
| 203 | +│ │ ├── benchmark.py measure decode/throughput/latency |
| 204 | +│ │ ├── calibrate.py calibration workflow (Levels 2-3) |
| 205 | +│ │ ├── collect_sample_data.py real sample-data collector (read-only) |
| 206 | +│ │ └── sample_data/ committed real recordings + SigMF + README |
196 | 207 | │ └── tests/ |
197 | 208 | │ ├── conftest.py cross-platform stub-binary factory |
198 | 209 | │ ├── fixtures/ frozen hackrf_info / sweep / iq samples |
@@ -248,12 +259,14 @@ uv run pytest -m hardware |
248 | 259 | uv run pytest --cov=hackrfpy --cov-report=term-missing |
249 | 260 | ``` |
250 | 261 |
|
251 | | -> **Note:** this is a configured uv project, so `uv run pytest` uses the synced venv and editable install. If a stray `.venv` appears somewhere unexpected, you ran it from the wrong directory. |
| 262 | +> **Note:** this is a configured uv project, so `uv run pytest` uses the synced venv and editable install. **Run *scripts* the same way** — `uv run python tests/collect_real_data.py ...`, not bare `python ...`. Bare `python` can pick up a different activated `.venv` (for example one at the repo root, outside the `hackrfpy/` project dir) that doesn't have the project's dependencies installed, producing a confusing `ModuleNotFoundError: No module named 'numpy'` even though numpy is declared. If you see uv warn that `VIRTUAL_ENV ... does not match the project environment`, that's the mismatch — prefer `uv run` so the right environment is always used. |
252 | 263 |
|
253 | 264 | The suite is split into hardware-free tests and tests marked `@pytest.mark.hardware`, which auto-skip when no board is detected. Hardware detection is intentionally **not cached**, so you can plug/unplug between runs. |
254 | 265 |
|
255 | 266 | **A note on cross-platform coverage:** the process-lifecycle tests (the riskiest code) use **cross-platform stub binaries**, not bash scripts, so they run on Windows — the platform this library targets. Stubs are generated by a factory in `conftest.py` that writes a small Python program plus a launcher (`.bat` on Windows, a shebang'd file on POSIX). This matters because the Windows interrupt path (`CTRL_BREAK_EVENT`, used to stop a running `hackrf_transfer`) is otherwise untested on the exact platform where it must work. Run `pytest tests/test_lifecycle_xplat.py` on Windows to prove the reap/stop machinery before attaching hardware. |
256 | 267 |
|
| 268 | +**Hardware-validated parsing.** The parsers (`parse_info`, `parse_sweep_line`, IQ decode) are tested against frozen *verbatim output from a real HackRF One* — see `tests/fixtures/*_real.*` and `tests/test_real_output.py`. Collecting that real output surfaced behaviors no synthetic fixture had: a git-style tools version with no year, a field whose value sits on an indented continuation line, trailing free-text USB warnings, and **out-of-order sweep segments**. The hardware-marked tests (`test_hardware.py`) pass against a connected board. You can regenerate the real fixtures yourself with `tests/collect_real_data.py` (read-only). |
| 269 | + |
257 | 270 |
|
258 | 271 | ## Error Handling |
259 | 272 |
|
@@ -460,6 +473,52 @@ fs = meta["global"]["core:sample_rate"] |
460 | 473 | fc = meta["captures"][0]["core:frequency"] |
461 | 474 | ``` |
462 | 475 |
|
| 476 | +### Sample Data (no board required) |
| 477 | + |
| 478 | +The repo ships **real recordings** under `examples/sample_data/` so you can try |
| 479 | +the library — and downstream signal processing — without owning a HackRF. Each |
| 480 | +`.iq` is interleaved int8 I/Q with a `.sigmf-meta` sidecar, plus sweep CSVs and a |
| 481 | +provenance README noting the firmware/tools that produced them. |
| 482 | + |
| 483 | +```python |
| 484 | +from hackrfpy import load_iq, read_sigmf_meta |
| 485 | +iq = load_iq("examples/sample_data/fm_2Msps.iq") |
| 486 | +meta = read_sigmf_meta("examples/sample_data/fm_2Msps.iq") |
| 487 | +``` |
| 488 | + |
| 489 | +To collect your own (read-only; never transmits), use the sample collector. It |
| 490 | +preflights for a board, then captures real IQ + sweep data per band with SigMF |
| 491 | +metadata: |
| 492 | + |
| 493 | +```bash |
| 494 | +uv run python examples/collect_sample_data.py --band fm --band ism433 |
| 495 | +``` |
| 496 | + |
| 497 | +Defaults are kept small (0.5 s at 2 Msps) so they can live in the repo; use |
| 498 | +`--seconds` / `--sample-rate` for larger local datasets. This is distinct from |
| 499 | +`tests/collect_real_data.py`, which freezes tiny verbatim slices as *parser test |
| 500 | +fixtures* rather than usable sample datasets. |
| 501 | + |
| 502 | +### Runnable Examples |
| 503 | + |
| 504 | +The `examples/` directory has end-to-end scripts you can run against a board (all read-only except where noted): |
| 505 | + |
| 506 | +| Script | What it shows | |
| 507 | +|---|---| |
| 508 | +| `device_explorer.py` | The "first thing you run": `detect` + `identify` + `features`, reporting board, firmware, capabilities, and any device warnings. | |
| 509 | +| `capture_to_file.py` | Bounded capture to a file with a SigMF sidecar, then read it back. | |
| 510 | +| `power_meter.py` | Live dBFS power meter at one frequency via `capture_callback` — the callback API on a real stream, with the gain-normalized relative reading. | |
| 511 | +| `scan_then_capture.py` | Sweep a band, find the strongest bin, then capture there — a pipeline the class expresses that CLI tools can't. | |
| 512 | +| `sweep_collect.py` | One sweep across a band saved to CSV. | |
| 513 | +| `waterfall_realtime.py` | Live spectrum waterfall (needs the `[plotting]` extra). | |
| 514 | +| `waterfall_persistent.py` | Single-frequency FFT waterfall over time, driven by a persistent receiver — the complement to the sweep waterfall (one channel evolving vs. a wide band). Needs the `[plotting]` extra. | |
| 515 | +| `persistent_capture.py` | Collect many segments at one frequency from a single long-lived process (amortizes startup). | |
| 516 | +| `benchmark.py` | Measure decode throughput, sustained-rate drop behavior, and callback latency on your hardware. | |
| 517 | +| `calibrate.py` | Calibration workflow (Levels 2–3): derive an absolute-ish `offset_db` from a known reference and/or a frequency-response curve, saved to `calibration.json`. | |
| 518 | +| `collect_sample_data.py` | Collect real sample datasets into `examples/sample_data/`. | |
| 519 | + |
| 520 | +Run any of them through uv so the project environment is used, e.g. `uv run python examples/device_explorer.py`. |
| 521 | + |
463 | 522 |
|
464 | 523 | ## Method Reference |
465 | 524 |
|
@@ -488,18 +547,46 @@ The `HackRF` class is composed from mixins. Methods build a `hackrf-tools` comma |
488 | 547 | * **Signature:** `capture_stream(freq, sample_rate, **kwargs)` |
489 | 548 | * **Returns:** a context manager yielding decoded `complex64` blocks; reaps `hackrf_transfer` on exit. |
490 | 549 |
|
| 550 | +#### `capture_callback` |
| 551 | +* **Signature:** `capture_callback(freq, sample_rate, on_block, *, max_samples=None, max_blocks=None, **kwargs)` |
| 552 | +* **Returns:** total samples delivered. |
| 553 | +* **Notes:** callback-style receive — `on_block(iq, n_so_far)` fires with each decoded `complex64` block as it streams; return `False` to stop. This is the ergonomic analog of libhackrf's RX callback (the loop is inverted so calling code resembles the C-binding libraries), riding the same subprocess stream as `capture_stream`. The child is always reaped on exit. Stops on `False`, on `max_samples`/`max_blocks`, or when the source ends. |
| 554 | + |
| 555 | +#### `scan_frequencies` |
| 556 | +* **Signature:** `scan_frequencies(freqs, sample_rate, num_samples, *, on_capture=None, **kwargs)` |
| 557 | +* **Returns:** `{freq: complex64}` dict, or `None` if `on_capture` is given. |
| 558 | +* **Notes:** sequentially captures `num_samples` at each frequency, retuning between them. Makes multi-frequency capture one call. **Not gapless** — each retune is a fresh `hackrf_transfer` with a short re-open (the binary can't retune mid-stream); pass `on_capture(freq, iq)` to process-and-discard instead of holding every array. |
| 559 | + |
| 560 | +#### `open_receiver` |
| 561 | +* **Signature:** `open_receiver(freq, sample_rate, *, lna=16, vga=20, amp=False, baseband_bw=None, read_samples=131072)` |
| 562 | +* **Returns:** a `PersistentReceiver` (use as a context manager). |
| 563 | +* **Notes:** opens **one long-lived** `hackrf_transfer` you drain in segments, so you pay the ~startup cost **once** instead of per capture — the efficient path for collecting many segments over time. **Fixed-frequency by design** (the binary can't retune mid-stream); for multiple frequencies use `scan_frequencies` or `monitor_frequencies`. The receiver offers `read(n)` (exactly *n* `complex64`), `blocks()` (iterate raw decoded blocks), and `callback(on_block, max_samples=)`. The child is reaped on `__exit__` and registered on the atexit backstop. `read_samples` tunes the stdout read granularity (larger = higher sustained throughput). |
| 564 | + |
| 565 | +```python |
| 566 | +with h.open_receiver(100e6, 8e6) as rx: |
| 567 | + a = rx.read(1_000_000) # exact count |
| 568 | + b = rx.read(1_000_000) # again, NO new process spin-up |
| 569 | + for block in rx.blocks(): # or iterate decoded blocks |
| 570 | + ... |
| 571 | +``` |
| 572 | + |
491 | 573 | ### Sweep |
492 | 574 |
|
493 | 575 | #### `sweep` |
494 | 576 | * **Builds:** `hackrf_sweep -f <lo:hi MHz> -l <lna> -g <vga> -a <0|1> [-w <bin_hz>] [-1] [-N <n>]` |
495 | 577 | * **Signature:** `sweep(f_min_hz, f_max_hz, *, bin_width=None, lna=16, vga=20, amp=False, one_shot=False, num_sweeps=None, print_cmd=False)` |
496 | 578 | * **Returns:** a generator of parsed rows: `{date, time, hz_low, hz_high, bin_width, num_samples, db: [...]}`. `None` for blank/garbled lines (filtered out). |
497 | | -* **Notes:** band edges are taken in integer MHz; sub-MHz edges are snapped and a warning is printed. A single sweep over a wide band arrives as multiple rows sharing one timestamp. |
| 579 | +* **Notes:** band edges are taken in integer MHz; sub-MHz edges are snapped and a warning is printed. A single sweep over a wide band arrives as multiple rows sharing one timestamp. **Real `hackrf_sweep` does not emit segments in frequency order** — it interleaves them (e.g. 88, 98, 93, 103 MHz). To reconstruct a contiguous spectrum, group rows by timestamp and sort segments by `hz_low`; never assume the rows arrive low-to-high. |
498 | 580 |
|
499 | 581 | #### `sweep_collect` |
500 | 582 | * **Signature:** `sweep_collect(f_min_hz, f_max_hz, num_sweeps=1, **kwargs)` |
501 | 583 | * **Returns:** a list (bounded — relies on `-N` terminating the sweep). |
502 | 584 |
|
| 585 | +#### `monitor_frequencies` |
| 586 | +* **Signature:** `monitor_frequencies(freqs_hz, *, span_hz=2_000_000, duration=None, on_update=None, lna=16, vga=20, amp=False)` |
| 587 | +* **Returns:** a list of `{freq_hz: power_db}` dicts (one per sweep pass), or `None` if `on_update` is given. |
| 588 | +* **Notes:** watch **power over time** at several frequencies, backed by `hackrf_sweep`'s fast internal retuning. Deliberately separate from `scan_frequencies`: that one returns **IQ samples** (per-frequency captures); this returns **power** (spectrum bins) and never yields IQ. Use it for "is there activity on these channels?" monitoring. `on_update(update)` returning `False` stops it. |
| 589 | + |
503 | 590 | #### `sweep_stream` |
504 | 591 | * **Signature:** `sweep_stream(f_min_hz, f_max_hz, **kwargs)` |
505 | 592 | * **Returns:** a context manager around the sweep generator; reaps `hackrf_sweep` on exit. Use for live/indefinite consumers. |
@@ -585,12 +672,13 @@ Modes (`-m manual|frequency|time`), port assignment (`-a`/`-b`), frequency routi |
585 | 672 |
|
586 | 673 | ```python |
587 | 674 | h = HackRF() |
588 | | -h.clock("-r") # read clock configuration |
589 | | -h.clock("-o", "1") # enable clock output |
590 | | -h.clock("-i") # read clock input status |
| 675 | +h.clock("-r", "3") # read settings for clock 3 (CLKOUT); -r needs a clock num |
| 676 | +h.clock("-a") # read settings for ALL clocks |
| 677 | +h.clock("-i") # get CLKIN status |
| 678 | +h.clock("-o", "1") # enable CLKOUT |
591 | 679 | ``` |
592 | 680 |
|
593 | | -Refer to `hackrf_clock -h` for the supported arguments on your tools version (the flag set has varied across releases). |
| 681 | +Note that `-r` requires a clock number (e.g. `-r 3`); calling `clock("-r")` alone prints the tool's usage rather than reading anything. Refer to `hackrf_clock -h` for the full set on your tools version (it has varied across releases — newer builds add HackRF Pro P1/P2 connector signal selection). |
594 | 682 |
|
595 | 683 | #### Debug register access — `debug` |
596 | 684 |
|
@@ -634,6 +722,18 @@ The `confirm=True` requirement exists because flashing firmware is the single mo |
634 | 722 | | `parse_sweep_line(line)` (staticmethod) | one `hackrf_sweep` CSV row → dict, or `None`. | |
635 | 723 | | `parse_freq(txt)` | `"433.92M"`, `"1.09G"`, `"2.5k"`, `"100MHz"`, or plain Hz → float Hz. Module-level. | |
636 | 724 |
|
| 725 | +### Power and Relative Calibration |
| 726 | + |
| 727 | +The HackRF is **not a calibrated instrument** — its raw dBFS amplitude is relative to ADC full scale, not power at the antenna, and it depends on the gain you set. These helpers (Level 1) make readings *consistent*; turning them into approximate dBm (Levels 2–3) is a hardware-and-reference workflow shown in `examples/calibrate.py`. |
| 728 | + |
| 729 | +| Method | Purpose | |
| 730 | +|---|---| |
| 731 | +| `power_dbfs(iq)` | mean power of a `complex64` block in dBFS (≤ 0; raw, uncalibrated). | |
| 732 | +| `gain_db(lna, vga, amp)` | total RX gain through the chain in dB. The quantity that makes a raw dBFS reading ambiguous. | |
| 733 | +| `relative_power_db(iq_or_dbfs, *, lna=, vga=, amp=, offset_db=0, freq_hz=, freq_correction=)` | gain-normalized power: subtracts the gain chain so readings at *different gains are comparable*. Gains default from `last_params`. Pass `offset_db` (from a calibration run) to approximate dBm, and a `freq_correction(freq)→dB` callable to flatten the front-end response. | |
| 734 | + |
| 735 | +The key property: the **same physical signal reads the same `relative_power_db` value regardless of gain**, where raw dBFS would shift by the gain delta. The library does only pure math here — it never invents reference data or claims a dBm it can't back up. To derive `offset_db` (feed a known power) or a `freq_correction` curve (sweep a flat source), run `examples/calibrate.py`; it saves a `calibration.json` you feed back in. |
| 736 | + |
637 | 737 | ### Mode, Validation, and Feedback |
638 | 738 |
|
639 | 739 | | Method | Purpose | |
@@ -798,4 +898,4 @@ This project is licensed under the GNU General Public License v2.0 or later. See |
798 | 898 |
|
799 | 899 | The **code in this repository** is released under GPL-2.0-or-later. This licensing does NOT take priority over the official HackRF releases or the decisions of Great Scott Gadgets, and does NOT apply to their products or firmware. |
800 | 900 |
|
801 | | -This software is released **AS-IS** — there may be bugs, especially under active development. It is **UNOFFICIAL**: Great Scott Gadgets does not support, maintain, or bear responsibility for it. You are responsible for operating your hardware safely and legally, particularly when transmitting. |
| 901 | +This software is released **AS-IS** — there may be bugs, especially under active development. It is **UNOFFICIAL**: Great Scott Gadgets does not support, maintain, or bear responsibility for it. You are responsible for operating your hardware safely and legally, particularly when transmitting. |
0 commit comments