Skip to content

Commit e0c0471

Browse files
committed
sweep data return, cosmetic and partial update squash
1 parent 90a4560 commit e0c0471

4 files changed

Lines changed: 80 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,17 @@ release.
258258
survives at ~72 dB SNR.
259259

260260
### Fixed
261+
- `monitor_frequencies()` emitted partial updates several times per sweep
262+
pass on real hardware: the pass boundary was a timestamp change, but real
263+
`hackrf_sweep` timestamps each row individually (the test stubs shared one
264+
timestamp per pass, which hid it), so over a wide span most watched
265+
frequencies read `None` in every update -- seen live as a wall of `--`.
266+
The boundary is now the sweep wrap (a segment arriving a second time),
267+
which is timestamp-independent; every update covers every watched
268+
frequency the span covers, pinned by a regression test with per-row
269+
timestamps. The `channel_monitor.py` example also now redraws its meter
270+
in place (ANSI, Windows VT enabled, scrolling fallback when piped)
271+
instead of scroll-printing each frame.
261272
- Abandoning a live stream could leak a child that held the USB claim for
262273
the rest of the process: with the consumer gone, the 64 KB stdout pipe
263274
fills in milliseconds at capture rates and the child blocks inside

‎hackrfpy/examples/channel_monitor.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
# Author(s): Lauren Linkous
2222
##--------------------------------------------------------------------\
2323
import argparse
24+
import os
2425
import sys
2526

2627
from hackrfpy import HackRF, parse_freq
@@ -58,11 +59,31 @@ def main():
5859
+ ", ".join(l.strip() for l in labels.values())
5960
+ " (Ctrl-C to stop)")
6061

62+
# In-place redraw: this is a live METER, so each update overwrites the
63+
# previous frame instead of scrolling a log. os.system("") on Windows
64+
# switches the classic console into ANSI/VT mode (a documented quirk:
65+
# spawning any shell command enables VT processing); it is a no-op
66+
# elsewhere. Piped/redirected output falls back to scrolling so logs
67+
# stay readable.
68+
if os.name == "nt":
69+
os.system("")
70+
redraw_in_place = sys.stdout.isatty()
71+
frame_state = {"drawn": False}
72+
6173
def on_update(update):
6274
lines = [f" {labels[f]} {bar(db)} "
6375
f"{'--' if db is None else f'{db:6.1f} dB'}"
6476
for f, db in update.items()]
65-
print("\n".join(lines) + "\n")
77+
if redraw_in_place:
78+
if frame_state["drawn"]:
79+
# move the cursor up over the previous frame
80+
sys.stdout.write(f"\x1b[{len(lines)}A")
81+
# \x1b[2K clears each line so shorter bars leave no residue
82+
sys.stdout.write("\n".join("\x1b[2K" + ln for ln in lines) + "\n")
83+
sys.stdout.flush()
84+
frame_state["drawn"] = True
85+
else:
86+
print("\n".join(lines) + "\n")
6687

6788
try:
6889
h.monitor_frequencies(freqs, span_hz=parse_freq(args.span),

‎hackrfpy/src/hackrfpy/_commands/sweep.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,20 @@ def _nearest_power(rows_by_low: dict[int, Any], f: float) -> Any:
165165
_rows = self.sweep(lo, hi, lna=lna, vga=vga, amp=amp)
166166
assert _rows is not None # print_cmd not passed -> real generator
167167
with StreamCtx(_rows) as gen:
168-
rows_by_low = {}
169-
last_time = None
168+
rows_by_low: dict[int, Any] = {}
170169
for row in gen:
171-
if last_time is not None and row["time"] != last_time and rows_by_low:
170+
# A pass is complete when the sweep WRAPS: the same segment
171+
# (hz_low) arriving again means a new pass began. The old
172+
# boundary was a timestamp change -- but real hackrf_sweep
173+
# timestamps each ROW individually, so over a wide span the
174+
# "pass" flushed on nearly every row batch, emitting partial
175+
# updates where most watched frequencies read None (the test
176+
# stubs share one timestamp per pass, which is why stubs
177+
# passed while real hardware showed a wall of "--"). Segment
178+
# revisit is timestamp-independent and matches the physical
179+
# sweep cycle, so every update now covers every watched
180+
# frequency the span covers.
181+
if row["hz_low"] in rows_by_low:
172182
update = {f: _nearest_power(rows_by_low, f) for f in freqs_hz}
173183
if on_update is not None:
174184
if on_update(update) is False:
@@ -180,7 +190,6 @@ def _nearest_power(rows_by_low: dict[int, Any], f: float) -> Any:
180190
if duration is not None and _time.time() - t0 >= duration:
181191
break
182192
rows_by_low[row["hz_low"]] = row
183-
last_time = row["time"]
184193
# flush the final buffered pass (stream ended before its timestamp
185194
# rolled over) -- unless we were explicitly stopped by on_update
186195
if rows_by_low and not stopped:

‎hackrfpy/tests/test_monitor.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,37 @@ def test_monitor_reads_covering_bin_not_segment_mean(stub_device):
8080
# 433.5 MHz -> bin idx 3 (the -20 dB carrier). The segment mean would
8181
# have read -74; the covering bin reads the carrier itself.
8282
assert abs(out[0][433.5e6] - (-20.0)) < 0.1
83+
84+
85+
# ---- pass detection must survive real per-row timestamps -------------------
86+
# Real hackrf_sweep timestamps each ROW individually; the stub fixtures used
87+
# one timestamp per pass, which hid a boundary bug: flushing on timestamp
88+
# change emitted PARTIAL updates several times per pass on real hardware
89+
# (most watched frequencies None -- seen live as a wall of "--"). The pass
90+
# boundary is now the sweep WRAP (a segment arriving again), which is
91+
# timestamp-independent. These rows reproduce the hardware shape: two full
92+
# passes over two segments, every row with a distinct timestamp.
93+
_PER_ROW_TS_PASSES = (
94+
"2026-09-19, 12:00:00.100000, 88000000, 88500000, 100000.00, 8192, "
95+
"-71.0, -70.0, -69.0, -72.0, -71.0\n"
96+
"2026-09-19, 12:00:00.230000, 88500000, 89000000, 100000.00, 8192, "
97+
"-70.0, -20.0, -72.0, -73.0, -74.0\n"
98+
"2026-09-19, 12:00:00.360000, 88000000, 88500000, 100000.00, 8192, "
99+
"-71.5, -70.5, -69.5, -72.5, -71.5\n"
100+
"2026-09-19, 12:00:00.490000, 88500000, 89000000, 100000.00, 8192, "
101+
"-70.5, -21.0, -72.5, -73.5, -74.5\n"
102+
)
103+
104+
105+
def test_updates_are_complete_despite_per_row_timestamps(stub_device):
106+
h = stub_device(sweep=dict(stdout_lines=_PER_ROW_TS_PASSES.strip()
107+
.split("\n")))
108+
seen = []
109+
h.monitor_frequencies([88.6e6], span_hz=0.4e6,
110+
on_update=lambda u: seen.append(u))
111+
# two passes -> exactly two updates (wrap flush + final flush), and BOTH
112+
# carry a real reading for the watched frequency -- no partial updates
113+
assert len(seen) == 2, f"expected 2 complete updates, got {len(seen)}"
114+
assert seen[0][88.6e6] == -20.0 # bin 1 of pass-1 second segment
115+
assert seen[1][88.6e6] == -21.0 # same bin, pass 2
116+
assert all(u[88.6e6] is not None for u in seen)

0 commit comments

Comments
 (0)