hackrfpy 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hackrfpy/__init__.py +19 -0
- hackrfpy/_commands/__init__.py +0 -0
- hackrfpy/_commands/capture.py +282 -0
- hackrfpy/_commands/device.py +204 -0
- hackrfpy/_commands/info.py +172 -0
- hackrfpy/_commands/sweep.py +219 -0
- hackrfpy/_commands/transmit.py +104 -0
- hackrfpy/_receiver.py +151 -0
- hackrfpy/_stream_ctx.py +24 -0
- hackrfpy/cli.py +292 -0
- hackrfpy/comparison.md +175 -0
- hackrfpy/constants.py +104 -0
- hackrfpy/core.py +743 -0
- hackrfpy/exceptions.py +43 -0
- hackrfpy/presets.py +68 -0
- hackrfpy/py.typed +0 -0
- hackrfpy/sigmf.py +72 -0
- hackrfpy-1.0.0.dist-info/METADATA +187 -0
- hackrfpy-1.0.0.dist-info/RECORD +22 -0
- hackrfpy-1.0.0.dist-info/WHEEL +4 -0
- hackrfpy-1.0.0.dist-info/entry_points.txt +3 -0
- hackrfpy-1.0.0.dist-info/licenses/LICENSE +339 -0
hackrfpy/cli.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
|
|
3
|
+
##--------------------------------------------------------------------\
|
|
4
|
+
# hackrfpy
|
|
5
|
+
# 'src/hackrfpy/cli.py'
|
|
6
|
+
#
|
|
7
|
+
# The CLI shell. One class owns the parser; main(args) dispatches on the
|
|
8
|
+
# subcommand. __init__(a=None) takes argv from sys.argv OR a passed list, so
|
|
9
|
+
# the same class drives from the shell or from a script:
|
|
10
|
+
# app = HackRFCLI(['rx', '-f', '433.92M', '-s', '8M', '-n', '1000000'])
|
|
11
|
+
# app.main(app.getArgs())
|
|
12
|
+
#
|
|
13
|
+
# The CLI layer is thin: it parses, resolves the persisted operating mode,
|
|
14
|
+
# instantiates HackRF once, calls the method, and maps typed exceptions to
|
|
15
|
+
# clean stderr + exit codes. All real work lives in core + mixins.
|
|
16
|
+
#
|
|
17
|
+
# Author(s): <you>
|
|
18
|
+
##--------------------------------------------------------------------\
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
_VERSION = _pkg_version("hackrfpy")
|
|
27
|
+
except PackageNotFoundError: # running from a source tree, not installed
|
|
28
|
+
_VERSION = "0+unknown"
|
|
29
|
+
|
|
30
|
+
from .core import HackRF, parse_freq
|
|
31
|
+
from .exceptions import HackRFError, HackRFValueError
|
|
32
|
+
from . import constants as C
|
|
33
|
+
from . import presets as P
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# frequency parsing now lives in core.parse_freq (single source of truth, and
|
|
37
|
+
# the typed-exception version) so library callers share it. Imported above.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---- mode state file (mode persists across CLI invocations) ------------------
|
|
41
|
+
def _print_detect(det):
|
|
42
|
+
print("hackrfpy detect")
|
|
43
|
+
print("-" * 40)
|
|
44
|
+
if det["problem"] and not det["found"]:
|
|
45
|
+
print(f" {det['problem']}")
|
|
46
|
+
return
|
|
47
|
+
print(f" boards found : {det['count']}")
|
|
48
|
+
if det["tools_version"]:
|
|
49
|
+
print(f" hackrf-tools : {det['tools_version']}")
|
|
50
|
+
if det["libhackrf_version"]:
|
|
51
|
+
print(f" libhackrf : {det['libhackrf_version']}")
|
|
52
|
+
for b in det["boards"]:
|
|
53
|
+
tag = "HackRF" if b["is_hackrf"] else "UNCONFIRMED"
|
|
54
|
+
print(f" [{b['index']}] {tag}")
|
|
55
|
+
print(f" serial : {b['serial']}")
|
|
56
|
+
print(f" name : {b['name']}")
|
|
57
|
+
print(f" firmware : {b['firmware']}"
|
|
58
|
+
+ (" (stale: <2021, streaming/-N may be unsupported)"
|
|
59
|
+
if b["firmware_stale"] else ""))
|
|
60
|
+
if det["multiple"]:
|
|
61
|
+
print(" ! multiple boards: select one with --serial <serial>")
|
|
62
|
+
for w in det.get("warnings", []):
|
|
63
|
+
print(f" ! {w}")
|
|
64
|
+
print(f" ready : {'yes' if det['ready'] else 'no'}")
|
|
65
|
+
if det["problem"] and det["found"]:
|
|
66
|
+
print(f" note : {det['problem']}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _state_path():
|
|
70
|
+
base = os.environ.get("XDG_CONFIG_HOME",
|
|
71
|
+
os.path.join(os.path.expanduser("~"), ".config"))
|
|
72
|
+
return os.path.join(base, C.STATE_DIR_NAME, C.STATE_FILE_NAME)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_mode():
|
|
76
|
+
path = _state_path()
|
|
77
|
+
try:
|
|
78
|
+
with open(path) as f:
|
|
79
|
+
for line in f:
|
|
80
|
+
if line.strip().startswith("mode"):
|
|
81
|
+
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
return C.DEFAULT_MODE
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_mode(mode):
|
|
88
|
+
path = _state_path()
|
|
89
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
90
|
+
with open(path, "w") as f:
|
|
91
|
+
f.write(f'mode = "{mode}"\n')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Subcommands whose only job is to relay to a device method with **flags.
|
|
95
|
+
def _add_common_rf(p, freq_required=True):
|
|
96
|
+
p.add_argument("-f", "--frequency", type=parse_freq, required=freq_required,
|
|
97
|
+
help="center frequency (e.g. 433.92M, 1.09G, or Hz)")
|
|
98
|
+
p.add_argument("-s", "--sample-rate", dest="sample_rate", type=parse_freq,
|
|
99
|
+
default=8e6, help="sample rate (default 8M)")
|
|
100
|
+
p.add_argument("--force", action="store_true",
|
|
101
|
+
help="downgrade out-of-range rejects to warnings")
|
|
102
|
+
p.add_argument("--print-cmd", dest="print_cmd", action="store_true",
|
|
103
|
+
help="print the hackrf_* command without running it")
|
|
104
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
105
|
+
p.add_argument("--serial", help="select a board by serial number "
|
|
106
|
+
"(multi-board setups)")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class HackRFCLI:
|
|
110
|
+
def __init__(self, a=None):
|
|
111
|
+
main_parser = argparse.ArgumentParser(prog="hrf", description="hackrfpy")
|
|
112
|
+
main_parser.add_argument("--version", action="version",
|
|
113
|
+
version=f"%(prog)s {_VERSION}")
|
|
114
|
+
sub = main_parser.add_subparsers(dest="subparser_name")
|
|
115
|
+
|
|
116
|
+
# info
|
|
117
|
+
sp = sub.add_parser("info", help="device + firmware info")
|
|
118
|
+
sp.add_argument("--raw", action="store_true")
|
|
119
|
+
sp.add_argument("-v", "--verbose", action="store_true")
|
|
120
|
+
sp.add_argument("--print-cmd", dest="print_cmd", action="store_true")
|
|
121
|
+
|
|
122
|
+
# detect
|
|
123
|
+
sp = sub.add_parser("detect",
|
|
124
|
+
help="autodetect + identify connected HackRF(s)")
|
|
125
|
+
sp.add_argument("-v", "--verbose", action="store_true")
|
|
126
|
+
|
|
127
|
+
# doctor
|
|
128
|
+
sp = sub.add_parser("doctor", help="environment / preflight check")
|
|
129
|
+
sp.add_argument("--path", default=".", help="disk to check for capture")
|
|
130
|
+
|
|
131
|
+
# mode
|
|
132
|
+
sp = sub.add_parser("mode", help="get/set operating mode (rx|tx)")
|
|
133
|
+
sp.add_argument("value", nargs="?", choices=C.MODES,
|
|
134
|
+
help="omit to print current mode")
|
|
135
|
+
|
|
136
|
+
# rx / capture (-f optional HERE because --preset can supply it;
|
|
137
|
+
# main() enforces that one of the two resolves a frequency)
|
|
138
|
+
sp = sub.add_parser("rx", help="receive IQ to a file")
|
|
139
|
+
_add_common_rf(sp, freq_required=False)
|
|
140
|
+
sp.add_argument("-o", "--out", default="capture.iq")
|
|
141
|
+
sp.add_argument("-n", "--num-samples", dest="num_samples", type=int)
|
|
142
|
+
sp.add_argument("-d", "--duration", type=float, help="seconds")
|
|
143
|
+
sp.add_argument("-l", "--lna", type=int, default=16)
|
|
144
|
+
sp.add_argument("-g", "--vga", type=int, default=20)
|
|
145
|
+
sp.add_argument("-a", "--amp", action="store_true")
|
|
146
|
+
sp.add_argument("--bias-tee", dest="bias_tee", action="store_true")
|
|
147
|
+
sp.add_argument("--bw", dest="baseband_bw", type=parse_freq)
|
|
148
|
+
sp.add_argument("--segment", dest="segment_secs", type=float,
|
|
149
|
+
help="rolling files of N seconds each")
|
|
150
|
+
sp.add_argument("--no-sigmf", dest="sigmf", action="store_false")
|
|
151
|
+
sp.add_argument("--preset", help="apply a band preset for freq/rate")
|
|
152
|
+
|
|
153
|
+
# tx
|
|
154
|
+
sp = sub.add_parser("tx", help="transmit IQ from a file (TX mode only)")
|
|
155
|
+
_add_common_rf(sp)
|
|
156
|
+
sp.add_argument("source", help="int8 I/Q file to transmit")
|
|
157
|
+
sp.add_argument("-x", "--txvga", type=int, default=20)
|
|
158
|
+
sp.add_argument("-a", "--amp", action="store_true")
|
|
159
|
+
sp.add_argument("--bias-tee", dest="bias_tee", action="store_true")
|
|
160
|
+
sp.add_argument("--bw", dest="baseband_bw", type=parse_freq)
|
|
161
|
+
sp.add_argument("-R", "--repeat", action="store_true")
|
|
162
|
+
sp.add_argument("-n", "--num-samples", dest="num_samples", type=int)
|
|
163
|
+
sp.add_argument("-d", "--duration", type=float)
|
|
164
|
+
sp.add_argument("--max-duration", dest="max_duration", type=float,
|
|
165
|
+
help="hard time cap (s), enforced even for --repeat")
|
|
166
|
+
|
|
167
|
+
# sweep
|
|
168
|
+
sp = sub.add_parser("sweep", help="spectrum sweep (CSV to stdout)")
|
|
169
|
+
sp.add_argument("--f-min", dest="f_min", type=parse_freq, required=True)
|
|
170
|
+
sp.add_argument("--f-max", dest="f_max", type=parse_freq, required=True)
|
|
171
|
+
sp.add_argument("-w", "--bin-width", dest="bin_width", type=parse_freq)
|
|
172
|
+
sp.add_argument("-l", "--lna", type=int, default=16)
|
|
173
|
+
sp.add_argument("-g", "--vga", type=int, default=20)
|
|
174
|
+
sp.add_argument("-a", "--amp", action="store_true")
|
|
175
|
+
sp.add_argument("-1", "--one-shot", dest="one_shot", action="store_true")
|
|
176
|
+
sp.add_argument("-N", "--num-sweeps", dest="num_sweeps", type=int)
|
|
177
|
+
sp.add_argument("--force", action="store_true")
|
|
178
|
+
sp.add_argument("--print-cmd", dest="print_cmd", action="store_true")
|
|
179
|
+
sp.add_argument("-v", "--verbose", action="store_true")
|
|
180
|
+
sp.add_argument("--serial", help="select a board by serial number")
|
|
181
|
+
|
|
182
|
+
# presets
|
|
183
|
+
sp = sub.add_parser("presets", help="list band presets")
|
|
184
|
+
|
|
185
|
+
self.args = main_parser.parse_args(a)
|
|
186
|
+
self._parser = main_parser
|
|
187
|
+
|
|
188
|
+
def getArgs(self):
|
|
189
|
+
return self.args
|
|
190
|
+
|
|
191
|
+
def _make_device(self, args):
|
|
192
|
+
h = HackRF(verbose=getattr(args, "verbose", False),
|
|
193
|
+
serial=getattr(args, "serial", None))
|
|
194
|
+
h.allow_out_of_spec = getattr(args, "force", False)
|
|
195
|
+
# restore persisted mode so rx/tx/sweep honor it across invocations
|
|
196
|
+
h.restore_mode(read_mode())
|
|
197
|
+
return h
|
|
198
|
+
|
|
199
|
+
def main(self, args):
|
|
200
|
+
name = args.subparser_name
|
|
201
|
+
if name is None:
|
|
202
|
+
self._parser.print_help()
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
if name == "presets":
|
|
206
|
+
for k, v in sorted(P.load_presets().items()):
|
|
207
|
+
print(f" {k:10} {v.get('desc','')}")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
if name == "mode":
|
|
211
|
+
if args.value is None:
|
|
212
|
+
print(read_mode())
|
|
213
|
+
else:
|
|
214
|
+
h = HackRF()
|
|
215
|
+
h.restore_mode(read_mode()) # so the banner fires only on a
|
|
216
|
+
h.set_mode(args.value) # real rx -> tx switch
|
|
217
|
+
write_mode(args.value)
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
h = self._make_device(args)
|
|
221
|
+
|
|
222
|
+
if name == "info":
|
|
223
|
+
res = h.info(raw=args.raw, print_cmd=args.print_cmd)
|
|
224
|
+
if not args.print_cmd:
|
|
225
|
+
print(res)
|
|
226
|
+
|
|
227
|
+
elif name == "detect":
|
|
228
|
+
det = h.detect()
|
|
229
|
+
_print_detect(det)
|
|
230
|
+
# exit non-zero when not ready, so `hrf detect && hrf rx ...` works
|
|
231
|
+
if not det["ready"]:
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
|
|
234
|
+
elif name == "doctor":
|
|
235
|
+
report = h.doctor(capture_path=args.path)
|
|
236
|
+
if report["problems"]:
|
|
237
|
+
sys.exit(1) # make preflight usable in `doctor && capture`
|
|
238
|
+
|
|
239
|
+
elif name == "rx":
|
|
240
|
+
if getattr(args, "preset", None):
|
|
241
|
+
pre = P.get_preset(args.preset)
|
|
242
|
+
if args.frequency is None:
|
|
243
|
+
args.frequency = pre.get("center") or pre.get("f_min")
|
|
244
|
+
args.sample_rate = pre.get("sample_rate", args.sample_rate)
|
|
245
|
+
if args.frequency is None:
|
|
246
|
+
raise HackRFValueError(
|
|
247
|
+
"rx needs a frequency: pass -f/--frequency or --preset")
|
|
248
|
+
h.capture(args.frequency, args.sample_rate, out=args.out,
|
|
249
|
+
num_samples=args.num_samples, duration=args.duration,
|
|
250
|
+
lna=args.lna, vga=args.vga, amp=args.amp,
|
|
251
|
+
bias_tee=args.bias_tee, baseband_bw=args.baseband_bw,
|
|
252
|
+
sigmf=args.sigmf, segment_secs=args.segment_secs,
|
|
253
|
+
print_cmd=args.print_cmd)
|
|
254
|
+
|
|
255
|
+
elif name == "tx":
|
|
256
|
+
h.transmit(args.frequency, args.sample_rate, args.source,
|
|
257
|
+
txvga=args.txvga, amp=args.amp, bias_tee=args.bias_tee,
|
|
258
|
+
baseband_bw=args.baseband_bw, repeat=args.repeat,
|
|
259
|
+
num_samples=args.num_samples, duration=args.duration,
|
|
260
|
+
max_duration=args.max_duration,
|
|
261
|
+
print_cmd=args.print_cmd)
|
|
262
|
+
|
|
263
|
+
elif name == "sweep":
|
|
264
|
+
gen = h.sweep(args.f_min, args.f_max, bin_width=args.bin_width,
|
|
265
|
+
lna=args.lna, vga=args.vga, amp=args.amp,
|
|
266
|
+
one_shot=args.one_shot, num_sweeps=args.num_sweeps,
|
|
267
|
+
print_cmd=args.print_cmd)
|
|
268
|
+
if gen is not None:
|
|
269
|
+
# CSV to stdout, as documented -- same shape hackrf_sweep
|
|
270
|
+
# emits, so it round-trips through parse_sweep_line and pipes
|
|
271
|
+
# cleanly into other tools.
|
|
272
|
+
for row in gen:
|
|
273
|
+
print(", ".join(
|
|
274
|
+
[row["date"], row["time"],
|
|
275
|
+
str(row["hz_low"]), str(row["hz_high"]),
|
|
276
|
+
f"{row['bin_width']:.2f}", str(row["num_samples"])]
|
|
277
|
+
+ [f"{d:.2f}" for d in row["db"]]))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def main():
|
|
281
|
+
app = HackRFCLI()
|
|
282
|
+
try:
|
|
283
|
+
app.main(app.getArgs())
|
|
284
|
+
except HackRFError as e:
|
|
285
|
+
print(f"error: {e}", file=sys.stderr)
|
|
286
|
+
sys.exit(e.exit_code)
|
|
287
|
+
except KeyboardInterrupt:
|
|
288
|
+
sys.exit(130)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
main()
|
hackrfpy/comparison.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# comparison.md — hackrfpy vs other Python HackRF libraries
|
|
2
|
+
|
|
3
|
+
An honest look at where this library sits among the Python options for the
|
|
4
|
+
HackRF One. The goal is **not** to claim it's the best at everything — each of
|
|
5
|
+
these makes a different, defensible tradeoff. The goal is to identify the niche
|
|
6
|
+
hackrfpy fills and be clear about what it gives up to fill it.
|
|
7
|
+
|
|
8
|
+
## The one decision that separates them
|
|
9
|
+
|
|
10
|
+
Every Python HackRF library makes a single architectural choice that determines
|
|
11
|
+
almost everything else: **bind `libhackrf` (the C library) or wrap the
|
|
12
|
+
`hackrf-tools` binaries (the command-line programs).**
|
|
13
|
+
|
|
14
|
+
* **libhackrf binders** (`python_hackrf`, `pyhackrf2`, `pyhackrf`, the various
|
|
15
|
+
`py-hackrf-ctypes` / `pylibhackrf` projects) call the C library's functions
|
|
16
|
+
directly — through ctypes, Cython, or a C extension. They get low-level
|
|
17
|
+
control and real-time sample callbacks, at the cost of a compiler/headers
|
|
18
|
+
dependency and a callback-based programming model.
|
|
19
|
+
* **hackrfpy wraps the binaries.** It runs `hackrf_info`, `hackrf_transfer`,
|
|
20
|
+
`hackrf_sweep`, etc. as subprocesses and manages their I/O and lifecycle. It
|
|
21
|
+
gives up real-time callback control and adds subprocess overhead, and in
|
|
22
|
+
exchange gets a dependency-free install, full tool coverage, and a place to
|
|
23
|
+
put a validation/safety layer.
|
|
24
|
+
|
|
25
|
+
Almost every difference below traces back to this one fork.
|
|
26
|
+
|
|
27
|
+
## The landscape
|
|
28
|
+
|
|
29
|
+
| Library | Approach | Install | TX | Sweep | Device mgmt (clock/spiflash/etc.) | Maintained |
|
|
30
|
+
|---|---|---|---|---|---|---|
|
|
31
|
+
| **hackrfpy** (this) | wraps **binaries** | `pip` + binaries on system; no compiler | yes (gated) | yes | **all 8 tools** | active |
|
|
32
|
+
| **python_hackrf** | Cython → libhackrf | `pip` + compiler + libhackrf headers/libusb | yes | yes | info, sweep, operacake, transfer only | active, most complete binder |
|
|
33
|
+
| **pyhackrf2** | Python → libhackrf (ctypes) | `pip` + libusb/libhackrf | yes | yes | minimal | moderate |
|
|
34
|
+
| **pyhackrf** (dressel/4thel00z) | ctypes → libhackrf | `pip` + libusb/fftw | partial | via callback | minimal | stale |
|
|
35
|
+
| **py-hackrf-ctypes / pylibhackrf** | ctypes / C ext → libhackrf | manual build | varies | via callback | minimal | stale/experimental |
|
|
36
|
+
|
|
37
|
+
(Approaches verified against each project's own PyPI/GitHub description; feature
|
|
38
|
+
columns reflect what each documents, not exhaustive testing.)
|
|
39
|
+
|
|
40
|
+
## What the libhackrf binders do well (and where hackrfpy now stands)
|
|
41
|
+
|
|
42
|
+
Being honest about the architectural tradeoff, and what has and hasn't been narrowed:
|
|
43
|
+
|
|
44
|
+
* **Real-time sample callbacks.** *Gap narrowed.* The binders expose libhackrf's
|
|
45
|
+
callback model — register a function that fires as buffers arrive. hackrfpy
|
|
46
|
+
now offers the same *ergonomics* via `capture_callback(freq, rate, on_block)`:
|
|
47
|
+
your function is called with each decoded `complex64` block as it streams, and
|
|
48
|
+
returns `False` to stop. The mechanism differs (it rides the subprocess stream
|
|
49
|
+
rather than a libhackrf USB-thread callback), so it is not zero-copy — but the
|
|
50
|
+
measured block-delivery latency is low: on a laptop at 20 Msps, median ~0.04 ms
|
|
51
|
+
between blocks with ~0.5 ms jitter (`examples/benchmark.py`). So *when data is
|
|
52
|
+
flowing*, the "latency floor is the pipe" concern is sub-millisecond; the real
|
|
53
|
+
constraint at top rates is throughput (above), not per-block latency.
|
|
54
|
+
* **Lower overhead at high rates.** *Gap narrowed; measured, with an honest
|
|
55
|
+
ceiling.* The decode path was optimized to construct `complex64` directly
|
|
56
|
+
(≈3× faster; benchmarked at ~190–390 MB/s depending on machine, i.e. 5–10× the
|
|
57
|
+
40 MB/s the device produces at 20 Msps — so **decode is not the bottleneck**).
|
|
58
|
+
The remaining cost is the subprocess + kernel pipe. Measured on a laptop with a
|
|
59
|
+
shared USB bus (`examples/benchmark.py`): a 20 Msps capture received **100% of
|
|
60
|
+
samples with no bulk loss** and only a single startup-transient shortfall over
|
|
61
|
+
120M samples, but the consumer drained the pipe at a **steady-state ~14 Msps**,
|
|
62
|
+
so the capture ran slower than real-time (the pipe back-pressures and
|
|
63
|
+
`hackrf_transfer` blocks rather than dropping). The practical reading: for
|
|
64
|
+
*capture-to-file or batch* work you lose nothing; for a *live real-time*
|
|
65
|
+
consumer at the very top of the rate range, the pipe drain is a real ceiling a
|
|
66
|
+
binder avoids. There is also a fixed ~1–2 s per-capture **startup cost**
|
|
67
|
+
(process launch + USB setup + device settle) the in-process binders don't pay.
|
|
68
|
+
These numbers are machine-specific — run the benchmark on your own setup.
|
|
69
|
+
* **Fine-grained mid-stream control.** *Genuine remaining gap.* `hackrf_transfer`
|
|
70
|
+
takes one frequency and runs until stopped; it cannot retune mid-stream, so
|
|
71
|
+
hackrfpy cannot either — that is the binary's limit, not a missing feature.
|
|
72
|
+
What hackrfpy adds is `scan_frequencies([...], rate, n)` to make sequential
|
|
73
|
+
multi-frequency capture a single clean call, but each retune is still a fresh
|
|
74
|
+
process with a short re-open gap. **True gapless retuning needs the C library**
|
|
75
|
+
— if that's your requirement, use a binder.
|
|
76
|
+
* **Android.** `python_hackrf` documents an Android build path. hackrfpy targets
|
|
77
|
+
desktop OSes. Unchanged.
|
|
78
|
+
|
|
79
|
+
If your work is "process every sample in real time with the lowest possible
|
|
80
|
+
latency, retuning on the fly," a libhackrf binder — `python_hackrf` is the most
|
|
81
|
+
complete and actively maintained — is still the better fit. The gap is now
|
|
82
|
+
narrower (callback ergonomics and fast decode), but the architectural ceiling is
|
|
83
|
+
real and this document won't pretend otherwise.
|
|
84
|
+
|
|
85
|
+
## What hackrfpy does that the others don't
|
|
86
|
+
|
|
87
|
+
The niche, stated plainly:
|
|
88
|
+
|
|
89
|
+
* **Dependency-free install, especially on Windows.** Because it wraps the
|
|
90
|
+
binaries, there is no C compiler, no `libhackrf.h`, no `libusb` headers, no
|
|
91
|
+
`PYTHON_HACKRF_CFLAGS`/`LDFLAGS` to set. The binders all require a build
|
|
92
|
+
toolchain and the native headers present at install time — the exact friction
|
|
93
|
+
that makes HackRF-in-Python painful on Windows. hackrfpy needs only the
|
|
94
|
+
`hackrf-tools` binaries (which ship prebuilt) and pip. This is the single
|
|
95
|
+
biggest practical differentiator.
|
|
96
|
+
* **Complete tool coverage.** hackrfpy wraps **all eight** `hackrf-tools`
|
|
97
|
+
binaries, including the ones the binders explicitly skip. `python_hackrf`'s
|
|
98
|
+
own docs list `hackrf_clock`, `hackrf_cpldjtag`, `hackrf_debug`, and
|
|
99
|
+
`hackrf_spiflash` as "Will not be implemented." hackrfpy wraps clock, debug,
|
|
100
|
+
and operacake as full argument passthroughs and provides brick-guarded
|
|
101
|
+
spiflash/cpldjtag. If you need clock configuration or firmware operations from
|
|
102
|
+
Python, this is currently the option.
|
|
103
|
+
* **A validation / safety layer.** None of the binders police your inputs — they
|
|
104
|
+
pass values straight to libhackrf. hackrfpy adds a reject-by-default operating
|
|
105
|
+
envelope (catch a 60 GHz typo for 6 GHz), gain snapping with honest
|
|
106
|
+
notification, a sub-recommended-sample-rate warning, an explicit RX/TX mode
|
|
107
|
+
gate so you can't transmit by accident, a TX gain ceiling against
|
|
108
|
+
order-of-magnitude fat-fingers, and a `max_duration` dead-man for open-ended
|
|
109
|
+
transmits. This is a deliberate "help you not break your hardware or the law"
|
|
110
|
+
layer that a thin binding has no place to put.
|
|
111
|
+
* **Lifecycle safety for scripts.** Clean child reaping on `KeyboardInterrupt`,
|
|
112
|
+
context managers (`capture_stream`, `sweep_stream`) that guarantee the radio
|
|
113
|
+
is stopped on exit, and an `atexit` backstop so a dying script doesn't leave a
|
|
114
|
+
transmitter running. These matter precisely because subprocess management is
|
|
115
|
+
the risk hackrfpy takes on — so it's where the engineering went.
|
|
116
|
+
* **A real CLI.** `hrf` is a first-class command-line tool (`hrf detect`,
|
|
117
|
+
`hrf rx`, `hrf sweep`, `hrf doctor`), not just a library. The binders are
|
|
118
|
+
libraries first; `python_hackrf` has a CLI but centered on info/sweep.
|
|
119
|
+
* **Self-describing captures.** SigMF sidecar metadata written automatically, so
|
|
120
|
+
a recording carries its frequency/rate/gains instead of being a headerless
|
|
121
|
+
int8 blob.
|
|
122
|
+
* **Hardware-validated parsing.** The parsers are tested against frozen verbatim
|
|
123
|
+
output from a real HackRF One (info, sweep, IQ), including real quirks like
|
|
124
|
+
out-of-order sweep segments and continuation-line fields.
|
|
125
|
+
|
|
126
|
+
## Honest weaknesses
|
|
127
|
+
|
|
128
|
+
Where hackrfpy is genuinely behind, beyond the architectural give-ups above:
|
|
129
|
+
|
|
130
|
+
* **Younger and fewer users** than the established binders. It is validated
|
|
131
|
+
against a real HackRF One (hardware-marked tests pass; parsers are pinned to
|
|
132
|
+
verbatim device output), but it has far less field exposure across diverse
|
|
133
|
+
setups, firmware versions, and edge cases than libraries that have been in
|
|
134
|
+
wide use for years. Maturity is about breadth of real-world exposure, and
|
|
135
|
+
that takes time and users.
|
|
136
|
+
* **Subprocess dependence on tool behavior.** It relies on the `hackrf-tools`
|
|
137
|
+
stdout format and exit codes, which it parses and version-checks — but a
|
|
138
|
+
future tools change could require parser updates. A binder against the stable
|
|
139
|
+
C ABI is insulated from that.
|
|
140
|
+
* **No real-time callback path.** Restated because it's the real ceiling: if you
|
|
141
|
+
outgrow "stream blocks from a subprocess," the architecture won't follow you
|
|
142
|
+
into zero-latency callback territory without becoming a different library.
|
|
143
|
+
* **Not a DSP library.** By design — it yields `complex64` and stops. The binders
|
|
144
|
+
don't do DSP either, but their callback model sits closer to where you'd build
|
|
145
|
+
it.
|
|
146
|
+
|
|
147
|
+
## Who should use which
|
|
148
|
+
|
|
149
|
+
* **Use a libhackrf binder (`python_hackrf` first) if:** you need real-time
|
|
150
|
+
per-buffer callbacks, minimum latency at high sample rates, mid-stream
|
|
151
|
+
retuning, Android support, or you're already in a compiled-toolchain
|
|
152
|
+
environment where the build dependency is free.
|
|
153
|
+
* **Use hackrfpy if:** you want a clean `pip` install with no compiler
|
|
154
|
+
(especially on Windows), a scriptable + CLI workflow, full access to *all* the
|
|
155
|
+
hackrf tools including clock/debug/firmware, a safety/validation layer that
|
|
156
|
+
guards against expensive mistakes, robust script lifecycle (clean stop, no
|
|
157
|
+
orphaned transmitters), and self-describing recordings — and your data path is
|
|
158
|
+
"capture/sweep to file or to `complex64`," not real-time callback DSP.
|
|
159
|
+
|
|
160
|
+
## The one-line version
|
|
161
|
+
|
|
162
|
+
The other libraries are **C bindings** that give you low-level, real-time
|
|
163
|
+
control at the cost of a build toolchain. hackrfpy is a **binary wrapper** that
|
|
164
|
+
gives you a dependency-free install, complete tool coverage, and a safety layer.
|
|
165
|
+
It now matches the binders' callback *ergonomics* (`capture_callback`) and has a
|
|
166
|
+
fast decode path, so the usability gap is small; the remaining hard difference
|
|
167
|
+
is true gapless mid-stream retuning, which needs the C library. Different tools
|
|
168
|
+
for different jobs; the niche is "scriptable HackRF control that installs cleanly
|
|
169
|
+
on Windows and tries to keep you from breaking things."
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
*Landscape current as of mid-2026; library capabilities and maintenance status
|
|
174
|
+
change. Verify against each project's current PyPI/GitHub before relying on a
|
|
175
|
+
specific claim. Feature columns reflect each project's own documentation.*
|
hackrfpy/constants.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
|
|
3
|
+
##--------------------------------------------------------------------\
|
|
4
|
+
# hackrfpy
|
|
5
|
+
# 'src/hackrfpy/constants.py'
|
|
6
|
+
#
|
|
7
|
+
# Single source of truth for the HackRF One operating envelope: hard
|
|
8
|
+
# frequency / sample-rate ranges, the stepped gain tables, the discrete
|
|
9
|
+
# baseband-filter bandwidths, and library defaults. EVERYTHING that is a
|
|
10
|
+
# "limit" lives here so that:
|
|
11
|
+
# 1. there is exactly one place to edit as hardware/firmware evolves,
|
|
12
|
+
# 2. the validation code and the test suite import the SAME numbers and
|
|
13
|
+
# therefore can never silently drift apart.
|
|
14
|
+
#
|
|
15
|
+
# WARNING: these describe the LIBRARY's checking envelope, not live device
|
|
16
|
+
# state. Editing them changes what the library will accept/reject; it does
|
|
17
|
+
# not change anything on the board.
|
|
18
|
+
#
|
|
19
|
+
# Author(s): <you>
|
|
20
|
+
##--------------------------------------------------------------------\
|
|
21
|
+
|
|
22
|
+
# ---- hard ranges (reject-by-default; --force / allow_out_of_spec downgrades
|
|
23
|
+
# a reject to a warning) -------------------------------------------------
|
|
24
|
+
# HackRF One tunes 1 MHz - 6 GHz. Values outside this are almost always a
|
|
25
|
+
# fat-finger (typing 60e9 for 6e9), which is exactly what reject-by-default
|
|
26
|
+
# is meant to catch.
|
|
27
|
+
FREQ_MIN_HZ = 1_000_000 # 1 MHz
|
|
28
|
+
FREQ_MAX_HZ = 6_000_000_000 # 6 GHz
|
|
29
|
+
|
|
30
|
+
# Usable sample rates. The device technically accepts 2-20 Msps; 8-20 is the
|
|
31
|
+
# commonly recommended band. We reject outside 2-20 and warn below 8.
|
|
32
|
+
SR_MIN = 2_000_000 # 2 Msps
|
|
33
|
+
SR_MAX = 20_000_000 # 20 Msps
|
|
34
|
+
SR_WARN_BELOW = 8_000_000 # warn (don't reject) under 8 Msps
|
|
35
|
+
|
|
36
|
+
# ---- stepped gains (snap-and-notify, round DOWN to the honest device value) -
|
|
37
|
+
# Each entry: (min_db, max_db, step_db). The silicon rounds to a step anyway,
|
|
38
|
+
# so snapping is just telling the truth about what you'll actually get.
|
|
39
|
+
LNA_GAIN = (0, 40, 8) # IF gain (MAX2837)
|
|
40
|
+
VGA_GAIN = (0, 62, 2) # baseband RX gain
|
|
41
|
+
TXVGA_GAIN = (0, 47, 1) # TX IF gain
|
|
42
|
+
|
|
43
|
+
# Front-end amplifier is a fixed ~14 dB block, on/off only.
|
|
44
|
+
AMP_DB = 14
|
|
45
|
+
|
|
46
|
+
# ---- TX safety ceilings (default at true device max so an order-of-magnitude
|
|
47
|
+
# typo rejects; tighten these for a specific bench) ----------------------
|
|
48
|
+
TX_VGA_CEILING_DB = TXVGA_GAIN[1] # 47; lower this to cap output on a bench
|
|
49
|
+
TX_AMP_ALLOWED = True # set False to forbid the 14 dB amp on TX
|
|
50
|
+
|
|
51
|
+
# ---- discrete baseband filter bandwidths (Hz), MAX2837 supported set --------
|
|
52
|
+
# hackrf_transfer auto-selects one if unset; we replicate a sensible default
|
|
53
|
+
# (~0.75 x sample_rate, snapped to nearest supported) and snap explicit values.
|
|
54
|
+
BASEBAND_FILTER_BW_HZ = (
|
|
55
|
+
1_750_000, 2_500_000, 3_500_000, 5_000_000, 5_500_000, 6_000_000,
|
|
56
|
+
7_000_000, 8_000_000, 9_000_000, 10_000_000, 12_000_000, 14_000_000,
|
|
57
|
+
15_000_000, 20_000_000, 24_000_000, 28_000_000,
|
|
58
|
+
)
|
|
59
|
+
BASEBAND_AUTO_FRACTION = 0.75 # default BW as a fraction of sample rate
|
|
60
|
+
|
|
61
|
+
# ---- sample format ----------------------------------------------------------
|
|
62
|
+
# HackRF native capture format: interleaved signed 8-bit I, Q, I, Q ...
|
|
63
|
+
BYTES_PER_SAMPLE = 2 # one int8 I + one int8 Q
|
|
64
|
+
IQ_DTYPE = "int8"
|
|
65
|
+
|
|
66
|
+
# ---- disk / capture lifecycle ----------------------------------------------
|
|
67
|
+
# Used by the capture estimator + guard. Bytes/sec = sample_rate * 2.
|
|
68
|
+
DEFAULT_DISK_HEADROOM = 0.95 # refuse if capture would exceed 95% of free
|
|
69
|
+
DEFAULT_SEGMENT_SECONDS = None # None = single file; set for rolling segments
|
|
70
|
+
|
|
71
|
+
# ---- external binaries ------------------------------------------------------
|
|
72
|
+
# Resolved via shutil.which by default; override the directory in config for
|
|
73
|
+
# Windows installs where hackrf-tools aren't on PATH.
|
|
74
|
+
TOOLS = {
|
|
75
|
+
"info": "hackrf_info",
|
|
76
|
+
"transfer": "hackrf_transfer",
|
|
77
|
+
"sweep": "hackrf_sweep",
|
|
78
|
+
"clock": "hackrf_clock",
|
|
79
|
+
"spiflash": "hackrf_spiflash",
|
|
80
|
+
"operacake": "hackrf_operacake",
|
|
81
|
+
"cpldjtag": "hackrf_cpldjtag",
|
|
82
|
+
"debug": "hackrf_debug",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Tools that accept `-d <serial>` for multi-board selection. hackrf_info
|
|
86
|
+
# always lists every board and hackrf_cpldjtag predates the convention, so
|
|
87
|
+
# both are excluded; HackRF(serial=...) injects -d for the rest.
|
|
88
|
+
TOOLS_WITH_SERIAL = ("transfer", "sweep", "clock", "spiflash",
|
|
89
|
+
"operacake", "debug")
|
|
90
|
+
|
|
91
|
+
# The minimum set required for the common capture/sweep workflow. doctor()
|
|
92
|
+
# treats only these as hard problems when missing; the rest are optional
|
|
93
|
+
# device-management extras that not every install ships.
|
|
94
|
+
CORE_TOOLS = ("info", "transfer", "sweep")
|
|
95
|
+
|
|
96
|
+
# ---- operating modes --------------------------------------------------------
|
|
97
|
+
MODE_RX = "rx"
|
|
98
|
+
MODE_TX = "tx"
|
|
99
|
+
MODES = (MODE_RX, MODE_TX)
|
|
100
|
+
DEFAULT_MODE = MODE_RX
|
|
101
|
+
|
|
102
|
+
# CLI state-file location (mode persists across invocations here).
|
|
103
|
+
STATE_DIR_NAME = "hackrfpy"
|
|
104
|
+
STATE_FILE_NAME = "state.toml"
|