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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
|
|
3
|
+
##--------------------------------------------------------------------\
|
|
4
|
+
# hackrfpy
|
|
5
|
+
# 'src/hackrfpy/_commands/sweep.py'
|
|
6
|
+
#
|
|
7
|
+
# SweepMixin: spectrum sweep via hackrf_sweep. hackrf_sweep emits CSV to
|
|
8
|
+
# stdout continuously until stopped, so this is the streaming/generator case
|
|
9
|
+
# -- the analog of a continuous-acquisition loop. Each CSV row is:
|
|
10
|
+
# date, time, hz_low, hz_high, hz_bin_width, num_samples, dB, dB, ...
|
|
11
|
+
# We yield parsed rows so the caller can plot/store/decode as they arrive.
|
|
12
|
+
#
|
|
13
|
+
# Author(s): <you>
|
|
14
|
+
##--------------------------------------------------------------------\
|
|
15
|
+
|
|
16
|
+
from .. import constants as C
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SweepMixin:
|
|
20
|
+
def sweep(self, f_min_hz, f_max_hz, *, bin_width=None, lna=16, vga=20,
|
|
21
|
+
amp=False, one_shot=False, num_sweeps=None, print_cmd=False):
|
|
22
|
+
# GENERATOR yielding parsed rows (dicts). Validate the band edges with
|
|
23
|
+
# the same hard-range logic, snap gains. hackrf_sweep takes the range
|
|
24
|
+
# in MHz as f_min:f_max.
|
|
25
|
+
f_min_hz = self._check_hard_range("f_min", f_min_hz,
|
|
26
|
+
C.FREQ_MIN_HZ, C.FREQ_MAX_HZ)
|
|
27
|
+
f_max_hz = self._check_hard_range("f_max", f_max_hz,
|
|
28
|
+
C.FREQ_MIN_HZ, C.FREQ_MAX_HZ)
|
|
29
|
+
if f_min_hz >= f_max_hz:
|
|
30
|
+
from ..exceptions import HackRFValueError
|
|
31
|
+
raise HackRFValueError("f_min must be < f_max")
|
|
32
|
+
lna = self._snap_gain("lna_gain", lna, C.LNA_GAIN)
|
|
33
|
+
vga = self._snap_gain("vga_gain", vga, C.VGA_GAIN)
|
|
34
|
+
self._record_params(f_min=f_min_hz, f_max=f_max_hz, lna=lna, vga=vga,
|
|
35
|
+
amp=amp, bin_width=bin_width, mode="rx")
|
|
36
|
+
self.print_message(f"[*] mode: {self.mode}")
|
|
37
|
+
|
|
38
|
+
lo = int(f_min_hz // 1_000_000)
|
|
39
|
+
hi = int(f_max_hz // 1_000_000)
|
|
40
|
+
# hackrf_sweep takes integer MHz edges, so sub-MHz precision is lost.
|
|
41
|
+
# Warn rather than silently shift the band the user asked for.
|
|
42
|
+
if f_min_hz % 1_000_000 or f_max_hz % 1_000_000:
|
|
43
|
+
self.warn(
|
|
44
|
+
f"sweep edges snapped to MHz: "
|
|
45
|
+
f"{f_min_hz/1e6:g}:{f_max_hz/1e6:g} -> {lo}:{hi} MHz "
|
|
46
|
+
f"(hackrf_sweep takes integer MHz)")
|
|
47
|
+
argv = ["sweep", "-f", f"{lo}:{hi}", "-l", lna, "-g", vga,
|
|
48
|
+
"-a", 1 if amp else 0]
|
|
49
|
+
if bin_width is not None:
|
|
50
|
+
argv += ["-w", int(bin_width)]
|
|
51
|
+
if one_shot:
|
|
52
|
+
argv += ["-1"]
|
|
53
|
+
if num_sweeps is not None:
|
|
54
|
+
argv += ["-N", int(num_sweeps)]
|
|
55
|
+
|
|
56
|
+
if print_cmd:
|
|
57
|
+
self._run(argv, mode="blocking", print_cmd=True)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
def _gen():
|
|
61
|
+
# Explicitly close the inner stream generator on the way out.
|
|
62
|
+
# Relying on GC to do it is not deterministic, and an unclosed
|
|
63
|
+
# inner generator leaves hackrf_sweep running ORPHANED after the
|
|
64
|
+
# caller breaks out of the loop.
|
|
65
|
+
#
|
|
66
|
+
# NOTE on ordering: real hackrf_sweep does NOT emit frequency
|
|
67
|
+
# segments low-to-high -- it interleaves them (observed: 88, 98,
|
|
68
|
+
# 93, 103 MHz for an 88:108 sweep). All rows of ONE sweep share a
|
|
69
|
+
# timestamp. Consumers that need a contiguous spectrum must group
|
|
70
|
+
# by timestamp and sort segments by hz_low; do NOT assume the rows
|
|
71
|
+
# arrive in frequency order.
|
|
72
|
+
inner = self._run(argv, mode="stream", text=True)
|
|
73
|
+
try:
|
|
74
|
+
for line in inner:
|
|
75
|
+
row = self.parse_sweep_line(line)
|
|
76
|
+
if row is not None:
|
|
77
|
+
yield row
|
|
78
|
+
finally:
|
|
79
|
+
inner.close()
|
|
80
|
+
return _gen()
|
|
81
|
+
|
|
82
|
+
def sweep_collect(self, f_min_hz, f_max_hz, num_sweeps=1, **k):
|
|
83
|
+
# Convenience: collect a bounded number of sweeps into a list.
|
|
84
|
+
k.pop("num_sweeps", None)
|
|
85
|
+
return list(self.sweep(f_min_hz, f_max_hz, num_sweeps=num_sweeps, **k))
|
|
86
|
+
|
|
87
|
+
def monitor_frequencies(self, freqs_hz, *, span_hz=2_000_000, duration=None,
|
|
88
|
+
on_update=None, lna=16, vga=20, amp=False):
|
|
89
|
+
# Watch POWER over time at several frequencies, backed by hackrf_sweep's
|
|
90
|
+
# fast internal hardware retuning. This is DELIBERATELY a different
|
|
91
|
+
# method from scan_frequencies():
|
|
92
|
+
# - scan_frequencies() returns IQ SAMPLES (complex64) per frequency,
|
|
93
|
+
# via separate hackrf_transfer captures (re-open gap between each).
|
|
94
|
+
# - monitor_frequencies() returns POWER (dB) per frequency over time,
|
|
95
|
+
# via one continuous hackrf_sweep. No IQ -- spectrum bins only.
|
|
96
|
+
# Use this for "is there activity on these channels?" monitoring; use
|
|
97
|
+
# scan_frequencies for "give me the samples at each frequency."
|
|
98
|
+
#
|
|
99
|
+
# Yields dicts: {freq_hz: power_db, ...} once per sweep pass, mapping
|
|
100
|
+
# each requested frequency to the dB of the sweep bin covering it.
|
|
101
|
+
# Runs until `duration` seconds elapse, on_update returns False, or the
|
|
102
|
+
# caller stops iterating (the underlying sweep is reaped on exit).
|
|
103
|
+
import time as _time
|
|
104
|
+
lo = min(freqs_hz) - span_hz
|
|
105
|
+
hi = max(freqs_hz) + span_hz
|
|
106
|
+
t0 = _time.time()
|
|
107
|
+
|
|
108
|
+
def _nearest_power(rows_by_low, f):
|
|
109
|
+
# find the sweep segment whose [hz_low, hz_high) covers f, return
|
|
110
|
+
# the mean dB of that segment's bins (a simple power proxy)
|
|
111
|
+
for low in sorted(rows_by_low):
|
|
112
|
+
r = rows_by_low[low]
|
|
113
|
+
if r["hz_low"] <= f < r["hz_high"]:
|
|
114
|
+
db = r["db"]
|
|
115
|
+
return sum(db) / len(db) if db else float("-inf")
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
from .._stream_ctx import StreamCtx
|
|
119
|
+
results = []
|
|
120
|
+
stopped = False
|
|
121
|
+
with StreamCtx(self.sweep(lo, hi, lna=lna, vga=vga, amp=amp)) as gen:
|
|
122
|
+
rows_by_low = {}
|
|
123
|
+
last_time = None
|
|
124
|
+
for row in gen:
|
|
125
|
+
if last_time is not None and row["time"] != last_time and rows_by_low:
|
|
126
|
+
update = {f: _nearest_power(rows_by_low, f) for f in freqs_hz}
|
|
127
|
+
if on_update is not None:
|
|
128
|
+
if on_update(update) is False:
|
|
129
|
+
stopped = True
|
|
130
|
+
break
|
|
131
|
+
else:
|
|
132
|
+
results.append(update)
|
|
133
|
+
rows_by_low = {}
|
|
134
|
+
if duration is not None and _time.time() - t0 >= duration:
|
|
135
|
+
break
|
|
136
|
+
rows_by_low[row["hz_low"]] = row
|
|
137
|
+
last_time = row["time"]
|
|
138
|
+
# flush the final buffered pass (stream ended before its timestamp
|
|
139
|
+
# rolled over) -- unless we were explicitly stopped by on_update
|
|
140
|
+
if rows_by_low and not stopped:
|
|
141
|
+
update = {f: _nearest_power(rows_by_low, f) for f in freqs_hz}
|
|
142
|
+
if on_update is not None:
|
|
143
|
+
on_update(update)
|
|
144
|
+
else:
|
|
145
|
+
results.append(update)
|
|
146
|
+
return None if on_update is not None else results
|
|
147
|
+
|
|
148
|
+
def sweep_to_file(self, f_min_hz, f_max_hz, out, *, binary=False,
|
|
149
|
+
inverse_fft=False, bin_width=None, lna=16, vga=20,
|
|
150
|
+
amp=False, one_shot=False, num_sweeps=None,
|
|
151
|
+
print_cmd=False):
|
|
152
|
+
# Write sweep output straight to a file instead of yielding parsed
|
|
153
|
+
# rows. This is the home for hackrf_sweep's binary-output flags, which
|
|
154
|
+
# don't fit the text-CSV generator:
|
|
155
|
+
# binary=True -> -B raw binary FFT-bin output
|
|
156
|
+
# inverse_fft=True -> -I binary inverse-FFT output (implies binary)
|
|
157
|
+
# With neither, this writes the normal CSV text to the file via -r.
|
|
158
|
+
# Returns the output path. The binary formats are NOT parsed by this
|
|
159
|
+
# library (parse_sweep_line is text-only); you own the format on read.
|
|
160
|
+
f_min_hz = self._check_hard_range("f_min", f_min_hz,
|
|
161
|
+
C.FREQ_MIN_HZ, C.FREQ_MAX_HZ)
|
|
162
|
+
f_max_hz = self._check_hard_range("f_max", f_max_hz,
|
|
163
|
+
C.FREQ_MIN_HZ, C.FREQ_MAX_HZ)
|
|
164
|
+
if f_min_hz >= f_max_hz:
|
|
165
|
+
from ..exceptions import HackRFValueError
|
|
166
|
+
raise HackRFValueError("f_min must be < f_max")
|
|
167
|
+
lna = self._snap_gain("lna_gain", lna, C.LNA_GAIN)
|
|
168
|
+
vga = self._snap_gain("vga_gain", vga, C.VGA_GAIN)
|
|
169
|
+
lo = int(f_min_hz // 1_000_000)
|
|
170
|
+
hi = int(f_max_hz // 1_000_000)
|
|
171
|
+
argv = ["sweep", "-f", f"{lo}:{hi}", "-l", lna, "-g", vga,
|
|
172
|
+
"-a", 1 if amp else 0, "-r", out]
|
|
173
|
+
if bin_width is not None:
|
|
174
|
+
argv += ["-w", int(bin_width)]
|
|
175
|
+
if one_shot:
|
|
176
|
+
argv += ["-1"]
|
|
177
|
+
if num_sweeps is not None:
|
|
178
|
+
argv += ["-N", int(num_sweeps)]
|
|
179
|
+
if inverse_fft:
|
|
180
|
+
argv += ["-I"] # binary inverse FFT (binary output mode)
|
|
181
|
+
elif binary:
|
|
182
|
+
argv += ["-B"] # raw binary output
|
|
183
|
+
res = self._run(argv, mode="blocking", print_cmd=print_cmd)
|
|
184
|
+
return None if print_cmd else out
|
|
185
|
+
|
|
186
|
+
def sweep_stream(self, f_min_hz, f_max_hz, **k):
|
|
187
|
+
# Context manager around the sweep generator so the underlying
|
|
188
|
+
# hackrf_sweep is ALWAYS reaped on exit -- including KeyboardInterrupt
|
|
189
|
+
# out of a live consumer loop (e.g. a waterfall). Without this, a
|
|
190
|
+
# caller that breaks out or Ctrl-C's relies on GC to close the
|
|
191
|
+
# generator, which can orphan the child:
|
|
192
|
+
# with h.sweep_stream(88e6, 108e6) as rows:
|
|
193
|
+
# for row in rows: ...
|
|
194
|
+
from .._stream_ctx import StreamCtx
|
|
195
|
+
gen = self.sweep(f_min_hz, f_max_hz, **k)
|
|
196
|
+
return StreamCtx(gen)
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def parse_sweep_line(line):
|
|
200
|
+
# Returns {date,time,hz_low,hz_high,bin_width,num_samples,db:[...]} or
|
|
201
|
+
# None for blank/garbled lines.
|
|
202
|
+
line = line.strip()
|
|
203
|
+
if not line:
|
|
204
|
+
return None
|
|
205
|
+
parts = [p.strip() for p in line.split(",")]
|
|
206
|
+
if len(parts) < 7:
|
|
207
|
+
return None
|
|
208
|
+
try:
|
|
209
|
+
return {
|
|
210
|
+
"date": parts[0],
|
|
211
|
+
"time": parts[1],
|
|
212
|
+
"hz_low": int(parts[2]),
|
|
213
|
+
"hz_high": int(parts[3]),
|
|
214
|
+
"bin_width": float(parts[4]),
|
|
215
|
+
"num_samples": int(parts[5]),
|
|
216
|
+
"db": [float(x) for x in parts[6:]],
|
|
217
|
+
}
|
|
218
|
+
except ValueError:
|
|
219
|
+
return None
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
|
|
3
|
+
##--------------------------------------------------------------------\
|
|
4
|
+
# hackrfpy
|
|
5
|
+
# 'src/hackrfpy/_commands/transmit.py'
|
|
6
|
+
#
|
|
7
|
+
# TransmitMixin: transmit via hackrf_transfer -t. Gated entirely by operating
|
|
8
|
+
# mode -- transmit() raises HackRFModeError unless the device is in TX mode.
|
|
9
|
+
# Switching to TX mode (set_mode/`hrf mode tx`) is the deliberate, one-time
|
|
10
|
+
# confirmation and prints the safety banner. Frequency uses the full device
|
|
11
|
+
# range and is never policed; the gain ceiling in constants.py guards against
|
|
12
|
+
# an order-of-magnitude fat-finger only.
|
|
13
|
+
#
|
|
14
|
+
# Author(s): <you>
|
|
15
|
+
##--------------------------------------------------------------------\
|
|
16
|
+
|
|
17
|
+
from .. import constants as C
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TransmitMixin:
|
|
21
|
+
def transmit(self, freq, sample_rate, source, *, txvga=20, amp=False,
|
|
22
|
+
bias_tee=False, baseband_bw=None, repeat=False,
|
|
23
|
+
num_samples=None, duration=None, max_duration=None,
|
|
24
|
+
print_cmd=False):
|
|
25
|
+
# source: path to an int8 I/Q file to transmit.
|
|
26
|
+
# max_duration: hard ceiling (seconds) enforced even for open-ended
|
|
27
|
+
# repeat transmits. A transmitter that runs until .stop() is a
|
|
28
|
+
# regulatory + hardware risk if the controlling script dies; this
|
|
29
|
+
# gives every transmit an optional dead-man bound.
|
|
30
|
+
self.require_mode(C.MODE_TX) # the gate
|
|
31
|
+
freq, sample_rate, txvga, amp = self.validate_tx(
|
|
32
|
+
freq, sample_rate, txvga, amp)
|
|
33
|
+
bw = self._auto_baseband(sample_rate, baseband_bw)
|
|
34
|
+
self._record_params(freq=freq, sample_rate=sample_rate, txvga=txvga,
|
|
35
|
+
amp=amp, baseband_bw=bw, mode="tx")
|
|
36
|
+
self.print_message(f"[*] mode: {self.mode} (TX)")
|
|
37
|
+
|
|
38
|
+
argv = ["transfer", "-t", source,
|
|
39
|
+
"-f", int(freq), "-s", int(sample_rate),
|
|
40
|
+
"-x", txvga, "-a", 1 if amp else 0, "-b", int(bw)]
|
|
41
|
+
if bias_tee:
|
|
42
|
+
argv += ["-p", 1]
|
|
43
|
+
if repeat:
|
|
44
|
+
argv += ["-R"]
|
|
45
|
+
if num_samples is not None:
|
|
46
|
+
argv += ["-n", int(num_samples)]
|
|
47
|
+
|
|
48
|
+
# An explicit duration, or a max_duration ceiling on an otherwise
|
|
49
|
+
# open-ended repeat, both run as TIMED so the child is reaped on time.
|
|
50
|
+
effective_timed = duration if duration is not None else (
|
|
51
|
+
max_duration if repeat and num_samples is None else None)
|
|
52
|
+
if effective_timed is not None:
|
|
53
|
+
return self._run(argv, mode="timed", duration=effective_timed,
|
|
54
|
+
print_cmd=print_cmd)
|
|
55
|
+
if num_samples is not None or not repeat:
|
|
56
|
+
# bounded by file length or -n; runs to completion
|
|
57
|
+
return self._run(argv, mode="blocking", print_cmd=print_cmd)
|
|
58
|
+
# repeat with no bound and no max_duration -> open-ended handle
|
|
59
|
+
if print_cmd:
|
|
60
|
+
self._run(argv, mode="blocking", print_cmd=True)
|
|
61
|
+
return None
|
|
62
|
+
self.warn("open-ended repeat transmit with no max_duration; "
|
|
63
|
+
"caller must .stop() the returned handle.")
|
|
64
|
+
return self._run(argv, mode="handle", kind="tx")
|
|
65
|
+
|
|
66
|
+
# ---- aliases ----
|
|
67
|
+
def tx(self, *a, **k):
|
|
68
|
+
return self.transmit(*a, **k)
|
|
69
|
+
|
|
70
|
+
def transmit_file(self, freq, sample_rate, source, **k):
|
|
71
|
+
return self.transmit(freq, sample_rate, source, **k)
|
|
72
|
+
|
|
73
|
+
def transmit_cw(self, freq, sample_rate, *, amplitude=127, txvga=20,
|
|
74
|
+
amp=False, bias_tee=False, baseband_bw=None,
|
|
75
|
+
duration=None, max_duration=None, print_cmd=False):
|
|
76
|
+
# Constant-wave / signal-source test mode: hackrf_transfer -c <amp>.
|
|
77
|
+
# Transmits a fixed signal at `amplitude` (0-127) instead of a file.
|
|
78
|
+
# TX-gated like any transmit. Useful for antenna/range testing. Open-
|
|
79
|
+
# ended unless duration/max_duration bounds it (the dead-man applies).
|
|
80
|
+
self.require_mode(C.MODE_TX)
|
|
81
|
+
freq, sample_rate, txvga, amp = self.validate_tx(
|
|
82
|
+
freq, sample_rate, txvga, amp)
|
|
83
|
+
bw = self._auto_baseband(sample_rate, baseband_bw)
|
|
84
|
+
amplitude = max(0, min(int(amplitude), 127))
|
|
85
|
+
self._record_params(freq=freq, sample_rate=sample_rate, txvga=txvga,
|
|
86
|
+
amp=amp, baseband_bw=bw, mode="tx", cw=amplitude)
|
|
87
|
+
self.print_message(f"[*] mode: {self.mode} (TX, CW source)")
|
|
88
|
+
|
|
89
|
+
argv = ["transfer", "-c", amplitude,
|
|
90
|
+
"-f", int(freq), "-s", int(sample_rate),
|
|
91
|
+
"-x", txvga, "-a", 1 if amp else 0, "-b", int(bw)]
|
|
92
|
+
if bias_tee:
|
|
93
|
+
argv += ["-p", 1]
|
|
94
|
+
|
|
95
|
+
timed = duration if duration is not None else max_duration
|
|
96
|
+
if timed is not None:
|
|
97
|
+
return self._run(argv, mode="timed", duration=timed,
|
|
98
|
+
print_cmd=print_cmd)
|
|
99
|
+
if print_cmd:
|
|
100
|
+
self._run(argv, mode="blocking", print_cmd=True)
|
|
101
|
+
return None
|
|
102
|
+
self.warn("open-ended CW transmit with no duration/max_duration; "
|
|
103
|
+
"caller must .stop() the returned handle.")
|
|
104
|
+
return self._run(argv, mode="handle", kind="tx")
|
hackrfpy/_receiver.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
|
|
3
|
+
##--------------------------------------------------------------------\
|
|
4
|
+
# hackrfpy 'src/hackrfpy/_receiver.py'
|
|
5
|
+
# Persistent fixed-frequency receiver. Opens ONE long-lived
|
|
6
|
+
# hackrf_transfer stream and serves decoded complex64 segments on demand,
|
|
7
|
+
# so you don't pay the ~1-2 s process spin-up for every capture. This is
|
|
8
|
+
# the data-collection answer to the per-capture startup cost measured by
|
|
9
|
+
# examples/benchmark.py.
|
|
10
|
+
#
|
|
11
|
+
# ARCHITECTURAL HONESTY: hackrf_transfer cannot retune mid-stream, so this
|
|
12
|
+
# is FIXED-FREQUENCY by design. It is "start once, drain over time at one
|
|
13
|
+
# frequency", NOT "one process I can retune". For multiple frequencies use
|
|
14
|
+
# scan_frequencies (separate captures) or monitor_frequencies (sweep). True
|
|
15
|
+
# gapless retuning needs the C library.
|
|
16
|
+
##--------------------------------------------------------------------\
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PersistentReceiver:
|
|
22
|
+
# A long-lived RX stream at one frequency. Created via
|
|
23
|
+
# HackRF.open_receiver(...); use as a context manager so the child is
|
|
24
|
+
# always reaped:
|
|
25
|
+
#
|
|
26
|
+
# with h.open_receiver(100e6, 8e6) as rx:
|
|
27
|
+
# a = rx.read(1_000_000) # exactly 1e6 complex64 samples
|
|
28
|
+
# b = rx.read(1_000_000) # again, NO new process spin-up
|
|
29
|
+
# for block in rx.blocks(): # or iterate raw decoded blocks
|
|
30
|
+
# ...
|
|
31
|
+
#
|
|
32
|
+
# The underlying hackrf_transfer is launched once on __enter__ (or first
|
|
33
|
+
# use) and runs continuously until close()/__exit__.
|
|
34
|
+
def __init__(self, hackrf, freq, sample_rate, *, lna=16, vga=20, amp=False,
|
|
35
|
+
baseband_bw=None, read_samples=131072):
|
|
36
|
+
self._h = hackrf
|
|
37
|
+
self.freq = freq
|
|
38
|
+
self.sample_rate = sample_rate
|
|
39
|
+
self.lna = lna
|
|
40
|
+
self.vga = vga
|
|
41
|
+
self.amp = amp
|
|
42
|
+
self.baseband_bw = baseband_bw
|
|
43
|
+
self.read_samples = read_samples
|
|
44
|
+
self._gen = None # the raw-bytes stream generator
|
|
45
|
+
self._carry = b"" # leftover bytes between reads (odd splits)
|
|
46
|
+
self._closed = False
|
|
47
|
+
self.total_samples = 0 # cumulative samples served
|
|
48
|
+
|
|
49
|
+
# ---- lifecycle ---------------------------------------------------------
|
|
50
|
+
def _ensure_open(self):
|
|
51
|
+
if self._gen is not None or self._closed:
|
|
52
|
+
return
|
|
53
|
+
h = self._h
|
|
54
|
+
# validate + snap exactly like capture() does, and record params so
|
|
55
|
+
# relative_power_db() etc. can read the gains back. validate_rx returns
|
|
56
|
+
# (freq, sample_rate, lna, vga); amp passes through untouched on RX.
|
|
57
|
+
freq, sample_rate, lna, vga = h.validate_rx(
|
|
58
|
+
self.freq, self.sample_rate, self.lna, self.vga)
|
|
59
|
+
amp = bool(self.amp)
|
|
60
|
+
bw = h._auto_baseband(sample_rate, self.baseband_bw)
|
|
61
|
+
self.freq, self.sample_rate = freq, sample_rate
|
|
62
|
+
self.lna, self.vga, self.amp = lna, vga, amp
|
|
63
|
+
h._record_params(freq=freq, sample_rate=sample_rate, lna=lna, vga=vga,
|
|
64
|
+
amp=amp, baseband_bw=bw, mode="rx")
|
|
65
|
+
argv = ["transfer", "-r", "-", "-f", int(freq), "-s", int(sample_rate),
|
|
66
|
+
"-l", lna, "-g", vga, "-a", 1 if amp else 0, "-b", int(bw)]
|
|
67
|
+
# one long-lived stream; large read granularity for sustained rate
|
|
68
|
+
self._gen = h._run(argv, mode="stream", read_samples=self.read_samples)
|
|
69
|
+
# register on the live backstop so a dying script reaps it
|
|
70
|
+
from .core import _LIVE
|
|
71
|
+
try:
|
|
72
|
+
_LIVE.add(self)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def __enter__(self):
|
|
77
|
+
self._ensure_open()
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, exc_type, exc, tb):
|
|
81
|
+
self.close()
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def close(self):
|
|
85
|
+
if self._closed:
|
|
86
|
+
return
|
|
87
|
+
self._closed = True
|
|
88
|
+
if self._gen is not None:
|
|
89
|
+
try:
|
|
90
|
+
self._gen.close() # interrupts + reaps the child
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
self._gen = None
|
|
94
|
+
try:
|
|
95
|
+
from .core import _LIVE
|
|
96
|
+
_LIVE.discard(self)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def stop(self):
|
|
101
|
+
# alias so PersistentReceiver works with the _LIVE backstop, which
|
|
102
|
+
# calls .stop() on whatever it holds
|
|
103
|
+
self.close()
|
|
104
|
+
|
|
105
|
+
# ---- data access -------------------------------------------------------
|
|
106
|
+
def read(self, n_samples):
|
|
107
|
+
# Return EXACTLY n_samples complex64 (or fewer if the stream ends).
|
|
108
|
+
# Pulls and decodes raw bytes from the persistent stream until it has
|
|
109
|
+
# enough; carries any partial trailing pair between calls. No new
|
|
110
|
+
# process is launched -- this is the whole point.
|
|
111
|
+
self._ensure_open()
|
|
112
|
+
need_bytes = int(n_samples) * 2 # 2 int8 per complex sample
|
|
113
|
+
buf = self._carry
|
|
114
|
+
for chunk in self._gen:
|
|
115
|
+
buf += chunk
|
|
116
|
+
if len(buf) >= need_bytes:
|
|
117
|
+
break
|
|
118
|
+
take = buf[:need_bytes]
|
|
119
|
+
self._carry = buf[need_bytes:]
|
|
120
|
+
# decode, dropping any odd trailing byte (shouldn't happen since we
|
|
121
|
+
# cut on an even boundary, but be safe)
|
|
122
|
+
iq = self._h.decode_iq(take)
|
|
123
|
+
self.total_samples += len(iq)
|
|
124
|
+
return iq
|
|
125
|
+
|
|
126
|
+
def blocks(self):
|
|
127
|
+
# Yield decoded complex64 blocks as they arrive (raw cadence), until
|
|
128
|
+
# the stream ends or the caller stops iterating. Good for a continuous
|
|
129
|
+
# consumer that doesn't need a fixed sample count per read.
|
|
130
|
+
self._ensure_open()
|
|
131
|
+
if self._carry:
|
|
132
|
+
lead = self._h.decode_iq(self._carry)
|
|
133
|
+
self._carry = b""
|
|
134
|
+
if len(lead):
|
|
135
|
+
self.total_samples += len(lead)
|
|
136
|
+
yield lead
|
|
137
|
+
for chunk in self._gen:
|
|
138
|
+
iq = self._h.decode_iq(chunk)
|
|
139
|
+
self.total_samples += len(iq)
|
|
140
|
+
yield iq
|
|
141
|
+
|
|
142
|
+
def callback(self, on_block, *, max_samples=None):
|
|
143
|
+
# Inverted loop over blocks(): call on_block(iq, total) per block;
|
|
144
|
+
# stop on False, on max_samples, or stream end.
|
|
145
|
+
for iq in self.blocks():
|
|
146
|
+
cont = on_block(iq, self.total_samples)
|
|
147
|
+
if cont is False:
|
|
148
|
+
break
|
|
149
|
+
if max_samples is not None and self.total_samples >= max_samples:
|
|
150
|
+
break
|
|
151
|
+
return self.total_samples
|
hackrfpy/_stream_ctx.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#! /usr/bin/python3
|
|
2
|
+
|
|
3
|
+
##--------------------------------------------------------------------\
|
|
4
|
+
# hackrfpy 'src/hackrfpy/_stream_ctx.py'
|
|
5
|
+
# Shared context manager that guarantees a live capture/sweep generator
|
|
6
|
+
# is closed (and its child process reaped) on block exit, including on
|
|
7
|
+
# exception or KeyboardInterrupt. Used by capture_stream and sweep_stream.
|
|
8
|
+
##--------------------------------------------------------------------\
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StreamCtx:
|
|
12
|
+
# Wrap a live generator so its finally-block (which interrupts + reaps the
|
|
13
|
+
# hackrf_* child) runs on __exit__, even if the body raised. Iterable, so
|
|
14
|
+
# `for x in rows:` works directly on the bound name.
|
|
15
|
+
def __init__(self, gen):
|
|
16
|
+
self._gen = gen
|
|
17
|
+
|
|
18
|
+
def __enter__(self):
|
|
19
|
+
return self._gen
|
|
20
|
+
|
|
21
|
+
def __exit__(self, exc_type, exc, tb):
|
|
22
|
+
if self._gen is not None:
|
|
23
|
+
self._gen.close()
|
|
24
|
+
return False # never suppress the caller's exception
|