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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hackrfpy
3
- Version: 1.1.0
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.1.0"
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
- iq = self._h.decode_iq(chunk)
185
- self.total_samples += len(iq)
186
- yield iq
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