hackrfpy 1.1.0__tar.gz → 1.2.0__tar.gz
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-1.1.0 → hackrfpy-1.2.0}/PKG-INFO +1 -1
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/pyproject.toml +1 -1
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/__init__.py +2 -2
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/_commands/sweep.py +87 -1
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/_receiver.py +14 -9
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/constants.py +12 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/core.py +34 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/LICENSE +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/README.md +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/_commands/__init__.py +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/_commands/capture.py +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/_commands/device.py +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/_commands/info.py +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/_commands/transmit.py +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/_host.py +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/_stream_ctx.py +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/cli.py +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/exceptions.py +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/presets.py +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/py.typed +0 -0
- {hackrfpy-1.1.0 → hackrfpy-1.2.0}/src/hackrfpy/sigmf.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hackrfpy
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: A self-contained CLI + scriptable wrapper for the HackRF One, built on the hackrf-tools binaries (no libhackrf bindings required).
|
|
5
5
|
Keywords: hackrf,sdr,rf,spectrum,sigmf
|
|
6
6
|
Author: LC-Linkous
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hackrfpy"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.2.0"
|
|
4
4
|
description = "A self-contained CLI + scriptable wrapper for the HackRF One, built on the hackrf-tools binaries (no libhackrf bindings required)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# hackrfpy 'src/hackrfpy/__init__.py'
|
|
4
4
|
# Public API surface.
|
|
5
5
|
##--------------------------------------------------------------------\
|
|
6
|
-
from .core import HackRF, load_iq, parse_freq
|
|
6
|
+
from .core import HackRF, load_iq, encode_iq, parse_freq
|
|
7
7
|
from .sigmf import write_sigmf_meta, read_sigmf_meta
|
|
8
8
|
from .exceptions import (
|
|
9
9
|
HackRFError, HackRFValueError, HackRFModeError,
|
|
@@ -18,7 +18,7 @@ except PackageNotFoundError: # running from a checkout
|
|
|
18
18
|
__version__ = "0.0.0+unknown"
|
|
19
19
|
|
|
20
20
|
__all__ = [
|
|
21
|
-
"HackRF", "load_iq", "parse_freq", "constants", "__version__",
|
|
21
|
+
"HackRF", "load_iq", "encode_iq", "parse_freq", "constants", "__version__",
|
|
22
22
|
"write_sigmf_meta", "read_sigmf_meta",
|
|
23
23
|
"HackRFError", "HackRFValueError", "HackRFModeError",
|
|
24
24
|
"HackRFDeviceError", "HackRFEnvironmentError",
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
-
from typing import Any, Callable, Generator
|
|
18
|
+
from typing import Any, Callable, Generator, Iterable
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
19
21
|
|
|
20
22
|
from .. import constants as C
|
|
21
23
|
from .._host import HostOps
|
|
@@ -111,6 +113,90 @@ class SweepMixin(HostOps):
|
|
|
111
113
|
return []
|
|
112
114
|
return list(rows)
|
|
113
115
|
|
|
116
|
+
@staticmethod
|
|
117
|
+
def iter_sweep_passes(rows: Iterable[dict[str, Any]]
|
|
118
|
+
) -> Generator[dict[str, Any], None, None]:
|
|
119
|
+
"""Group parsed sweep rows into whole passes, one contiguous array each.
|
|
120
|
+
|
|
121
|
+
Pure function over rows from sweep() / parse_sweep_line(); no device.
|
|
122
|
+
Yields, per pass:
|
|
123
|
+
|
|
124
|
+
{"start": hz, "stop": hz, "bin_width": hz, "points": n,
|
|
125
|
+
"level": np.ndarray[float32, n] # ascending frequency,
|
|
126
|
+
"date": str, "time": str, # first row of the pass
|
|
127
|
+
"segments": k, "contiguous": bool}
|
|
128
|
+
|
|
129
|
+
The pass boundary is SEGMENT REVISIT -- the same hz_low arriving
|
|
130
|
+
again -- never a timestamp change. Real hackrf_sweep stamps each row
|
|
131
|
+
individually, so a timestamp boundary flushes on nearly every row over
|
|
132
|
+
a wide span and emits partial spectra (this is what broke
|
|
133
|
+
monitor_frequencies on hardware before 1.1.0; a consumer that groups
|
|
134
|
+
by timestamp has the same bug). Segments arrive out of frequency
|
|
135
|
+
order and are sorted here. Bins a pass never covered (a gap between
|
|
136
|
+
segments, which hackrf_sweep does not normally produce) are NaN, and
|
|
137
|
+
"contiguous" is False, so a plot shows a hole rather than a lie.
|
|
138
|
+
|
|
139
|
+
A trailing partial pass (stream ended mid-pass) is yielded last with
|
|
140
|
+
whatever it has; check "segments" if that matters.
|
|
141
|
+
"""
|
|
142
|
+
seen: set[int] = set()
|
|
143
|
+
batch: list[dict[str, Any]] = []
|
|
144
|
+
for row in rows:
|
|
145
|
+
low = int(row["hz_low"])
|
|
146
|
+
if low in seen:
|
|
147
|
+
yield SweepMixin._stitch_pass(batch)
|
|
148
|
+
seen.clear()
|
|
149
|
+
batch = []
|
|
150
|
+
seen.add(low)
|
|
151
|
+
batch.append(row)
|
|
152
|
+
if batch:
|
|
153
|
+
yield SweepMixin._stitch_pass(batch)
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _stitch_pass(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
157
|
+
rows = sorted(rows, key=lambda r: int(r["hz_low"]))
|
|
158
|
+
bw = float(rows[0]["bin_width"])
|
|
159
|
+
start = int(rows[0]["hz_low"])
|
|
160
|
+
stop = max(int(r["hz_high"]) for r in rows)
|
|
161
|
+
n = int(round((stop - start) / bw))
|
|
162
|
+
level = np.full(n, np.nan, dtype=np.float32)
|
|
163
|
+
covered = 0
|
|
164
|
+
for r in rows:
|
|
165
|
+
db = np.asarray(r["db"], dtype=np.float32)
|
|
166
|
+
i0 = int(round((int(r["hz_low"]) - start) / bw))
|
|
167
|
+
i1 = min(n, i0 + db.size)
|
|
168
|
+
level[i0:i1] = db[: i1 - i0]
|
|
169
|
+
covered += i1 - i0
|
|
170
|
+
return {"start": start, "stop": stop, "bin_width": bw, "points": n,
|
|
171
|
+
"level": level, "date": rows[0].get("date"),
|
|
172
|
+
"time": rows[0].get("time"), "segments": len(rows),
|
|
173
|
+
"contiguous": covered >= n}
|
|
174
|
+
|
|
175
|
+
def sweep_passes(self, f_min_hz: float, f_max_hz: float, *,
|
|
176
|
+
bin_width: float | None = None,
|
|
177
|
+
lna: int = 16, vga: int = 20, amp: bool = False,
|
|
178
|
+
num_sweeps: int | None = None
|
|
179
|
+
) -> Generator[dict[str, Any], None, None]:
|
|
180
|
+
"""Stream a sweep as whole passes: one stitched spectrum per pass.
|
|
181
|
+
|
|
182
|
+
sweep() with iter_sweep_passes() applied: the consumer gets a
|
|
183
|
+
contiguous, frequency-sorted float32 array per complete pass instead
|
|
184
|
+
of interleaved segments. Same validation, snapping and child
|
|
185
|
+
lifecycle as sweep(); break out of the loop (or close the generator)
|
|
186
|
+
to stop hackrf_sweep. Edges snap outward to whole MHz, so "start" and
|
|
187
|
+
"stop" are the swept edges, not the requested ones.
|
|
188
|
+
"""
|
|
189
|
+
rows = self.sweep(f_min_hz, f_max_hz, bin_width=bin_width, lna=lna,
|
|
190
|
+
vga=vga, amp=amp, num_sweeps=num_sweeps)
|
|
191
|
+
assert rows is not None # print_cmd is not offered here
|
|
192
|
+
|
|
193
|
+
def _gen() -> Generator[dict[str, Any], None, None]:
|
|
194
|
+
try:
|
|
195
|
+
yield from SweepMixin.iter_sweep_passes(rows)
|
|
196
|
+
finally:
|
|
197
|
+
rows.close() # reaps hackrf_sweep on early exit
|
|
198
|
+
return _gen()
|
|
199
|
+
|
|
114
200
|
def monitor_frequencies(self, freqs_hz: list[float], *,
|
|
115
201
|
span_hz: float = 2_000_000,
|
|
116
202
|
duration: float | None = None,
|
|
@@ -173,17 +173,22 @@ class PersistentReceiver:
|
|
|
173
173
|
# Yield decoded complex64 blocks as they arrive (raw cadence), until
|
|
174
174
|
# the stream ends or the caller stops iterating. Good for a continuous
|
|
175
175
|
# consumer that doesn't need a fixed sample count per read.
|
|
176
|
+
# An odd-length chunk splits an I/Q pair across two reads. decode_iq
|
|
177
|
+
# DROPS an odd trailing byte, and dropping one byte here would swap
|
|
178
|
+
# I and Q for every sample that follows (capture()'s stream path
|
|
179
|
+
# carries the byte for exactly this reason). The read1() cap in
|
|
180
|
+
# _run is even, but a pipe is free to return fewer bytes than
|
|
181
|
+
# asked, so the odd case is real. Carry the byte in self._carry,
|
|
182
|
+
# the same buffer read() uses, so blocks() and read() can be mixed.
|
|
176
183
|
gen = self._ensure_open()
|
|
177
|
-
if self._carry:
|
|
178
|
-
lead = self._h.decode_iq(self._carry)
|
|
179
|
-
self._carry = b""
|
|
180
|
-
if len(lead):
|
|
181
|
-
self.total_samples += len(lead)
|
|
182
|
-
yield lead
|
|
183
184
|
for chunk in gen:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
buf = self._carry + chunk
|
|
186
|
+
cut = len(buf) - (len(buf) % 2)
|
|
187
|
+
self._carry = buf[cut:]
|
|
188
|
+
if cut:
|
|
189
|
+
iq = self._h.decode_iq(buf[:cut])
|
|
190
|
+
self.total_samples += len(iq)
|
|
191
|
+
yield iq
|
|
187
192
|
|
|
188
193
|
def callback(self, on_block: Callable[[np.ndarray, int], Any], *,
|
|
189
194
|
max_samples: int | None = None) -> int:
|
|
@@ -45,6 +45,18 @@ TXVGA_GAIN = (0, 47, 1) # TX IF gain
|
|
|
45
45
|
# Front-end amplifier is a fixed ~14 dB block, on/off only.
|
|
46
46
|
AMP_DB = 14
|
|
47
47
|
|
|
48
|
+
# ---- recommended starting gains for a receive capture ----------------------
|
|
49
|
+
# The API defaults (lna=16, vga=20 on capture()/open_receiver()/sweep()) are
|
|
50
|
+
# unchanged for compatibility, but they are LOW: on 2026-09-17 a typical
|
|
51
|
+
# antenna at 16/20 produced captures spanning only +/-4 of the 127 int8 codes
|
|
52
|
+
# (effectively 3-bit recordings) that passed every power check. The collection
|
|
53
|
+
# tools moved to 32/28 and so should any new consumer. Reference these BY NAME
|
|
54
|
+
# rather than copying the numbers, so a future revision reaches everyone.
|
|
55
|
+
# Tune per band from here; a short probe through a capture-health check tells
|
|
56
|
+
# you whether the ADC is under-driven or clipping.
|
|
57
|
+
RECOMMENDED_LNA = 32 # dB, on the LNA_GAIN steps
|
|
58
|
+
RECOMMENDED_VGA = 28 # dB, on the VGA_GAIN steps
|
|
59
|
+
|
|
48
60
|
# ---- TX safety ceilings (default at true device max so an order-of-magnitude
|
|
49
61
|
# typo rejects; tighten these for a specific bench) ----------------------
|
|
50
62
|
TX_VGA_CEILING_DB = TXVGA_GAIN[1] # 47; lower this to cap output on a bench
|
|
@@ -976,6 +976,15 @@ class HackRF(InfoMixin, CaptureMixin, TransmitMixin, SweepMixin, DeviceMixin):
|
|
|
976
976
|
iq /= 128.0 # normalize int8 full-scale to ~[-1, 1)
|
|
977
977
|
return iq
|
|
978
978
|
|
|
979
|
+
@staticmethod
|
|
980
|
+
def encode_iq(iq: np.ndarray) -> bytes:
|
|
981
|
+
"""Encode complex samples to HackRF-native interleaved int8 bytes.
|
|
982
|
+
|
|
983
|
+
The inverse of decode_iq: see the module-level encode_iq() for the
|
|
984
|
+
contract. Provided on the class for symmetry with decode_iq.
|
|
985
|
+
"""
|
|
986
|
+
return encode_iq(iq)
|
|
987
|
+
|
|
979
988
|
# ---- power / relative calibration (Level 1) ----------------------------
|
|
980
989
|
# These turn the device's arbitrary dBFS amplitude into something that is
|
|
981
990
|
# at least CONSISTENT across gain settings (and, with a user-supplied
|
|
@@ -1127,3 +1136,28 @@ def load_iq(path: str, count: int | None = None,
|
|
|
1127
1136
|
iq.imag = a[1::2]
|
|
1128
1137
|
iq /= 128.0
|
|
1129
1138
|
return iq
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def encode_iq(iq: np.ndarray) -> bytes:
|
|
1142
|
+
"""Encode complex samples to HackRF-native interleaved int8 I/Q bytes.
|
|
1143
|
+
|
|
1144
|
+
The exact inverse of decode_iq() / load_iq() for anything they produced:
|
|
1145
|
+
those divide by 128, this multiplies by 128, rounds to nearest, and clips
|
|
1146
|
+
to the int8 range, so decode -> encode -> decode is lossless and a
|
|
1147
|
+
recording re-encoded from decoded blocks is byte-identical to the stream
|
|
1148
|
+
(the property hackrf-web's recorder relies on). For arbitrary complex
|
|
1149
|
+
input it is the natural quantiser: +1.0 clips to 127, -1.0 maps to -128.
|
|
1150
|
+
|
|
1151
|
+
Returns bytes laid out I0, Q0, I1, Q1, ... -- the format hackrf_transfer
|
|
1152
|
+
reads for -t and writes for -r, and the SigMF "ci8" datatype.
|
|
1153
|
+
"""
|
|
1154
|
+
iq = np.asarray(iq)
|
|
1155
|
+
if iq.ndim != 1:
|
|
1156
|
+
raise HackRFValueError(
|
|
1157
|
+
f"encode_iq expects a 1-D complex array, got shape {iq.shape}")
|
|
1158
|
+
out = np.empty(2 * iq.size, dtype=np.int8)
|
|
1159
|
+
# rint, not astype: astype truncates toward zero and would bias every
|
|
1160
|
+
# sample toward 0 by up to one code.
|
|
1161
|
+
out[0::2] = np.clip(np.rint(iq.real * 128.0), -128, 127)
|
|
1162
|
+
out[1::2] = np.clip(np.rint(iq.imag * 128.0), -128, 127)
|
|
1163
|
+
return out.tobytes()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|