11#! /usr/bin/python3
22##--------------------------------------------------------------------\
33# hackrfpy 'examples/waterfall_realtime.py'
4- # Live waterfall from hackrf_sweep. Needs the [plotting] extra.
4+ # Live spectrum waterfall from hackrf_sweep. Needs the [plotting] extra.
5+ #
56# Demonstrates consuming the sweep generator as frames arrive, with a
67# clean shutdown that reaps hackrf_sweep on Ctrl-C (sweep_stream).
8+ #
9+ # Robust to real hackrf_sweep behavior: segments arrive OUT OF ORDER and a
10+ # sweep pass can be partial at the flush boundary, so every spectrum line is
11+ # conformed to a fixed width before being stacked into the image (a naive
12+ # np.array(history) crashes on the resulting ragged rows).
713##--------------------------------------------------------------------\
814import numpy as np
915import matplotlib .pyplot as plt
16+ from matplotlib import colors
1017from hackrfpy import HackRF
1118
12- F_MIN , F_MAX = 88e6 , 108e6 # FM broadcast band
13- ROWS = 100
19+ # ---- what to watch -------------------------------------------------------
20+ F_MIN , F_MAX = 2.40e9 , 2.50e9 # 2.4 GHz ISM (Wi-Fi / BT / microwave leak)
21+ ROWS = 160 # waterfall history depth (time axis)
22+ DB_FLOOR , DB_CEIL = - 90 , - 20 # fixed color scale (dBFS); stable colors
23+
24+ # ---- aesthetic: a signals-monitor identity, not a default plot -----------
25+ # Palette drawn from spectrum-monitor / oscilloscope vernacular: near-black
26+ # instrument background, phosphor-cyan accents, signal mapped through a
27+ # perceptually-uniform heat map (turbo) so weak-to-strong reads intuitively.
28+ BG = "#0a0e14" # instrument black-blue
29+ PANEL = "#0d1320"
30+ GRID = "#1c2738"
31+ ACCENT = "#36e0c8" # phosphor cyan
32+ TEXT = "#9fb3c8" # cool grey-blue
33+ TEXTDIM = "#52617a"
34+ CMAP = "turbo" # perceptually uniform, high dynamic range
35+
36+ plt .rcParams .update ({
37+ "figure.facecolor" : BG ,
38+ "axes.facecolor" : PANEL ,
39+ "savefig.facecolor" : BG ,
40+ "font.family" : "monospace" , # telemetry feel
41+ "text.color" : TEXT ,
42+ "axes.edgecolor" : GRID ,
43+ "axes.labelcolor" : TEXT ,
44+ "xtick.color" : TEXTDIM ,
45+ "ytick.color" : TEXTDIM ,
46+ "axes.linewidth" : 0.8 ,
47+ })
1448
1549h = HackRF ()
16- fig , ax = plt .subplots ()
50+
51+ fig , ax = plt .subplots (figsize = (11 , 6 ))
52+ try :
53+ fig .canvas .manager .set_window_title ("hackrfpy :: live spectrum waterfall" )
54+ except Exception :
55+ pass
56+
1757img = None
58+ cbar = None
59+ width = None # locked to the first COMPLETE sweep's bin count
60+ history = []
1861
19- # A single hackrf_sweep pass over a wide band arrives as MANY rows (one per
20- # ~5 MHz tuning segment) that share a timestamp. We assemble all rows with the
21- # same timestamp into one spectrum line, then flush when the timestamp changes.
62+ # assemble all rows sharing a timestamp into one spectrum line
2263sweep_bins = {}
2364last_time = None
24- history = []
65+ sweeps_seen = 0
66+
67+
68+ def assemble_line ():
69+ # concatenate segments low->high (real hackrf_sweep emits them out of
70+ # order, so sort by hz_low) into one spectrum row
71+ return np .concatenate ([sweep_bins [k ] for k in sorted (sweep_bins )])
72+
73+
74+ def conform (line , w ):
75+ # make a line exactly w wide: truncate if long, edge-pad if short. Real
76+ # sweeps occasionally yield a partial pass at the flush boundary; this
77+ # keeps the image rectangular instead of crashing np.array on ragged rows.
78+ if len (line ) == w :
79+ return line
80+ if len (line ) > w :
81+ return line [:w ]
82+ return np .pad (line , (0 , w - len (line )), mode = "edge" )
83+
84+
85+ def style_axes ():
86+ band = f"{ F_MIN / 1e6 :.0f} \u2013 { F_MAX / 1e6 :.0f} MHz"
87+ ax .set_title (f"LIVE SPECTRUM \u2014 { band } " ,
88+ color = ACCENT , fontsize = 13 , fontweight = "bold" ,
89+ loc = "left" , pad = 12 , family = "monospace" )
90+ ax .set_xlabel ("frequency (MHz)" , fontsize = 9 )
91+ ax .set_ylabel ("time (newest at top)" , fontsize = 9 )
92+ # frequency ticks across the band
93+ xt = np .linspace (0 , width - 1 , 6 )
94+ xl = [f"{ (F_MIN + (F_MAX - F_MIN ) * (t / max (width - 1 , 1 )))/ 1e6 :.0f} "
95+ for t in xt ]
96+ ax .set_xticks (xt )
97+ ax .set_xticklabels (xl )
98+ ax .set_yticks ([])
99+ for s in ax .spines .values ():
100+ s .set_color (GRID )
25101
26102
27103def flush_line ():
28- # concatenate the assembled segments low->high into one spectrum row
29- global history , img
30- line = np .concatenate ([sweep_bins [k ] for k in sorted (sweep_bins )])
104+ global history , img , cbar , width
105+ line = assemble_line ()
31106 sweep_bins .clear ()
32- history .append (line )
107+
108+ if width is None :
109+ # lock the image width to the first complete sweep we see
110+ width = len (line )
111+
112+ history .append (conform (line , width ))
33113 history = history [- ROWS :]
34- arr = np .array (history )
114+ arr = np .array (history ) # now guaranteed rectangular
115+ arr = arr [::- 1 ] # newest row on top
116+
35117 if img is None :
36- img = ax .imshow (arr , aspect = "auto" , cmap = "viridis" )
118+ img = ax .imshow (arr , aspect = "auto" , cmap = CMAP ,
119+ norm = colors .Normalize (vmin = DB_FLOOR , vmax = DB_CEIL ),
120+ interpolation = "nearest" , origin = "upper" ,
121+ extent = [0 , width , 0 , len (arr )])
122+ cbar = fig .colorbar (img , ax = ax , pad = 0.01 , fraction = 0.046 )
123+ cbar .set_label ("power (dBFS)" , color = TEXT , fontsize = 9 )
124+ cbar .ax .yaxis .set_tick_params (color = TEXTDIM )
125+ cbar .outline .set_edgecolor (GRID )
126+ plt .setp (plt .getp (cbar .ax , "yticklabels" ), color = TEXTDIM )
127+ style_axes ()
128+ fig .tight_layout ()
37129 else :
38130 img .set_data (arr )
39- img .set_clim (arr .min (), arr .max ())
131+ img .set_extent ([0 , width , 0 , len (arr )])
132+
40133 plt .pause (0.001 )
41134
42135
136+ print (f"[*] live waterfall { F_MIN / 1e6 :.0f} -{ F_MAX / 1e6 :.0f} MHz "
137+ f"-- close the window or Ctrl-C to stop" )
138+
43139# sweep_stream guarantees hackrf_sweep is reaped on exit, including the
44140# KeyboardInterrupt that ends a live waterfall. Without the context manager a
45141# Ctrl-C could orphan the child sweep process.
46142try :
47143 with h .sweep_stream (F_MIN , F_MAX ) as rows :
48144 for row in rows :
49145 if last_time is not None and row ["time" ] != last_time and sweep_bins :
146+ sweeps_seen += 1
50147 flush_line ()
51148 sweep_bins [row ["hz_low" ]] = np .array (row ["db" ])
52149 last_time = row ["time" ]
53150except KeyboardInterrupt :
54151 pass # context manager has already reaped the child on the way out
152+
153+ print ("[*] stopped" )
0 commit comments