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/core.py
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
|
|
3
|
+
##--------------------------------------------------------------------\
|
|
4
|
+
# hackrfpy
|
|
5
|
+
# 'src/hackrfpy/core.py'
|
|
6
|
+
#
|
|
7
|
+
# The HackRF device class. Composed from functional mixins under
|
|
8
|
+
# ./_commands/ (info, capture, transmit, sweep, device). core.py holds the
|
|
9
|
+
# shared state, the operating-mode machine, the validation layer, and the
|
|
10
|
+
# ONE device-I/O choke point (_run) that every command funnels through.
|
|
11
|
+
#
|
|
12
|
+
# Design mirrors a request/response wrapper, but the "response" from a
|
|
13
|
+
# hackrf_* binary can be bounded (-n samples / sweep -1), timed, streamed
|
|
14
|
+
# (sweep CSV), or open-ended (rx until stopped). So _run takes a `mode`
|
|
15
|
+
# argument the way a serial wrapper takes a length flag.
|
|
16
|
+
#
|
|
17
|
+
# Author(s): <you>
|
|
18
|
+
##--------------------------------------------------------------------\
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import signal
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from . import constants as C
|
|
30
|
+
from .exceptions import (
|
|
31
|
+
HackRFValueError, HackRFModeError, HackRFDeviceError, HackRFEnvironmentError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# ---- platform interrupt plumbing -------------------------------------------
|
|
35
|
+
# hackrf_* tools flush + close cleanly on SIGINT. On Windows SIGINT can't be
|
|
36
|
+
# delivered to a child; the equivalent is CTRL_BREAK_EVENT, which requires
|
|
37
|
+
# the child to be in its own process group. Best-effort: exercised on POSIX,
|
|
38
|
+
# untested on Windows.
|
|
39
|
+
if os.name == "nt": # pragma: no cover
|
|
40
|
+
_CREATION_FLAGS = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
41
|
+
_INTERRUPT_SIGNAL = signal.CTRL_BREAK_EVENT
|
|
42
|
+
else:
|
|
43
|
+
_CREATION_FLAGS = 0
|
|
44
|
+
_INTERRUPT_SIGNAL = signal.SIGINT
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _interrupt(proc):
|
|
48
|
+
proc.send_signal(_INTERRUPT_SIGNAL)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---- live-process registry + atexit backstop (Tier B) ----------------------
|
|
52
|
+
# Tier A is the context managers (_Process / _StreamCtx __exit__). Tier B is
|
|
53
|
+
# this: a registry of still-running handles that an atexit hook stops on
|
|
54
|
+
# interpreter shutdown, catching the "script raised/exited and forgot to
|
|
55
|
+
# .stop()" case that Tier A misses when the caller didn't use `with`.
|
|
56
|
+
#
|
|
57
|
+
# It does NOT survive `kill -9` / power loss (no handler runs); that needs an
|
|
58
|
+
# OS dead-man (Linux PR_SET_PDEATHSIG / Windows Job Object) which is platform-
|
|
59
|
+
# split work deferred to a later pass.
|
|
60
|
+
# TODO(os-deadman): Linux prctl(PR_SET_PDEATHSIG); Windows Job Object with
|
|
61
|
+
# JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Survives hard-kill of the parent.
|
|
62
|
+
#
|
|
63
|
+
# TX handles are ALWAYS registered (an orphaned transmitter is a regulatory /
|
|
64
|
+
# interference problem). RX handles are registered by default but can opt out
|
|
65
|
+
# (an orphaned receiver only wastes disk, and fire-and-forget is occasionally
|
|
66
|
+
# wanted).
|
|
67
|
+
import atexit
|
|
68
|
+
import weakref
|
|
69
|
+
|
|
70
|
+
_LIVE = weakref.WeakSet()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _register_live(proc_handle):
|
|
74
|
+
_LIVE.add(proc_handle)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@atexit.register
|
|
78
|
+
def _stop_all_live():
|
|
79
|
+
for h in list(_LIVE):
|
|
80
|
+
try:
|
|
81
|
+
if h.is_alive():
|
|
82
|
+
h.stop()
|
|
83
|
+
except Exception:
|
|
84
|
+
pass # best-effort on the way down; never raise from atexit
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
from ._commands.info import InfoMixin
|
|
88
|
+
from ._commands.capture import CaptureMixin
|
|
89
|
+
from ._commands.transmit import TransmitMixin
|
|
90
|
+
from ._commands.sweep import SweepMixin
|
|
91
|
+
from ._commands.device import DeviceMixin
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Process:
|
|
95
|
+
# Thin controller returned by _run(mode="handle") for "run until I stop it"
|
|
96
|
+
# workflows (the wait-for-user-stop consumption mode). Wraps a Popen and
|
|
97
|
+
# exposes the liveness poll so callers can build their own error checking.
|
|
98
|
+
#
|
|
99
|
+
# stdout/stderr are drained continuously in daemon threads. hackrf_transfer
|
|
100
|
+
# prints a stats line every second; with an undrained PIPE the 64 KB buffer
|
|
101
|
+
# eventually fills and the child blocks on write, silently stalling a
|
|
102
|
+
# long-running capture. Draining as we go removes that failure mode.
|
|
103
|
+
def __init__(self, proc, owner, kind="rx"):
|
|
104
|
+
self._proc = proc
|
|
105
|
+
self._owner = owner
|
|
106
|
+
self._kind = kind # "rx" | "tx" -- for atexit messaging
|
|
107
|
+
self._stopped = False
|
|
108
|
+
self._out_chunks = []
|
|
109
|
+
self._err_chunks = []
|
|
110
|
+
self._threads = []
|
|
111
|
+
for stream, sink in ((proc.stdout, self._out_chunks),
|
|
112
|
+
(proc.stderr, self._err_chunks)):
|
|
113
|
+
if stream is None:
|
|
114
|
+
continue
|
|
115
|
+
t = threading.Thread(target=self._drain, args=(stream, sink),
|
|
116
|
+
daemon=True)
|
|
117
|
+
t.start()
|
|
118
|
+
self._threads.append(t)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _drain(stream, sink):
|
|
122
|
+
try:
|
|
123
|
+
for chunk in iter(lambda: stream.read(65536), b""):
|
|
124
|
+
sink.append(chunk)
|
|
125
|
+
except (OSError, ValueError):
|
|
126
|
+
pass # pipe closed under us during shutdown
|
|
127
|
+
|
|
128
|
+
def is_alive(self):
|
|
129
|
+
return self._proc.poll() is None
|
|
130
|
+
|
|
131
|
+
def stop(self, grace=2.0):
|
|
132
|
+
# SIGINT lets hackrf_transfer flush + close the file cleanly so the
|
|
133
|
+
# IQ stream isn't truncated mid-sample-pair.
|
|
134
|
+
if self._proc.poll() is None:
|
|
135
|
+
_interrupt(self._proc)
|
|
136
|
+
try:
|
|
137
|
+
self._proc.wait(timeout=grace)
|
|
138
|
+
except subprocess.TimeoutExpired:
|
|
139
|
+
self._proc.terminate()
|
|
140
|
+
self._stopped = True
|
|
141
|
+
_LIVE.discard(self) # no longer needs the atexit backstop
|
|
142
|
+
return self.result()
|
|
143
|
+
|
|
144
|
+
def wait(self):
|
|
145
|
+
self._proc.wait()
|
|
146
|
+
_LIVE.discard(self)
|
|
147
|
+
return self.result()
|
|
148
|
+
|
|
149
|
+
def result(self):
|
|
150
|
+
for t in self._threads:
|
|
151
|
+
t.join(timeout=2.0)
|
|
152
|
+
out = b"".join(self._out_chunks)
|
|
153
|
+
err = b"".join(self._err_chunks)
|
|
154
|
+
return out, err, self._proc.returncode
|
|
155
|
+
|
|
156
|
+
# ---- context manager (Tier A): guaranteed reap on block exit -----------
|
|
157
|
+
def __enter__(self):
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def __exit__(self, exc_type, exc, tb):
|
|
161
|
+
if not self._stopped:
|
|
162
|
+
self.stop()
|
|
163
|
+
return False # never suppress the caller's exception
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class HackRF(InfoMixin, CaptureMixin, TransmitMixin, SweepMixin, DeviceMixin):
|
|
167
|
+
def __init__(self, tools_dir=None, verbose=False, serial=None):
|
|
168
|
+
# ---- feedback ----
|
|
169
|
+
self.verboseEnabled = verbose
|
|
170
|
+
|
|
171
|
+
# ---- device selection (multi-board setups) ----
|
|
172
|
+
# When set, `-d <serial>` is injected into every tool that supports
|
|
173
|
+
# it (constants.TOOLS_WITH_SERIAL). None = let the tools pick the
|
|
174
|
+
# sole attached board, exactly as before.
|
|
175
|
+
self.serial = serial
|
|
176
|
+
|
|
177
|
+
# ---- operating mode (the TX gate) ----
|
|
178
|
+
# Default RX. transmit() refuses unless mode == MODE_TX. Switching to
|
|
179
|
+
# TX is the deliberate confirmation; it emits a one-time safety banner.
|
|
180
|
+
self._mode = C.DEFAULT_MODE
|
|
181
|
+
|
|
182
|
+
# ---- envelope override ----
|
|
183
|
+
# When True, hard-range violations warn instead of raising. CLI maps
|
|
184
|
+
# this to --force. Does NOT affect TX frequency (never policed) or the
|
|
185
|
+
# snap-and-notify params (always snapped).
|
|
186
|
+
self.allow_out_of_spec = False
|
|
187
|
+
|
|
188
|
+
# ---- binary resolution ----
|
|
189
|
+
# tools_dir lets Windows installs point at the hackrf-tools folder when
|
|
190
|
+
# they aren't on PATH.
|
|
191
|
+
self.tools_dir = tools_dir
|
|
192
|
+
|
|
193
|
+
# ---- handle-mode safety backstop (Tier B) ----
|
|
194
|
+
# TX handles are ALWAYS atexit-stopped. RX handles are too by default;
|
|
195
|
+
# set backstop_rx=False for deliberate fire-and-forget receivers.
|
|
196
|
+
self.backstop_rx = True
|
|
197
|
+
|
|
198
|
+
# ---- parameter readback ----
|
|
199
|
+
# Populated by every validated operation with the ACTUAL values used
|
|
200
|
+
# after snapping / auto-derivation, so a script can see what really
|
|
201
|
+
# happened (e.g. a requested lna=30 that snapped to 24). None until the
|
|
202
|
+
# first rx/tx/sweep call on this instance.
|
|
203
|
+
self.last_params = None
|
|
204
|
+
|
|
205
|
+
def _record_params(self, **params):
|
|
206
|
+
# Single place the mixins call to publish post-snap values. Returns the
|
|
207
|
+
# dict so callers can also use it inline if they want.
|
|
208
|
+
self.last_params = params
|
|
209
|
+
return params
|
|
210
|
+
|
|
211
|
+
# =================================================================
|
|
212
|
+
# Feedback
|
|
213
|
+
# =================================================================
|
|
214
|
+
def set_verbose(self, verbose=True):
|
|
215
|
+
self.verboseEnabled = verbose
|
|
216
|
+
|
|
217
|
+
def get_verbose(self):
|
|
218
|
+
return self.verboseEnabled
|
|
219
|
+
|
|
220
|
+
def print_message(self, msg):
|
|
221
|
+
if self.verboseEnabled:
|
|
222
|
+
print(msg)
|
|
223
|
+
|
|
224
|
+
def warn(self, msg):
|
|
225
|
+
# Safety / correctness warnings the user must see REGARDLESS of verbose.
|
|
226
|
+
# (A degraded-results or out-of-spec notice that only prints in verbose
|
|
227
|
+
# mode is, in practice, a silent warning.) Routed to stderr so it never
|
|
228
|
+
# pollutes stdout data streams (sweep CSV, rx -r -).
|
|
229
|
+
print(f"[!] {msg}", file=sys.stderr)
|
|
230
|
+
|
|
231
|
+
# =================================================================
|
|
232
|
+
# Operating mode machine
|
|
233
|
+
# =================================================================
|
|
234
|
+
@property
|
|
235
|
+
def mode(self):
|
|
236
|
+
return self._mode
|
|
237
|
+
|
|
238
|
+
@mode.setter
|
|
239
|
+
def mode(self, value):
|
|
240
|
+
self.set_mode(value)
|
|
241
|
+
|
|
242
|
+
def get_mode(self):
|
|
243
|
+
return self._mode
|
|
244
|
+
|
|
245
|
+
def set_mode(self, value):
|
|
246
|
+
value = str(value).lower()
|
|
247
|
+
if value not in C.MODES:
|
|
248
|
+
raise HackRFValueError(
|
|
249
|
+
f"mode must be one of {C.MODES}, got {value!r}")
|
|
250
|
+
if value == C.MODE_TX and self._mode != C.MODE_TX:
|
|
251
|
+
# the one important warn, attached to the deliberate switch
|
|
252
|
+
self._tx_safety_banner()
|
|
253
|
+
self._mode = value
|
|
254
|
+
self.print_message(f"[*] mode -> {value}")
|
|
255
|
+
return self._mode
|
|
256
|
+
|
|
257
|
+
def restore_mode(self, value):
|
|
258
|
+
# Rehydrate previously-persisted mode WITHOUT the switch ceremony
|
|
259
|
+
# (no banner, no chatter). For the CLI restoring state between
|
|
260
|
+
# invocations; the banner already fired at the original `mode tx`.
|
|
261
|
+
value = str(value).lower()
|
|
262
|
+
if value not in C.MODES:
|
|
263
|
+
raise HackRFValueError(
|
|
264
|
+
f"mode must be one of {C.MODES}, got {value!r}")
|
|
265
|
+
self._mode = value
|
|
266
|
+
return self._mode
|
|
267
|
+
|
|
268
|
+
def require_mode(self, needed):
|
|
269
|
+
if self._mode != needed:
|
|
270
|
+
raise HackRFModeError(
|
|
271
|
+
f"operation requires '{needed}' mode but device is in "
|
|
272
|
+
f"'{self._mode}' mode. Switch first (set_mode('{needed}') or "
|
|
273
|
+
f"`hrf mode {needed}`).")
|
|
274
|
+
|
|
275
|
+
def _tx_safety_banner(self):
|
|
276
|
+
# Always printed (not gated on verbose): switching to TX is a moment
|
|
277
|
+
# that deserves a clear, one-time notice regardless of verbosity.
|
|
278
|
+
print("=" * 64)
|
|
279
|
+
print(" TX MODE ARMED -- the device can now transmit.")
|
|
280
|
+
print(" Transmitting is regulated. You are responsible for operating")
|
|
281
|
+
print(" within your license privileges, power limits, and local law.")
|
|
282
|
+
print(f" Gain is capped at {C.TX_VGA_CEILING_DB} dB by constants.py.")
|
|
283
|
+
print("=" * 64)
|
|
284
|
+
|
|
285
|
+
# =================================================================
|
|
286
|
+
# Validation layer
|
|
287
|
+
# =================================================================
|
|
288
|
+
def _check_hard_range(self, name, value, lo, hi, warn_below=None):
|
|
289
|
+
# Reject-by-default outside [lo, hi]; --force downgrades to a warning.
|
|
290
|
+
# warn_below: a soft floor that only ever warns (e.g. low sample rate).
|
|
291
|
+
value = float(value)
|
|
292
|
+
if value < lo or value > hi:
|
|
293
|
+
msg = (f"{name}={value:g} is outside the device range "
|
|
294
|
+
f"[{lo:g}, {hi:g}]")
|
|
295
|
+
if self.allow_out_of_spec:
|
|
296
|
+
self.warn("forced out-of-spec: " + msg)
|
|
297
|
+
else:
|
|
298
|
+
raise HackRFValueError(
|
|
299
|
+
msg + " (pass --force / allow_out_of_spec=True to override)")
|
|
300
|
+
elif warn_below is not None and value < warn_below:
|
|
301
|
+
self.warn(
|
|
302
|
+
f"{name}={value:g} is below the recommended "
|
|
303
|
+
f"{warn_below:g}; results may be degraded.")
|
|
304
|
+
return value
|
|
305
|
+
|
|
306
|
+
def _snap_gain(self, name, value, table):
|
|
307
|
+
# Round DOWN to the device's real step and notify. This is the honest
|
|
308
|
+
# "what you'll actually get" value, matching the silicon. A request
|
|
309
|
+
# that snaps to a DIFFERENT value is surfaced via warn() (not just
|
|
310
|
+
# verbose) because e.g. lna=7 -> 0 is a silent 7 dB loss otherwise.
|
|
311
|
+
lo, hi, step = table
|
|
312
|
+
try:
|
|
313
|
+
v = int(value)
|
|
314
|
+
except (TypeError, ValueError):
|
|
315
|
+
raise HackRFValueError(f"{name} must be a number, got {value!r}")
|
|
316
|
+
clamped = min(max(v, lo), hi)
|
|
317
|
+
snapped = lo + ((clamped - lo) // step) * step
|
|
318
|
+
if snapped != v:
|
|
319
|
+
self.warn(
|
|
320
|
+
f"{name} {value} -> {snapped} dB "
|
|
321
|
+
f"(snapped to device step <= request, range [{lo},{hi}]/{step}dB)")
|
|
322
|
+
return snapped
|
|
323
|
+
|
|
324
|
+
def _snap_baseband(self, value):
|
|
325
|
+
# Snap an explicit baseband BW to the nearest supported value.
|
|
326
|
+
opts = C.BASEBAND_FILTER_BW_HZ
|
|
327
|
+
nearest = min(opts, key=lambda o: abs(o - value))
|
|
328
|
+
if nearest != value:
|
|
329
|
+
self.print_message(
|
|
330
|
+
f"[*] baseband BW {value/1e6:g} -> {nearest/1e6:g} MHz "
|
|
331
|
+
f"(nearest supported)")
|
|
332
|
+
return nearest
|
|
333
|
+
|
|
334
|
+
def _auto_baseband(self, sample_rate, explicit=None):
|
|
335
|
+
# If unset, derive ~0.75x sample rate snapped to a supported BW. If set
|
|
336
|
+
# explicitly, snap it and warn when it exceeds the sample rate (a
|
|
337
|
+
# likely mistake -- you can't filter wider than you sample).
|
|
338
|
+
if explicit is None:
|
|
339
|
+
target = sample_rate * C.BASEBAND_AUTO_FRACTION
|
|
340
|
+
return self._snap_baseband(target)
|
|
341
|
+
bw = self._snap_baseband(explicit)
|
|
342
|
+
if bw > sample_rate:
|
|
343
|
+
self.warn(
|
|
344
|
+
f"baseband BW {bw/1e6:g} MHz exceeds sample rate "
|
|
345
|
+
f"{sample_rate/1e6:g} Msps.")
|
|
346
|
+
return bw
|
|
347
|
+
|
|
348
|
+
def validate_rx(self, freq, sample_rate, lna, vga):
|
|
349
|
+
freq = self._check_hard_range("frequency", freq,
|
|
350
|
+
C.FREQ_MIN_HZ, C.FREQ_MAX_HZ)
|
|
351
|
+
sample_rate = self._check_hard_range("sample_rate", sample_rate,
|
|
352
|
+
C.SR_MIN, C.SR_MAX, C.SR_WARN_BELOW)
|
|
353
|
+
lna = self._snap_gain("lna_gain", lna, C.LNA_GAIN)
|
|
354
|
+
vga = self._snap_gain("vga_gain", vga, C.VGA_GAIN)
|
|
355
|
+
return freq, sample_rate, lna, vga
|
|
356
|
+
|
|
357
|
+
def validate_tx(self, freq, sample_rate, txvga, amp):
|
|
358
|
+
# NOTE: TX frequency uses the full device range and is never policed by
|
|
359
|
+
# --force; the only gate is being in TX mode. The gain ceiling guards
|
|
360
|
+
# against an order-of-magnitude fat-finger.
|
|
361
|
+
freq = self._check_hard_range("frequency", freq,
|
|
362
|
+
C.FREQ_MIN_HZ, C.FREQ_MAX_HZ)
|
|
363
|
+
sample_rate = self._check_hard_range("sample_rate", sample_rate,
|
|
364
|
+
C.SR_MIN, C.SR_MAX, C.SR_WARN_BELOW)
|
|
365
|
+
# Check the RAW request against the ceiling BEFORE snapping, so an
|
|
366
|
+
# order-of-magnitude fat-finger (e.g. 470 for 47) rejects instead of
|
|
367
|
+
# being silently clamped to the device max.
|
|
368
|
+
if txvga > C.TX_VGA_CEILING_DB:
|
|
369
|
+
raise HackRFValueError(
|
|
370
|
+
f"txvga_gain {txvga} exceeds the TX ceiling "
|
|
371
|
+
f"{C.TX_VGA_CEILING_DB} dB set in constants.py")
|
|
372
|
+
txvga = self._snap_gain("txvga_gain", txvga, C.TXVGA_GAIN)
|
|
373
|
+
if amp and not C.TX_AMP_ALLOWED:
|
|
374
|
+
raise HackRFValueError("TX amplifier disabled in constants.py")
|
|
375
|
+
return freq, sample_rate, txvga, amp
|
|
376
|
+
|
|
377
|
+
# =================================================================
|
|
378
|
+
# Binary resolution
|
|
379
|
+
# =================================================================
|
|
380
|
+
def resolve(self, key):
|
|
381
|
+
# key is a TOOLS key ("transfer", "info", ...). Returns the full path
|
|
382
|
+
# or raises HackRFDeviceError with an actionable message.
|
|
383
|
+
name = C.TOOLS[key]
|
|
384
|
+
if self.tools_dir:
|
|
385
|
+
# On Windows the real binaries are hackrf_transfer.exe etc., so a
|
|
386
|
+
# bare-name check misses them. Try the bare name first (POSIX),
|
|
387
|
+
# then each executable extension from PATHEXT (Windows). This is
|
|
388
|
+
# the documented Windows workflow: tools_dir -> a hackrf-tools
|
|
389
|
+
# folder that isn't on PATH.
|
|
390
|
+
exts = [""]
|
|
391
|
+
if os.name == "nt":
|
|
392
|
+
exts += [e.lower() for e in
|
|
393
|
+
os.environ.get("PATHEXT", ".EXE;.BAT;.CMD").split(";")
|
|
394
|
+
if e]
|
|
395
|
+
for ext in exts:
|
|
396
|
+
cand = os.path.join(self.tools_dir, name + ext)
|
|
397
|
+
if os.path.isfile(cand) and os.access(cand, os.X_OK):
|
|
398
|
+
return cand
|
|
399
|
+
path = shutil.which(name)
|
|
400
|
+
if path is None:
|
|
401
|
+
raise HackRFDeviceError(
|
|
402
|
+
f"'{name}' not found. Install hackrf-tools (Great Scott "
|
|
403
|
+
f"Gadgets) or set tools_dir / [tools].dir in config.")
|
|
404
|
+
return path
|
|
405
|
+
|
|
406
|
+
# =================================================================
|
|
407
|
+
# Capability probe
|
|
408
|
+
# =================================================================
|
|
409
|
+
@classmethod
|
|
410
|
+
def from_device(cls, *, tools_dir=None, verbose=False, serial=None):
|
|
411
|
+
# Build a HackRF and immediately probe the attached board so callers
|
|
412
|
+
# get a device whose reported firmware is known up front. Unlike the
|
|
413
|
+
# bare constructor (which touches nothing), this RUNS hackrf_info, so
|
|
414
|
+
# it raises HackRFDeviceError if tools are missing or no board is
|
|
415
|
+
# present. Use it when you want a fail-fast handle for scripting.
|
|
416
|
+
h = cls(tools_dir=tools_dir, verbose=verbose, serial=serial)
|
|
417
|
+
info = h.info() # raises if no tools / no board
|
|
418
|
+
if not info.get("boards"):
|
|
419
|
+
raise HackRFDeviceError("no HackRF board detected")
|
|
420
|
+
h._probed = info
|
|
421
|
+
h._warn_if_firmware_stale(info)
|
|
422
|
+
return h
|
|
423
|
+
|
|
424
|
+
def _warn_if_firmware_stale(self, info):
|
|
425
|
+
# The subprocess approach is firmware-version sensitive: '-r -' stdout
|
|
426
|
+
# streaming and 'sweep -N' need reasonably modern tools. Surface a
|
|
427
|
+
# warning (not a raise) so old boards still work for what they can do.
|
|
428
|
+
import re as _re
|
|
429
|
+
ver = (info.get("library", {}) or {}).get("hackrf_info_version", "")
|
|
430
|
+
m = _re.match(r"(\d{4})", ver or "")
|
|
431
|
+
if m and int(m.group(1)) < 2021:
|
|
432
|
+
self.warn(
|
|
433
|
+
f"hackrf-tools {ver} predates 2021; stdout streaming "
|
|
434
|
+
f"(rx to_stdout / capture_array) and 'sweep -N' may be "
|
|
435
|
+
f"unsupported -- consider upgrading.")
|
|
436
|
+
|
|
437
|
+
# =================================================================
|
|
438
|
+
# The device-I/O choke point
|
|
439
|
+
# =================================================================
|
|
440
|
+
def _run(self, argv, *, mode="blocking", duration=None,
|
|
441
|
+
text=False, check=True, print_cmd=False, kind="rx",
|
|
442
|
+
read_samples=65536):
|
|
443
|
+
# argv: full command list whose [0] is a TOOLS key, e.g.
|
|
444
|
+
# ["transfer", "-r", "out.iq", ...]; resolved to a real path here.
|
|
445
|
+
#
|
|
446
|
+
# mode:
|
|
447
|
+
# "blocking" -> process self-terminates (-n / sweep -1). Wait, return
|
|
448
|
+
# (stdout, stderr, returncode).
|
|
449
|
+
# "timed" -> run `duration` s, then SIGINT, return result.
|
|
450
|
+
# "stream" -> GENERATOR yielding raw stdout chunks/lines as they
|
|
451
|
+
# arrive (sweep CSV, rx -r -). Caller decodes.
|
|
452
|
+
# "handle" -> return a _Process the caller drives with .stop().
|
|
453
|
+
#
|
|
454
|
+
# print_cmd: if True, print the resolved command and DO NOT run it
|
|
455
|
+
# (the --print-cmd / dry-run feature). Returns None so a
|
|
456
|
+
# dry run is never mistaken for a result tuple / handle.
|
|
457
|
+
resolved = [self.resolve(argv[0])] + [str(a) for a in argv[1:]]
|
|
458
|
+
if self.serial and argv[0] in C.TOOLS_WITH_SERIAL:
|
|
459
|
+
resolved[1:1] = ["-d", str(self.serial)]
|
|
460
|
+
|
|
461
|
+
if print_cmd:
|
|
462
|
+
print(" ".join(resolved))
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
self.print_message("[*] exec: " + " ".join(resolved))
|
|
466
|
+
|
|
467
|
+
if mode == "stream":
|
|
468
|
+
return self._stream(resolved, text=text, read_samples=read_samples)
|
|
469
|
+
if mode == "handle":
|
|
470
|
+
proc = subprocess.Popen(resolved, stdout=subprocess.PIPE,
|
|
471
|
+
stderr=subprocess.PIPE,
|
|
472
|
+
creationflags=_CREATION_FLAGS)
|
|
473
|
+
handle = _Process(proc, self, kind=kind)
|
|
474
|
+
# Tier B: TX always backstopped; RX backstopped unless opted out.
|
|
475
|
+
if kind == "tx" or self.backstop_rx:
|
|
476
|
+
_register_live(handle)
|
|
477
|
+
return handle
|
|
478
|
+
|
|
479
|
+
if mode == "timed" and duration is None:
|
|
480
|
+
raise HackRFValueError("timed mode requires duration")
|
|
481
|
+
|
|
482
|
+
proc = subprocess.Popen(resolved, stdout=subprocess.PIPE,
|
|
483
|
+
stderr=subprocess.PIPE, text=text,
|
|
484
|
+
creationflags=_CREATION_FLAGS)
|
|
485
|
+
try:
|
|
486
|
+
if mode == "timed":
|
|
487
|
+
# communicate(timeout=) drains stdout/stderr WHILE waiting, so
|
|
488
|
+
# a chatty child can't fill the pipe and stall (the old poll
|
|
489
|
+
# loop drained nothing until the end).
|
|
490
|
+
try:
|
|
491
|
+
out, err = proc.communicate(timeout=duration)
|
|
492
|
+
except subprocess.TimeoutExpired:
|
|
493
|
+
out, err = self._sigint_and_reap(proc)
|
|
494
|
+
else:
|
|
495
|
+
out, err = proc.communicate()
|
|
496
|
+
except KeyboardInterrupt:
|
|
497
|
+
# Stop the child cleanly, then RE-RAISE. Swallowing Ctrl-C here
|
|
498
|
+
# made it impossible to break out of multi-_run loops (segmented
|
|
499
|
+
# capture would just start the next segment).
|
|
500
|
+
self._sigint_and_reap(proc)
|
|
501
|
+
raise
|
|
502
|
+
|
|
503
|
+
rc = proc.returncode
|
|
504
|
+
# timed runs end via our SIGINT, so a non-zero rc there is expected.
|
|
505
|
+
if check and mode != "timed" and rc not in (0, None):
|
|
506
|
+
errtxt = err.decode(errors="replace") if isinstance(err, bytes) else err
|
|
507
|
+
raise HackRFDeviceError(
|
|
508
|
+
f"{C.TOOLS[argv[0]]} exited {rc}: {errtxt.strip()}")
|
|
509
|
+
return out, err, rc
|
|
510
|
+
|
|
511
|
+
@staticmethod
|
|
512
|
+
def _sigint_and_reap(proc, grace=5.0):
|
|
513
|
+
# SIGINT (clean flush/close in hackrf_*), bounded wait, escalate.
|
|
514
|
+
_interrupt(proc)
|
|
515
|
+
try:
|
|
516
|
+
return proc.communicate(timeout=grace)
|
|
517
|
+
except subprocess.TimeoutExpired:
|
|
518
|
+
proc.terminate()
|
|
519
|
+
return proc.communicate()
|
|
520
|
+
|
|
521
|
+
def _stream(self, resolved, text=False, read_samples=65536):
|
|
522
|
+
# Generator twin of _run. Launch, yield stdout as it arrives, clean up
|
|
523
|
+
# on the caller breaking out (GeneratorExit) so we never leave a
|
|
524
|
+
# transmitting/receiving process orphaned.
|
|
525
|
+
#
|
|
526
|
+
# read_samples sets the stdout read granularity (in I/Q samples). Bigger
|
|
527
|
+
# = fewer syscalls / higher sustained throughput; smaller = lower
|
|
528
|
+
# per-block latency. The default 65536 (128 KB) is a balance; the
|
|
529
|
+
# persistent receiver and benchmark can raise it to push sustained rate.
|
|
530
|
+
#
|
|
531
|
+
# stderr is drained continuously by a daemon thread (capped tail) --
|
|
532
|
+
# an undrained PIPE eventually fills and blocks the child. If the
|
|
533
|
+
# child dies on its own with a non-zero code (e.g. no board found),
|
|
534
|
+
# we now RAISE with the stderr tail instead of silently yielding
|
|
535
|
+
# nothing and looking like an empty-but-successful run.
|
|
536
|
+
proc = subprocess.Popen(resolved, stdout=subprocess.PIPE,
|
|
537
|
+
stderr=subprocess.PIPE, text=text,
|
|
538
|
+
creationflags=_CREATION_FLAGS)
|
|
539
|
+
err_tail = []
|
|
540
|
+
err_cap = 64 * 1024
|
|
541
|
+
|
|
542
|
+
def _eat(stream, sink):
|
|
543
|
+
try:
|
|
544
|
+
for data in iter(lambda: stream.read(4096), b"" if not text else ""):
|
|
545
|
+
sink.append(data)
|
|
546
|
+
while sum(len(d) for d in sink) > err_cap and len(sink) > 1:
|
|
547
|
+
sink.pop(0)
|
|
548
|
+
except (OSError, ValueError):
|
|
549
|
+
pass
|
|
550
|
+
|
|
551
|
+
t = threading.Thread(target=_eat, args=(proc.stderr, err_tail),
|
|
552
|
+
daemon=True)
|
|
553
|
+
t.start()
|
|
554
|
+
broke_out = True
|
|
555
|
+
try:
|
|
556
|
+
if text:
|
|
557
|
+
for line in proc.stdout:
|
|
558
|
+
yield line
|
|
559
|
+
else:
|
|
560
|
+
# read1() returns whatever is available after ONE underlying
|
|
561
|
+
# read, instead of blocking until the full count or EOF. With
|
|
562
|
+
# plain read(n) a continuous source (hackrf_transfer -r -) that
|
|
563
|
+
# has only a partial chunk buffered would block, stalling
|
|
564
|
+
# low-latency consumers and making bounded capture_array hang
|
|
565
|
+
# until a full 128 KB accumulated. Cap the request so we never
|
|
566
|
+
# split an I/Q pair across yields.
|
|
567
|
+
while True:
|
|
568
|
+
chunk = proc.stdout.read1(C.BYTES_PER_SAMPLE * read_samples)
|
|
569
|
+
if not chunk:
|
|
570
|
+
break
|
|
571
|
+
yield chunk
|
|
572
|
+
broke_out = False # natural EOF, not a caller break
|
|
573
|
+
finally:
|
|
574
|
+
if proc.poll() is None:
|
|
575
|
+
_interrupt(proc)
|
|
576
|
+
try:
|
|
577
|
+
proc.wait(timeout=2.0)
|
|
578
|
+
except subprocess.TimeoutExpired:
|
|
579
|
+
proc.terminate()
|
|
580
|
+
if not broke_out and proc.returncode not in (0, None):
|
|
581
|
+
t.join(timeout=2.0) # let the drain finish before reading it
|
|
582
|
+
err = (b"" if not text else "").join(err_tail)
|
|
583
|
+
if isinstance(err, bytes):
|
|
584
|
+
err = err.decode(errors="replace")
|
|
585
|
+
raise HackRFDeviceError(
|
|
586
|
+
f"{os.path.basename(resolved[0])} exited "
|
|
587
|
+
f"{proc.returncode}: {err.strip()}")
|
|
588
|
+
|
|
589
|
+
# =================================================================
|
|
590
|
+
# Shared helpers used by mixins
|
|
591
|
+
# =================================================================
|
|
592
|
+
def decode_iq(self, raw):
|
|
593
|
+
# HackRF native format -> complex64. Interleaved int8 I,Q,I,Q...
|
|
594
|
+
# Guard against an odd trailing byte (truncated final pair).
|
|
595
|
+
#
|
|
596
|
+
# Direct construction into a preallocated complex64 array (assigning
|
|
597
|
+
# the int8 I/Q slices straight into .real/.imag) is ~3x faster than
|
|
598
|
+
# going via a full float32 intermediate. At 20 Msps (40 MB/s) the old
|
|
599
|
+
# path left ~2.4x headroom; this leaves ~7x, which matters when other
|
|
600
|
+
# Python work is competing for the GIL during a live capture.
|
|
601
|
+
a = np.frombuffer(raw, dtype=np.int8)
|
|
602
|
+
if a.size % 2:
|
|
603
|
+
a = a[:-1]
|
|
604
|
+
iq = np.empty(a.size // 2, dtype=np.complex64)
|
|
605
|
+
iq.real = a[0::2]
|
|
606
|
+
iq.imag = a[1::2]
|
|
607
|
+
iq /= 128.0 # normalize int8 full-scale to ~[-1, 1)
|
|
608
|
+
return iq
|
|
609
|
+
|
|
610
|
+
# ---- power / relative calibration (Level 1) ----------------------------
|
|
611
|
+
# These turn the device's arbitrary dBFS amplitude into something that is
|
|
612
|
+
# at least CONSISTENT across gain settings (and, with a user-supplied
|
|
613
|
+
# offset, approximately absolute). They are PURE math on values you give
|
|
614
|
+
# them -- the library never invents reference data or claims a calibrated
|
|
615
|
+
# dBm figure it cannot honestly produce. Producing the offset and any
|
|
616
|
+
# frequency-correction table is a hardware+reference workflow; see
|
|
617
|
+
# examples/calibrate.py.
|
|
618
|
+
|
|
619
|
+
@staticmethod
|
|
620
|
+
def power_dbfs(iq):
|
|
621
|
+
# Mean power of a complex64 block in dBFS (dB relative to full scale).
|
|
622
|
+
# 0 dBFS == |amplitude| 1.0 (ADC full scale). Always <= 0 for real
|
|
623
|
+
# captures. This is the raw, UNCALIBRATED reading.
|
|
624
|
+
if len(iq) == 0:
|
|
625
|
+
return float("-inf")
|
|
626
|
+
power = float(np.mean((iq.real.astype(np.float64) ** 2
|
|
627
|
+
+ iq.imag.astype(np.float64) ** 2)))
|
|
628
|
+
return 10.0 * np.log10(power + 1e-20)
|
|
629
|
+
|
|
630
|
+
@staticmethod
|
|
631
|
+
def gain_db(lna=0, vga=0, amp=False):
|
|
632
|
+
# Total RX gain through the chain in dB: LNA (IF) + VGA (baseband) +
|
|
633
|
+
# the fixed ~14 dB front-end amp if enabled. This is the quantity that
|
|
634
|
+
# makes a raw dBFS reading ambiguous -- the SAME signal reads ~36 dB
|
|
635
|
+
# different between min and max gain.
|
|
636
|
+
return float(lna) + float(vga) + (C.AMP_DB if amp else 0.0)
|
|
637
|
+
|
|
638
|
+
def relative_power_db(self, iq_or_dbfs, *, lna=None, vga=None, amp=None,
|
|
639
|
+
offset_db=0.0, freq_hz=None, freq_correction=None):
|
|
640
|
+
# Gain-normalized power: subtract the gain chain so readings taken at
|
|
641
|
+
# DIFFERENT gain settings are directly comparable. This is the Level 1
|
|
642
|
+
# relative calibration -- still not absolute dBm, but consistent.
|
|
643
|
+
#
|
|
644
|
+
# value = dBFS - total_gain_dB + offset_db [- freq_correction(freq)]
|
|
645
|
+
#
|
|
646
|
+
# iq_or_dbfs: a complex64 block (its power is measured) OR a dBFS float
|
|
647
|
+
# lna/vga/amp: gain settings; if omitted, taken from last_params
|
|
648
|
+
# offset_db: your single-point reference offset (Level 3), if any.
|
|
649
|
+
# With a correct offset this approximates dBm; without
|
|
650
|
+
# one it is relative dB (still gain-consistent).
|
|
651
|
+
# freq_hz + freq_correction: optional per-frequency correction. If you
|
|
652
|
+
# pass a callable freq_correction(freq_hz)->dB (e.g. built
|
|
653
|
+
# from a sweep of a flat source, see examples/calibrate.py),
|
|
654
|
+
# it is subtracted to flatten the front-end response
|
|
655
|
+
# (Level 2). The library ships NO built-in curve, because
|
|
656
|
+
# front-end response varies per unit.
|
|
657
|
+
dbfs = (iq_or_dbfs if isinstance(iq_or_dbfs, (int, float))
|
|
658
|
+
else self.power_dbfs(iq_or_dbfs))
|
|
659
|
+
lp = self.last_params or {}
|
|
660
|
+
if lna is None:
|
|
661
|
+
lna = lp.get("lna_gain", lp.get("lna", 0))
|
|
662
|
+
if vga is None:
|
|
663
|
+
vga = lp.get("vga_gain", lp.get("vga", 0))
|
|
664
|
+
if amp is None:
|
|
665
|
+
amp = lp.get("amp", False)
|
|
666
|
+
value = dbfs - self.gain_db(lna, vga, amp) + float(offset_db)
|
|
667
|
+
if freq_hz is not None and freq_correction is not None:
|
|
668
|
+
value -= float(freq_correction(freq_hz))
|
|
669
|
+
return value
|
|
670
|
+
|
|
671
|
+
return load_iq(path, count=count, offset_samples=offset_samples)
|
|
672
|
+
|
|
673
|
+
def estimate_capture(self, sample_rate, num_samples=None, duration=None,
|
|
674
|
+
path="."):
|
|
675
|
+
# Bytes/sec = sample_rate * 2 (int8 I + int8 Q). Returns a dict and
|
|
676
|
+
# raises HackRFEnvironmentError if it would blow past free disk.
|
|
677
|
+
bps = sample_rate * C.BYTES_PER_SAMPLE
|
|
678
|
+
if num_samples is not None:
|
|
679
|
+
total = num_samples * C.BYTES_PER_SAMPLE
|
|
680
|
+
secs = num_samples / sample_rate
|
|
681
|
+
elif duration is not None:
|
|
682
|
+
total = int(bps * duration)
|
|
683
|
+
secs = duration
|
|
684
|
+
else:
|
|
685
|
+
total = None # open-ended (handle mode)
|
|
686
|
+
secs = None
|
|
687
|
+
free = shutil.disk_usage(path).free
|
|
688
|
+
info = {"bytes_per_sec": bps, "total_bytes": total,
|
|
689
|
+
"seconds": secs, "free_bytes": free}
|
|
690
|
+
if total is not None and total > free * C.DEFAULT_DISK_HEADROOM:
|
|
691
|
+
raise HackRFEnvironmentError(
|
|
692
|
+
f"capture needs ~{total/1e9:.2f} GB but only "
|
|
693
|
+
f"{free/1e9:.2f} GB free at {path}")
|
|
694
|
+
return info
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
# =====================================================================
|
|
698
|
+
# Module-level helpers: consume recordings WITHOUT a device / HackRF()
|
|
699
|
+
# =====================================================================
|
|
700
|
+
def parse_freq(txt):
|
|
701
|
+
"""Parse '433.92M', '88M', '1.09G', '2.5k', '100MHz', or plain Hz -> float Hz.
|
|
702
|
+
|
|
703
|
+
Lives at module scope (not just in the CLI) so library callers can accept
|
|
704
|
+
the same human notation. Raises HackRFValueError on bad input instead of a
|
|
705
|
+
bare ValueError, so the CLI's typed-exception handler catches it.
|
|
706
|
+
"""
|
|
707
|
+
s = str(txt).strip()
|
|
708
|
+
# tolerate a trailing 'Hz'/'hz' so '433.92MHz' works, not just '433.92M'
|
|
709
|
+
if s[-2:].lower() == "hz":
|
|
710
|
+
s = s[:-2]
|
|
711
|
+
mult = 1.0
|
|
712
|
+
if s and s[-1] in "kKmMgG":
|
|
713
|
+
mult = {"k": 1e3, "m": 1e6, "g": 1e9}[s[-1].lower()]
|
|
714
|
+
s = s[:-1]
|
|
715
|
+
try:
|
|
716
|
+
return float(s) * mult
|
|
717
|
+
except ValueError:
|
|
718
|
+
raise HackRFValueError(
|
|
719
|
+
f"could not parse frequency {txt!r} "
|
|
720
|
+
f"(try 433.92M, 1.09G, 2.5k, or plain Hz)")
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def load_iq(path: str, count: int | None = None,
|
|
724
|
+
offset_samples: int = 0) -> np.ndarray:
|
|
725
|
+
"""Load a HackRF int8 I/Q recording into a complex64 numpy array.
|
|
726
|
+
|
|
727
|
+
The other half of capture(): other code's entry point into a recording.
|
|
728
|
+
Reads with np.fromfile (no whole-file Python bytes copy), drops a
|
|
729
|
+
truncated trailing byte, and normalizes int8 full-scale to ~[-1, 1).
|
|
730
|
+
|
|
731
|
+
count: number of complex samples to read (None = all)
|
|
732
|
+
offset_samples: complex samples to skip from the start of the file
|
|
733
|
+
"""
|
|
734
|
+
n_bytes = -1 if count is None else int(count) * C.BYTES_PER_SAMPLE
|
|
735
|
+
a = np.fromfile(path, dtype=np.int8, count=n_bytes,
|
|
736
|
+
offset=int(offset_samples) * C.BYTES_PER_SAMPLE)
|
|
737
|
+
if a.size % 2:
|
|
738
|
+
a = a[:-1]
|
|
739
|
+
iq = np.empty(a.size // 2, dtype=np.complex64) # fast direct construction
|
|
740
|
+
iq.real = a[0::2]
|
|
741
|
+
iq.imag = a[1::2]
|
|
742
|
+
iq /= 128.0
|
|
743
|
+
return iq
|