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/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