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/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
##--------------------------------------------------------------------\
|
|
3
|
+
# hackrfpy 'src/hackrfpy/__init__.py'
|
|
4
|
+
# Public API surface.
|
|
5
|
+
##--------------------------------------------------------------------\
|
|
6
|
+
from .core import HackRF, load_iq, parse_freq
|
|
7
|
+
from .sigmf import write_sigmf_meta, read_sigmf_meta
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
HackRFError, HackRFValueError, HackRFModeError,
|
|
10
|
+
HackRFDeviceError, HackRFEnvironmentError,
|
|
11
|
+
)
|
|
12
|
+
from . import constants
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"HackRF", "load_iq", "parse_freq", "constants",
|
|
16
|
+
"write_sigmf_meta", "read_sigmf_meta",
|
|
17
|
+
"HackRFError", "HackRFValueError", "HackRFModeError",
|
|
18
|
+
"HackRFDeviceError", "HackRFEnvironmentError",
|
|
19
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
|
|
3
|
+
##--------------------------------------------------------------------\
|
|
4
|
+
# hackrfpy
|
|
5
|
+
# 'src/hackrfpy/_commands/capture.py'
|
|
6
|
+
#
|
|
7
|
+
# CaptureMixin: receive via hackrf_transfer -r. Exercises all consumption
|
|
8
|
+
# modes and the IQ-path choice:
|
|
9
|
+
# - num_samples -> bounded (default), mode="blocking"
|
|
10
|
+
# - duration -> timed, mode="timed"
|
|
11
|
+
# - neither -> open-ended handle, mode="handle" (.stop() to end)
|
|
12
|
+
# - to_stdout -> '-r -' streamed + decoded in-Python (live path)
|
|
13
|
+
# - segment_secs -> rolling files (capture lifecycle the CLI binary lacks)
|
|
14
|
+
#
|
|
15
|
+
# Mixin only; assumes _run, validate_rx, estimate_capture, decode_iq,
|
|
16
|
+
# _auto_baseband, require_mode, print_message on the host class.
|
|
17
|
+
#
|
|
18
|
+
# Author(s): <you>
|
|
19
|
+
##--------------------------------------------------------------------\
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
from .. import constants as C
|
|
24
|
+
from ..sigmf import write_sigmf_meta
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CaptureMixin:
|
|
28
|
+
def capture(self, freq, sample_rate, *, out="capture.iq",
|
|
29
|
+
num_samples=None, duration=None,
|
|
30
|
+
lna=16, vga=20, amp=False, bias_tee=False,
|
|
31
|
+
baseband_bw=None, to_stdout=False, sigmf=True,
|
|
32
|
+
segment_secs=None, print_cmd=False):
|
|
33
|
+
self.require_mode(C.MODE_RX)
|
|
34
|
+
freq, sample_rate, lna, vga = self.validate_rx(freq, sample_rate, lna, vga)
|
|
35
|
+
bw = self._auto_baseband(sample_rate, baseband_bw)
|
|
36
|
+
self._record_params(freq=freq, sample_rate=sample_rate, lna=lna,
|
|
37
|
+
vga=vga, amp=amp, baseband_bw=bw, mode="rx")
|
|
38
|
+
self.print_message(f"[*] mode: {self.mode}")
|
|
39
|
+
|
|
40
|
+
# segmented capture is its own lifecycle path
|
|
41
|
+
if segment_secs:
|
|
42
|
+
return self._capture_segmented(
|
|
43
|
+
freq, sample_rate, out, segment_secs, lna, vga, amp,
|
|
44
|
+
bias_tee, bw, sigmf, print_cmd)
|
|
45
|
+
|
|
46
|
+
target = "-" if to_stdout else out
|
|
47
|
+
argv = self._rx_argv(freq, sample_rate, target, lna, vga, amp,
|
|
48
|
+
bias_tee, bw, num_samples)
|
|
49
|
+
|
|
50
|
+
if to_stdout:
|
|
51
|
+
if print_cmd:
|
|
52
|
+
self._run(argv, mode="blocking", print_cmd=True)
|
|
53
|
+
return None
|
|
54
|
+
# live path: stream raw IQ chunks, decode to complex64 as we go.
|
|
55
|
+
# An odd-length chunk would split an I/Q pair; dropping the byte
|
|
56
|
+
# would swap I/Q for everything after, so carry it forward.
|
|
57
|
+
# The inner stream generator is closed EXPLICITLY on the way out;
|
|
58
|
+
# leaving it to GC can orphan a receiving hackrf_transfer after
|
|
59
|
+
# the caller breaks out of the loop.
|
|
60
|
+
def _gen():
|
|
61
|
+
tail = b""
|
|
62
|
+
inner = self._run(argv, mode="stream")
|
|
63
|
+
try:
|
|
64
|
+
for chunk in inner:
|
|
65
|
+
buf = tail + chunk
|
|
66
|
+
cut = len(buf) - (len(buf) % 2)
|
|
67
|
+
buf, tail = buf[:cut], buf[cut:]
|
|
68
|
+
if buf:
|
|
69
|
+
yield self.decode_iq(buf)
|
|
70
|
+
finally:
|
|
71
|
+
inner.close()
|
|
72
|
+
return _gen()
|
|
73
|
+
|
|
74
|
+
# file path: estimate + guard disk before committing
|
|
75
|
+
if not print_cmd:
|
|
76
|
+
est = self.estimate_capture(sample_rate, num_samples, duration,
|
|
77
|
+
os.path.dirname(out) or ".")
|
|
78
|
+
self.print_message(
|
|
79
|
+
f"[*] ~{(est['total_bytes'] or 0)/1e6:.1f} MB, "
|
|
80
|
+
f"{est['bytes_per_sec']/1e6:.1f} MB/s")
|
|
81
|
+
|
|
82
|
+
if duration is not None:
|
|
83
|
+
res = self._run(argv, mode="timed", duration=duration,
|
|
84
|
+
print_cmd=print_cmd)
|
|
85
|
+
elif num_samples is not None:
|
|
86
|
+
res = self._run(argv, mode="blocking", print_cmd=print_cmd)
|
|
87
|
+
else:
|
|
88
|
+
# open-ended: hand back a controller; caller .stop()s it
|
|
89
|
+
if print_cmd:
|
|
90
|
+
self._run(argv, mode="blocking", print_cmd=True)
|
|
91
|
+
return None
|
|
92
|
+
return self._run(argv, mode="handle")
|
|
93
|
+
|
|
94
|
+
if sigmf and not print_cmd:
|
|
95
|
+
write_sigmf_meta(out, freq, sample_rate, lna=lna, vga=vga,
|
|
96
|
+
amp=amp, datatype="ci8")
|
|
97
|
+
return res
|
|
98
|
+
|
|
99
|
+
# ---- aliases ----
|
|
100
|
+
def rx(self, *a, **k):
|
|
101
|
+
return self.capture(*a, **k)
|
|
102
|
+
|
|
103
|
+
def capture_samples(self, freq, sample_rate, num_samples, **k):
|
|
104
|
+
return self.capture(freq, sample_rate, num_samples=num_samples, **k)
|
|
105
|
+
|
|
106
|
+
def capture_seconds(self, freq, sample_rate, duration, **k):
|
|
107
|
+
return self.capture(freq, sample_rate, duration=duration, **k)
|
|
108
|
+
|
|
109
|
+
def scan_frequencies(self, freqs, sample_rate, num_samples, *,
|
|
110
|
+
on_capture=None, **k):
|
|
111
|
+
# Sequentially capture a fixed sample count at each frequency in
|
|
112
|
+
# `freqs`, retuning between them. This does NOT close the gapless-
|
|
113
|
+
# retune gap (each retune is a fresh hackrf_transfer with a short
|
|
114
|
+
# re-open, since the binary can't retune mid-stream) -- but it makes
|
|
115
|
+
# the common "look at several frequencies in one pass" workflow a
|
|
116
|
+
# single call instead of hand-rolled orchestration.
|
|
117
|
+
#
|
|
118
|
+
# results = h.scan_frequencies([433.92e6, 868e6, 915e6], 8e6, 1e6)
|
|
119
|
+
# # -> {freq: complex64 array}
|
|
120
|
+
#
|
|
121
|
+
# on_capture(freq, iq) is called per frequency if given (e.g. to
|
|
122
|
+
# process-and-discard instead of holding every array in memory).
|
|
123
|
+
results = {}
|
|
124
|
+
for f in freqs:
|
|
125
|
+
iq = self.capture_array(f, sample_rate, int(num_samples), **k)
|
|
126
|
+
if on_capture is not None:
|
|
127
|
+
on_capture(f, iq)
|
|
128
|
+
else:
|
|
129
|
+
results[f] = iq
|
|
130
|
+
return results if on_capture is None else None
|
|
131
|
+
|
|
132
|
+
# ---- in-memory + context-managed entry points ----
|
|
133
|
+
def capture_array(self, freq, sample_rate, num_samples, *,
|
|
134
|
+
return_params=False, **k):
|
|
135
|
+
# Scripting entry point: return EXACTLY num_samples complex64 samples
|
|
136
|
+
# in RAM, no file. Built on the stdout-stream path so it shares the
|
|
137
|
+
# odd-byte carry + clean-reap logic. The stream is closed as soon as
|
|
138
|
+
# we have enough, which interrupts hackrf_transfer promptly.
|
|
139
|
+
#
|
|
140
|
+
# return_params=True -> (iq, params) where params is the snapped
|
|
141
|
+
# last_params dict; default False keeps the bare-ndarray return so the
|
|
142
|
+
# common case is unchanged.
|
|
143
|
+
import numpy as np
|
|
144
|
+
if num_samples is None or num_samples <= 0:
|
|
145
|
+
from ..exceptions import HackRFValueError
|
|
146
|
+
raise HackRFValueError("capture_array needs a positive num_samples")
|
|
147
|
+
k.pop("to_stdout", None)
|
|
148
|
+
k.pop("out", None)
|
|
149
|
+
gen = self.capture(freq, sample_rate, to_stdout=True, **k)
|
|
150
|
+
if gen is None: # print_cmd dry run
|
|
151
|
+
return None
|
|
152
|
+
blocks, have = [], 0
|
|
153
|
+
try:
|
|
154
|
+
for block in gen:
|
|
155
|
+
blocks.append(block)
|
|
156
|
+
have += len(block)
|
|
157
|
+
if have >= num_samples:
|
|
158
|
+
break
|
|
159
|
+
finally:
|
|
160
|
+
gen.close() # reap the child deterministically
|
|
161
|
+
iq = (np.empty(0, dtype=np.complex64) if not blocks
|
|
162
|
+
else np.concatenate(blocks)[:num_samples])
|
|
163
|
+
if return_params:
|
|
164
|
+
return iq, self.last_params
|
|
165
|
+
return iq
|
|
166
|
+
|
|
167
|
+
def open_receiver(self, freq, sample_rate, *, lna=16, vga=20, amp=False,
|
|
168
|
+
baseband_bw=None, read_samples=131072):
|
|
169
|
+
# Open a PERSISTENT fixed-frequency receiver: one long-lived
|
|
170
|
+
# hackrf_transfer you drain in segments over time, so you don't pay the
|
|
171
|
+
# ~1-2 s process spin-up per capture. Use as a context manager:
|
|
172
|
+
#
|
|
173
|
+
# with h.open_receiver(100e6, 8e6) as rx:
|
|
174
|
+
# a = rx.read(1_000_000) # exact sample count
|
|
175
|
+
# b = rx.read(1_000_000) # again, no new process
|
|
176
|
+
# for blk in rx.blocks(): ... # or raw decoded blocks
|
|
177
|
+
#
|
|
178
|
+
# FIXED-FREQUENCY by design -- hackrf_transfer can't retune mid-stream.
|
|
179
|
+
# For multiple frequencies use scan_frequencies (IQ per freq) or
|
|
180
|
+
# monitor_frequencies (sweep-backed power). The child is reaped on exit
|
|
181
|
+
# and registered on the atexit backstop.
|
|
182
|
+
from .._receiver import PersistentReceiver
|
|
183
|
+
return PersistentReceiver(self, freq, sample_rate, lna=lna, vga=vga,
|
|
184
|
+
amp=amp, baseband_bw=baseband_bw,
|
|
185
|
+
read_samples=read_samples)
|
|
186
|
+
|
|
187
|
+
def capture_stream(self, freq, sample_rate, **k):
|
|
188
|
+
# Context manager wrapping the live stdout stream so the receiving
|
|
189
|
+
# hackrf_transfer is ALWAYS reaped on exit, even on exception:
|
|
190
|
+
# with h.capture_stream(433.92e6, 8e6) as blocks:
|
|
191
|
+
# for iq in blocks: ...
|
|
192
|
+
from .._stream_ctx import StreamCtx
|
|
193
|
+
k.pop("to_stdout", None)
|
|
194
|
+
gen = self.capture(freq, sample_rate, to_stdout=True, **k)
|
|
195
|
+
return StreamCtx(gen)
|
|
196
|
+
|
|
197
|
+
def capture_callback(self, freq, sample_rate, on_block, *,
|
|
198
|
+
max_samples=None, max_blocks=None, **k):
|
|
199
|
+
# Binder-style ergonomics over the subprocess stream: instead of the
|
|
200
|
+
# caller writing the receive loop, register a callback that fires with
|
|
201
|
+
# each decoded complex64 block as it arrives. This is the usability
|
|
202
|
+
# analog of libhackrf's rx callback -- same data, same latency as
|
|
203
|
+
# capture_stream, but the loop is inverted so calling code looks like
|
|
204
|
+
# the C-binding libraries.
|
|
205
|
+
#
|
|
206
|
+
# def on_block(iq, n_so_far):
|
|
207
|
+
# ... # return False to stop early
|
|
208
|
+
#
|
|
209
|
+
# The child is always reaped on exit (normal, early-stop, or
|
|
210
|
+
# exception). Stops when on_block returns False, when max_samples /
|
|
211
|
+
# max_blocks is reached, or when the source ends.
|
|
212
|
+
from .._stream_ctx import StreamCtx
|
|
213
|
+
k.pop("to_stdout", None)
|
|
214
|
+
total = 0
|
|
215
|
+
blocks = 0
|
|
216
|
+
with StreamCtx(self.capture(freq, sample_rate, to_stdout=True, **k)) \
|
|
217
|
+
as gen:
|
|
218
|
+
for iq in gen:
|
|
219
|
+
cont = on_block(iq, total)
|
|
220
|
+
total += len(iq)
|
|
221
|
+
blocks += 1
|
|
222
|
+
if cont is False:
|
|
223
|
+
break
|
|
224
|
+
if max_samples is not None and total >= max_samples:
|
|
225
|
+
break
|
|
226
|
+
if max_blocks is not None and blocks >= max_blocks:
|
|
227
|
+
break
|
|
228
|
+
return total
|
|
229
|
+
|
|
230
|
+
# ---- internals ----
|
|
231
|
+
def _rx_argv(self, freq, sample_rate, target, lna, vga, amp, bias_tee,
|
|
232
|
+
bw, num_samples):
|
|
233
|
+
argv = ["transfer", "-r", target,
|
|
234
|
+
"-f", int(freq), "-s", int(sample_rate),
|
|
235
|
+
"-l", lna, "-g", vga,
|
|
236
|
+
"-a", 1 if amp else 0,
|
|
237
|
+
"-b", int(bw)]
|
|
238
|
+
if bias_tee:
|
|
239
|
+
argv += ["-p", 1]
|
|
240
|
+
if num_samples is not None:
|
|
241
|
+
argv += ["-n", int(num_samples)]
|
|
242
|
+
return argv
|
|
243
|
+
|
|
244
|
+
def _capture_segmented(self, freq, sample_rate, out, segment_secs, lna,
|
|
245
|
+
vga, amp, bias_tee, bw, sigmf, print_cmd):
|
|
246
|
+
# Rolling files out_000.iq, out_001.iq, ... each `segment_secs` long.
|
|
247
|
+
# Each segment is a bounded blocking capture, so files are whole.
|
|
248
|
+
base, ext = os.path.splitext(out)
|
|
249
|
+
ext = ext or ".iq"
|
|
250
|
+
n_per = int(sample_rate * segment_secs)
|
|
251
|
+
idx = 0
|
|
252
|
+
results = []
|
|
253
|
+
self.print_message(f"[*] segmented capture: {segment_secs}s per file")
|
|
254
|
+
# Honest note: each segment is a fresh hackrf_transfer spawn (device
|
|
255
|
+
# open + retune), so there is a short GAP (~hundreds of ms) of missing
|
|
256
|
+
# samples between files. Segments are whole, not gapless.
|
|
257
|
+
self.print_message("[!] note: segments are not gapless "
|
|
258
|
+
"(short re-open gap between files)")
|
|
259
|
+
try:
|
|
260
|
+
while True:
|
|
261
|
+
# Disk guard PER SEGMENT: this is the run-indefinitely mode,
|
|
262
|
+
# so check before each file rather than once up front. Raises
|
|
263
|
+
# HackRFEnvironmentError when the next segment wouldn't fit
|
|
264
|
+
# within the headroom, instead of filling the disk.
|
|
265
|
+
if not print_cmd:
|
|
266
|
+
self.estimate_capture(sample_rate, num_samples=n_per,
|
|
267
|
+
path=os.path.dirname(out) or ".")
|
|
268
|
+
seg = f"{base}_{idx:03d}{ext}"
|
|
269
|
+
argv = self._rx_argv(freq, sample_rate, seg, lna, vga, amp,
|
|
270
|
+
bias_tee, bw, n_per)
|
|
271
|
+
res = self._run(argv, mode="blocking", print_cmd=print_cmd)
|
|
272
|
+
if sigmf and not print_cmd:
|
|
273
|
+
write_sigmf_meta(seg, freq, sample_rate, lna=lna, vga=vga,
|
|
274
|
+
amp=amp, datatype="ci8")
|
|
275
|
+
results.append(seg)
|
|
276
|
+
idx += 1
|
|
277
|
+
if print_cmd:
|
|
278
|
+
break # one example command is enough for dry-run
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
# reachable now that _run re-raises after stopping the child
|
|
281
|
+
self.print_message("[*] segmented capture stopped by user")
|
|
282
|
+
return results
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
|
|
3
|
+
##--------------------------------------------------------------------\
|
|
4
|
+
# hackrfpy
|
|
5
|
+
# 'src/hackrfpy/_commands/device.py'
|
|
6
|
+
#
|
|
7
|
+
# DeviceMixin: the device-management commands other wrappers tend to drop
|
|
8
|
+
# (clock, spiflash, operacake, cpldjtag, debug), plus the `doctor` preflight.
|
|
9
|
+
# spiflash writes firmware and can BRICK the board, so it is heavily guarded:
|
|
10
|
+
# it refuses unless confirm=True is passed explicitly.
|
|
11
|
+
#
|
|
12
|
+
# Author(s): <you>
|
|
13
|
+
##--------------------------------------------------------------------\
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
|
|
18
|
+
from .. import constants as C
|
|
19
|
+
from ..exceptions import HackRFDeviceError, HackRFEnvironmentError, HackRFValueError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DeviceMixin:
|
|
23
|
+
# ---- clock ----
|
|
24
|
+
def clock(self, *args, print_cmd=False):
|
|
25
|
+
argv = ["clock"] + list(args)
|
|
26
|
+
return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd)
|
|
27
|
+
|
|
28
|
+
# ---- operacake antenna switch ----
|
|
29
|
+
def operacake(self, *args, print_cmd=False):
|
|
30
|
+
argv = ["operacake"] + list(args)
|
|
31
|
+
return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd)
|
|
32
|
+
|
|
33
|
+
def operacake_list(self):
|
|
34
|
+
return self.operacake("-l")
|
|
35
|
+
|
|
36
|
+
# ---- cpld jtag (CPLD firmware) ----
|
|
37
|
+
def cpldjtag(self, firmware, *, confirm=False, print_cmd=False):
|
|
38
|
+
if not confirm and not print_cmd:
|
|
39
|
+
raise HackRFValueError(
|
|
40
|
+
"cpldjtag flashes the CPLD and can brick the board. "
|
|
41
|
+
"Pass confirm=True if you are certain.")
|
|
42
|
+
return self._run(["cpldjtag", "-x", firmware], mode="blocking",
|
|
43
|
+
text=True, print_cmd=print_cmd)
|
|
44
|
+
|
|
45
|
+
# ---- spiflash (firmware) ----
|
|
46
|
+
def spiflash_write(self, firmware, *, confirm=False, print_cmd=False):
|
|
47
|
+
# Writing firmware. The single most dangerous operation in the library.
|
|
48
|
+
if not confirm and not print_cmd:
|
|
49
|
+
raise HackRFValueError(
|
|
50
|
+
"spiflash_write flashes device firmware and can BRICK the "
|
|
51
|
+
"board if interrupted or given a bad image. Pass confirm=True "
|
|
52
|
+
"only with a verified firmware .bin and stable power.")
|
|
53
|
+
return self._run(["spiflash", "-w", firmware], mode="blocking",
|
|
54
|
+
text=True, print_cmd=print_cmd)
|
|
55
|
+
|
|
56
|
+
def spiflash_read(self, out, length=None, print_cmd=False):
|
|
57
|
+
argv = ["spiflash", "-r", out]
|
|
58
|
+
if length is not None:
|
|
59
|
+
argv += ["-l", int(length)]
|
|
60
|
+
return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd)
|
|
61
|
+
|
|
62
|
+
def spiflash_reset(self, print_cmd=False):
|
|
63
|
+
return self._run(["spiflash", "-R"], mode="blocking", text=True,
|
|
64
|
+
print_cmd=print_cmd)
|
|
65
|
+
|
|
66
|
+
# ---- debug register access ----
|
|
67
|
+
def debug(self, *args, print_cmd=False):
|
|
68
|
+
argv = ["debug"] + list(args)
|
|
69
|
+
return self._run(argv, mode="blocking", text=True, print_cmd=print_cmd)
|
|
70
|
+
|
|
71
|
+
# =================================================================
|
|
72
|
+
# preflight: environment / readiness check
|
|
73
|
+
# (was 'doctor' -- kept as an alias below for the familiar CLI verb)
|
|
74
|
+
# =================================================================
|
|
75
|
+
def preflight(self, capture_path="."):
|
|
76
|
+
# Checks tooling, board presence, free disk, and reports the active
|
|
77
|
+
# mode. Returns a structured report; raises only on hard environment
|
|
78
|
+
# failures the user must fix.
|
|
79
|
+
report = {"tools": {}, "boards": [], "mode": self.mode,
|
|
80
|
+
"tool_version": None, "disk_free_bytes": None,
|
|
81
|
+
"features": {}, "problems": []}
|
|
82
|
+
|
|
83
|
+
# 1. tools on PATH (or tools_dir). Only the CORE tools (info,
|
|
84
|
+
# transfer, sweep) count as problems -- a box without operacake or
|
|
85
|
+
# spiflash is perfectly capture-ready, and flagging them broke the
|
|
86
|
+
# documented `doctor && capture` exit-code pattern on minimal
|
|
87
|
+
# Windows installs that ship only the core binaries.
|
|
88
|
+
for key, name in C.TOOLS.items():
|
|
89
|
+
try:
|
|
90
|
+
report["tools"][name] = self.resolve(key)
|
|
91
|
+
except HackRFDeviceError:
|
|
92
|
+
report["tools"][name] = None
|
|
93
|
+
if key in C.CORE_TOOLS:
|
|
94
|
+
report["problems"].append(f"missing core binary: {name}")
|
|
95
|
+
|
|
96
|
+
# 2. board enumeration via hackrf_info (only if present)
|
|
97
|
+
if report["tools"].get(C.TOOLS["info"]):
|
|
98
|
+
try:
|
|
99
|
+
out, _, _ = self._run(["info"], mode="blocking", text=True)
|
|
100
|
+
parsed = self.parse_info(out)
|
|
101
|
+
report["boards"] = parsed.get("boards", [])
|
|
102
|
+
# version skew is the main fragility of the subprocess
|
|
103
|
+
# approach: '-r -' stdout and 'sweep -N' need modern tools.
|
|
104
|
+
lib = parsed.get("library", {})
|
|
105
|
+
report["tool_version"] = lib.get("hackrf_info_version")
|
|
106
|
+
m = re.match(r"(\d{4})", report["tool_version"] or "")
|
|
107
|
+
if m and int(m.group(1)) < 2021:
|
|
108
|
+
report["problems"].append(
|
|
109
|
+
f"hackrf-tools {report['tool_version']} predates "
|
|
110
|
+
f"2021; '-r -' streaming and 'sweep -N' may be "
|
|
111
|
+
f"unsupported -- please upgrade")
|
|
112
|
+
if not report["boards"]:
|
|
113
|
+
report["problems"].append(
|
|
114
|
+
"no HackRF board detected (check USB / permissions)")
|
|
115
|
+
except HackRFDeviceError as e:
|
|
116
|
+
report["problems"].append(str(e))
|
|
117
|
+
|
|
118
|
+
# 2b. feature probe: derive capability flags from the firmware version
|
|
119
|
+
# (reliable year) and tools version. See features().
|
|
120
|
+
fw = None
|
|
121
|
+
if report["boards"]:
|
|
122
|
+
fw = report["boards"][0].get("firmware_version")
|
|
123
|
+
report["features"] = self.features(report.get("tool_version"), fw)
|
|
124
|
+
|
|
125
|
+
# 3. disk
|
|
126
|
+
try:
|
|
127
|
+
report["disk_free_bytes"] = shutil.disk_usage(capture_path).free
|
|
128
|
+
except OSError as e:
|
|
129
|
+
report["problems"].append(f"cannot stat disk at {capture_path}: {e}")
|
|
130
|
+
|
|
131
|
+
self._print_preflight(report)
|
|
132
|
+
return report
|
|
133
|
+
|
|
134
|
+
# doctor: familiar alias for preflight (brew/flutter-style verb). The CLI
|
|
135
|
+
# still exposes `hrf doctor`; the honest method name is preflight().
|
|
136
|
+
def doctor(self, capture_path="."):
|
|
137
|
+
return self.preflight(capture_path=capture_path)
|
|
138
|
+
|
|
139
|
+
def features(self, tool_version=None, firmware_version=None):
|
|
140
|
+
# Map version strings -> capability flags. The reliable year signal is
|
|
141
|
+
# the FIRMWARE version (e.g. "2024.02.1"); the tools version is often a
|
|
142
|
+
# git tag (e.g. "git-b1dbb47") with no parseable year. If nothing is
|
|
143
|
+
# passed, probe the device once and use both. Table-driven + pure, so
|
|
144
|
+
# unit-testable without hardware.
|
|
145
|
+
if tool_version is None and firmware_version is None:
|
|
146
|
+
try:
|
|
147
|
+
parsed = self.parse_info(
|
|
148
|
+
self._run(["info"], mode="blocking", text=True)[0])
|
|
149
|
+
tool_version = (parsed.get("library", {})
|
|
150
|
+
.get("hackrf_info_version"))
|
|
151
|
+
boards = parsed.get("boards", [])
|
|
152
|
+
if boards:
|
|
153
|
+
firmware_version = boards[0].get("firmware_version")
|
|
154
|
+
except HackRFDeviceError:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
# Prefer the firmware version's year; fall back to the tools version.
|
|
158
|
+
# A "git-" build is a from-source build, which is current by
|
|
159
|
+
# definition -- treat it as modern rather than merely "unknown".
|
|
160
|
+
def _year(v):
|
|
161
|
+
m = re.match(r"(\d{4})", v or "")
|
|
162
|
+
return int(m.group(1)) if m else None
|
|
163
|
+
|
|
164
|
+
year = _year(firmware_version) or _year(tool_version)
|
|
165
|
+
is_git = ((tool_version or "").startswith("git-")
|
|
166
|
+
or (firmware_version or "").startswith("git-"))
|
|
167
|
+
# known if we have a real year OR it's an identifiable git build
|
|
168
|
+
known = year is not None or is_git
|
|
169
|
+
modern = is_git or year is None or year >= 2021
|
|
170
|
+
return {
|
|
171
|
+
"tool_version": tool_version,
|
|
172
|
+
"firmware_version": firmware_version,
|
|
173
|
+
"version_known": known,
|
|
174
|
+
"is_git_build": is_git,
|
|
175
|
+
"stdout_streaming": modern, # rx -r - / sweep
|
|
176
|
+
"sweep_num_sweeps": modern, # sweep -N
|
|
177
|
+
"bias_tee": is_git or year is None or year >= 2018, # -p
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def _print_preflight(self, r):
|
|
181
|
+
print("hackrfpy preflight")
|
|
182
|
+
print("-" * 40)
|
|
183
|
+
for name, path in r["tools"].items():
|
|
184
|
+
print(f" {'OK ' if path else 'XX '} {name:18} "
|
|
185
|
+
f"{path or 'NOT FOUND'}")
|
|
186
|
+
if r.get("tool_version"):
|
|
187
|
+
print(f" tools version : {r['tool_version']}")
|
|
188
|
+
feats = r.get("features", {})
|
|
189
|
+
if feats and not feats.get("stdout_streaming", True):
|
|
190
|
+
print(" ! stdout streaming (rx -r - / capture_array) unsupported "
|
|
191
|
+
"by this tools version")
|
|
192
|
+
print(f" boards detected : {len(r['boards'])}")
|
|
193
|
+
for b in r["boards"]:
|
|
194
|
+
sn = b.get("serial_number", b.get("serial", "?"))
|
|
195
|
+
print(f" - {sn}")
|
|
196
|
+
if r["disk_free_bytes"] is not None:
|
|
197
|
+
print(f" free disk : {r['disk_free_bytes']/1e9:.1f} GB")
|
|
198
|
+
print(f" active mode : {r['mode']}")
|
|
199
|
+
if r["problems"]:
|
|
200
|
+
print(" problems:")
|
|
201
|
+
for p in r["problems"]:
|
|
202
|
+
print(f" ! {p}")
|
|
203
|
+
else:
|
|
204
|
+
print(" no problems found")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
|
|
3
|
+
##--------------------------------------------------------------------\
|
|
4
|
+
# hackrfpy
|
|
5
|
+
# 'src/hackrfpy/_commands/info.py'
|
|
6
|
+
#
|
|
7
|
+
# InfoMixin: hackrf_info wrapper. The clean request/response case -- run the
|
|
8
|
+
# binary, capture stdout text, parse to a dict. Mixin only; assumes the host
|
|
9
|
+
# HackRF class provides _run, print_message, and mode state.
|
|
10
|
+
#
|
|
11
|
+
# Author(s): <you>
|
|
12
|
+
##--------------------------------------------------------------------\
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InfoMixin:
|
|
18
|
+
def info(self, raw=False, print_cmd=False):
|
|
19
|
+
# Returns a parsed dict by default, or the raw text if raw=True.
|
|
20
|
+
if print_cmd:
|
|
21
|
+
self._run(["info"], mode="blocking", print_cmd=True)
|
|
22
|
+
return None
|
|
23
|
+
out, err, rc = self._run(["info"], mode="blocking", text=True)
|
|
24
|
+
self.print_message(f"[*] mode: {self.mode}")
|
|
25
|
+
if raw:
|
|
26
|
+
return out
|
|
27
|
+
return self.parse_info(out)
|
|
28
|
+
|
|
29
|
+
def get_info(self):
|
|
30
|
+
# alias
|
|
31
|
+
return self.info()
|
|
32
|
+
|
|
33
|
+
def detect(self):
|
|
34
|
+
# Hardware autodetection + identification, the HackRF analog of a
|
|
35
|
+
# serial-port scan. The HackRF is NOT a serial device -- there are no
|
|
36
|
+
# COM ports to walk -- so "detection" means: run hackrf_info, confirm
|
|
37
|
+
# each enumerated board is actually a HackRF, and report identity +
|
|
38
|
+
# firmware. Never raises for "no board"; that is a normal result the
|
|
39
|
+
# caller inspects. Raises HackRFDeviceError only if hackrf_info itself
|
|
40
|
+
# cannot run (missing binary).
|
|
41
|
+
#
|
|
42
|
+
# Returns a dict:
|
|
43
|
+
# {
|
|
44
|
+
# "found": bool, # at least one board enumerated
|
|
45
|
+
# "ready": bool, # >=1 CONFIRMED HackRF + usable tooling
|
|
46
|
+
# "count": int,
|
|
47
|
+
# "boards": [ {index, serial, name, firmware, is_hackrf,
|
|
48
|
+
# firmware_stale}, ... ],
|
|
49
|
+
# "tools_version": str|None,
|
|
50
|
+
# "libhackrf_version": str|None,
|
|
51
|
+
# "multiple": bool, # >1 board -> disambiguate with serial=
|
|
52
|
+
# "problem": str|None,
|
|
53
|
+
# }
|
|
54
|
+
from ..exceptions import HackRFDeviceError
|
|
55
|
+
result = {"found": False, "ready": False, "count": 0, "boards": [],
|
|
56
|
+
"tools_version": None, "libhackrf_version": None,
|
|
57
|
+
"multiple": False, "warnings": [], "problem": None}
|
|
58
|
+
try:
|
|
59
|
+
out, _, _ = self._run(["info"], mode="blocking", text=True)
|
|
60
|
+
except HackRFDeviceError as e:
|
|
61
|
+
# hackrf_info couldn't run at all (binary missing). Surface it as a
|
|
62
|
+
# problem rather than raising, so detect() is always safe to call.
|
|
63
|
+
result["problem"] = str(e)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
parsed = self.parse_info(out)
|
|
67
|
+
lib = parsed.get("library", {})
|
|
68
|
+
result["tools_version"] = lib.get("hackrf_info_version")
|
|
69
|
+
result["libhackrf_version"] = lib.get("libhackrf_version")
|
|
70
|
+
# device-emitted free-text warnings, e.g. "There are N other devices
|
|
71
|
+
# on the same USB bus. You may have problems at high sample rates."
|
|
72
|
+
result["warnings"] = parsed.get("warnings", [])
|
|
73
|
+
|
|
74
|
+
boards = parsed.get("boards", [])
|
|
75
|
+
result["count"] = len(boards)
|
|
76
|
+
result["found"] = len(boards) > 0
|
|
77
|
+
result["multiple"] = len(boards) > 1
|
|
78
|
+
|
|
79
|
+
for b in boards:
|
|
80
|
+
board_id = b.get("board_id_number", "")
|
|
81
|
+
# "Board ID Number: 2 (HackRF One)" -> confirm it's a HackRF
|
|
82
|
+
is_hackrf = "hackrf" in board_id.lower()
|
|
83
|
+
fw = b.get("firmware_version", "")
|
|
84
|
+
m = re.match(r"(\d{4})", fw or "")
|
|
85
|
+
stale = bool(m and int(m.group(1)) < 2021)
|
|
86
|
+
result["boards"].append({
|
|
87
|
+
"index": b.get("index"),
|
|
88
|
+
"serial": b.get("serial_number"),
|
|
89
|
+
"name": board_id or None,
|
|
90
|
+
"firmware": fw or None,
|
|
91
|
+
"is_hackrf": is_hackrf,
|
|
92
|
+
"firmware_stale": stale,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
# ready = at least one confirmed HackRF present
|
|
96
|
+
result["ready"] = any(bd["is_hackrf"] for bd in result["boards"])
|
|
97
|
+
if result["found"] and not result["ready"]:
|
|
98
|
+
result["problem"] = ("a USB device was enumerated but did not "
|
|
99
|
+
"identify as a HackRF")
|
|
100
|
+
elif not result["found"]:
|
|
101
|
+
result["problem"] = "no HackRF board detected (check USB / drivers)"
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
def identify(self, serial=None):
|
|
105
|
+
# Return the identity of a single board: the one matching `serial`, or
|
|
106
|
+
# the first detected board if serial is None. Returns the board dict
|
|
107
|
+
# from detect()["boards"], or None if not found. Convenience for "what
|
|
108
|
+
# am I about to talk to?" before a capture/transmit.
|
|
109
|
+
det = self.detect()
|
|
110
|
+
if not det["boards"]:
|
|
111
|
+
return None
|
|
112
|
+
if serial is None:
|
|
113
|
+
return det["boards"][0]
|
|
114
|
+
for b in det["boards"]:
|
|
115
|
+
if b["serial"] and serial in b["serial"]:
|
|
116
|
+
return b
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def parse_info(text):
|
|
121
|
+
# hackrf_info prints "Key: value" lines: a version preamble, then one
|
|
122
|
+
# block per board. We keep the preamble under "library" and each board
|
|
123
|
+
# under "boards". A board starts at "Found HackRF" or, for outputs that
|
|
124
|
+
# omit it, at an "Index:" line.
|
|
125
|
+
#
|
|
126
|
+
# Real-hardware quirks this handles:
|
|
127
|
+
# - "Hardware supported by installed firmware:" puts its value on the
|
|
128
|
+
# NEXT, indented line (e.g. " HackRF One") rather than after the
|
|
129
|
+
# colon -- so an empty value followed by an indented line is a
|
|
130
|
+
# continuation, not a blank.
|
|
131
|
+
# - Trailing free-text warnings with no colon (e.g. "There are 2
|
|
132
|
+
# other devices on the same USB bus. You may have problems at high
|
|
133
|
+
# sample rates.") are collected under result["warnings"] instead of
|
|
134
|
+
# being silently dropped.
|
|
135
|
+
result = {"library": {}, "boards": [], "warnings": []}
|
|
136
|
+
current = None
|
|
137
|
+
last_target = None # dict the last key was written to
|
|
138
|
+
last_key = None # the last key, for continuation lines
|
|
139
|
+
for raw in text.splitlines():
|
|
140
|
+
line = raw.strip()
|
|
141
|
+
if not line:
|
|
142
|
+
continue
|
|
143
|
+
if line.lower().startswith("found hackrf"):
|
|
144
|
+
current = {}
|
|
145
|
+
result["boards"].append(current)
|
|
146
|
+
last_target = last_key = None
|
|
147
|
+
continue
|
|
148
|
+
if ":" not in line:
|
|
149
|
+
# No colon. Either a continuation of the previous key whose
|
|
150
|
+
# value was on this indented line, or a free-text warning.
|
|
151
|
+
if (last_target is not None and last_key is not None
|
|
152
|
+
and raw[:1].isspace() and not last_target.get(last_key)):
|
|
153
|
+
last_target[last_key] = line # fill the empty continuation
|
|
154
|
+
else:
|
|
155
|
+
result["warnings"].append(line)
|
|
156
|
+
continue
|
|
157
|
+
key, _, val = line.partition(":")
|
|
158
|
+
key = re.sub(r"[^a-z0-9]+", "_", key.strip().lower()).strip("_")
|
|
159
|
+
val = val.strip()
|
|
160
|
+
if key == "index":
|
|
161
|
+
if current is None or "index" in current:
|
|
162
|
+
current = {}
|
|
163
|
+
result["boards"].append(current)
|
|
164
|
+
current[key] = val
|
|
165
|
+
last_target, last_key = current, key
|
|
166
|
+
elif current is None:
|
|
167
|
+
result["library"][key] = val # version preamble
|
|
168
|
+
last_target, last_key = result["library"], key
|
|
169
|
+
else:
|
|
170
|
+
current[key] = val
|
|
171
|
+
last_target, last_key = current, key
|
|
172
|
+
return result
|