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