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.
@@ -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
@@ -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