Skip to content

Commit 2f5f232

Browse files
committed
core update for some blocking features removed
1 parent f34aa2b commit 2f5f232

5 files changed

Lines changed: 418 additions & 5 deletions

File tree

hackrfpy/src/hackrfpy/_commands/capture.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,26 @@ def capture_array(self, freq, sample_rate, num_samples, *,
164164
return iq, self.last_params
165165
return iq
166166

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+
167187
def capture_stream(self, freq, sample_rate, **k):
168188
# Context manager wrapping the live stdout stream so the receiving
169189
# hackrf_transfer is ALWAYS reaped on exit, even on exception:
@@ -259,4 +279,4 @@ def _capture_segmented(self, freq, sample_rate, out, segment_secs, lna,
259279
except KeyboardInterrupt:
260280
# reachable now that _run re-raises after stopping the child
261281
self.print_message("[*] segmented capture stopped by user")
262-
return results
282+
return results

hackrfpy/src/hackrfpy/_commands/sweep.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,67 @@ def sweep_collect(self, f_min_hz, f_max_hz, num_sweeps=1, **k):
8484
k.pop("num_sweeps", None)
8585
return list(self.sweep(f_min_hz, f_max_hz, num_sweeps=num_sweeps, **k))
8686

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+
87148
def sweep_to_file(self, f_min_hz, f_max_hz, out, *, binary=False,
88149
inverse_fft=False, bin_width=None, lna=16, vga=20,
89150
amp=False, one_shot=False, num_sweeps=None,

hackrfpy/src/hackrfpy/_receiver.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)