1+ #! /usr/bin/python3
2+
3+ ##--------------------------------------------------------------------\
4+ # hackrfpy 'examples/collect_sample_data.py'
5+ #
6+ # Collect REAL, usable example datasets from a connected HackRF One and
7+ # write them into a sample library (examples/sample_data/) with SigMF
8+ # metadata, so anyone who clones the repo has actual recordings to load,
9+ # inspect, and feed into downstream processing -- without owning a board.
10+ #
11+ # This is DIFFERENT from tests/collect_real_data.py:
12+ # - collect_real_data.py freezes TINY verbatim slices as PARSER TEST
13+ # FIXTURES (16 bytes of IQ, a few sweep rows).
14+ # - this script collects SAMPLE DATASETS: real captures big enough to
15+ # be useful, with SigMF sidecars, organized as a sample library.
16+ #
17+ # SAFETY: strictly READ-ONLY. Receive and sweep only. Never transmits,
18+ # never writes firmware. Stays in RX mode throughout.
19+ #
20+ # Sizes are kept small by default so the samples can live in the repo
21+ # (a 0.5 s capture at 2 Msps is ~2 MB of int8 I/Q). Use --seconds /
22+ # --sample-rate to collect larger local datasets.
23+ #
24+ # Usage:
25+ # uv run python examples/collect_sample_data.py
26+ # uv run python examples/collect_sample_data.py --tools-dir "C:\hackrf-tools-windows"
27+ # uv run python examples/collect_sample_data.py --band fm --band ism433
28+ # uv run python examples/collect_sample_data.py --seconds 1.0 --sample-rate 4e6
29+ #
30+ # (Use `uv run` so the project environment with numpy is used.)
31+ ##--------------------------------------------------------------------\
32+
33+ import argparse
34+ import datetime
35+ import os
36+ import sys
37+
38+ _HERE = os .path .dirname (os .path .abspath (__file__ ))
39+ for cand in (os .path .join (_HERE , ".." , "src" ), os .path .join (_HERE , "src" )):
40+ if os .path .isdir (os .path .join (cand , "hackrfpy" )):
41+ sys .path .insert (0 , os .path .abspath (cand ))
42+ break
43+
44+ from hackrfpy import HackRF , load_iq # noqa: E402
45+ from hackrfpy .exceptions import HackRFError # noqa: E402
46+
47+ try :
48+ import numpy as np # noqa: E402
49+ except ModuleNotFoundError :
50+ sys .stderr .write (
51+ "ERROR: numpy not available -- run through uv so the project env is "
52+ "used:\n uv run python examples/collect_sample_data.py [args]\n " )
53+ sys .exit (1 )
54+
55+ OUT_DIR = os .path .join (_HERE , "sample_data" )
56+
57+ # Named band presets: an IQ capture at `center`, and (optionally) a sweep over
58+ # (sweep_min, sweep_max). All legal to RECEIVE; FM is the universal default.
59+ BANDS = {
60+ "fm" : {
61+ "center" : 98_000_000 , "sweep" : (88_000_000 , 108_000_000 ),
62+ "desc" : "FM broadcast band" },
63+ "airband" : {
64+ "center" : 124_000_000 , "sweep" : (118_000_000 , 137_000_000 ),
65+ "desc" : "VHF airband (AM voice)" },
66+ "ism433" : {
67+ "center" : 433_920_000 , "sweep" : (433_000_000 , 435_000_000 ),
68+ "desc" : "433 MHz ISM" },
69+ "ism915" : {
70+ "center" : 915_000_000 , "sweep" : (902_000_000 , 928_000_000 ),
71+ "desc" : "915 MHz ISM (US)" },
72+ "noaa" : {
73+ "center" : 137_500_000 , "sweep" : (137_000_000 , 138_000_000 ),
74+ "desc" : "NOAA weather satellite downlink" },
75+ }
76+
77+
78+ def _signal_summary (iq ):
79+ # A quick, honest description of what was captured: mean power and whether
80+ # there's evident signal vs noise floor. Not DSP -- just a sanity readout.
81+ if len (iq ) == 0 :
82+ return "empty"
83+ power = float (np .mean (np .abs (iq ) ** 2 ))
84+ peak = float (np .max (np .abs (iq )))
85+ db = 10 * np .log10 (power + 1e-12 )
86+ return f"mean { db :.1f} dBFS, peak |amp| { peak :.3f} "
87+
88+
89+ def collect_band (h , name , args ):
90+ band = BANDS [name ]
91+ os .makedirs (OUT_DIR , exist_ok = True )
92+ n = int (args .sample_rate * args .seconds )
93+ results = []
94+
95+ # ---- IQ capture (with SigMF sidecar via capture()) ----
96+ iq_path = os .path .join (OUT_DIR , f"{ name } _{ int (args .sample_rate / 1e6 )} Msps.iq" )
97+ print (f"\n == { name } : IQ capture @ { band ['center' ]/ 1e6 :g} MHz "
98+ f"({ args .seconds } s, { args .sample_rate / 1e6 :g} Msps) ==" )
99+ try :
100+ h .capture (band ["center" ], args .sample_rate , num_samples = n ,
101+ out = iq_path , sigmf = True )
102+ iq = load_iq (iq_path )
103+ size_mb = os .path .getsize (iq_path ) / 1e6
104+ print (f" wrote { os .path .basename (iq_path )} "
105+ f"({ size_mb :.1f} MB, { len (iq )} samples) -- { _signal_summary (iq )} " )
106+ print (f" + { os .path .basename (iq_path ).rsplit ('.' ,1 )[0 ]} .sigmf-meta" )
107+ results .append (iq_path )
108+ except HackRFError as e :
109+ print (f" IQ capture failed: { e } " , file = sys .stderr )
110+
111+ # ---- sweep dataset (CSV) ----
112+ if not args .no_sweep and band .get ("sweep" ):
113+ lo , hi = band ["sweep" ]
114+ sweep_path = os .path .join (OUT_DIR , f"{ name } _sweep.csv" )
115+ print (f" sweep { lo / 1e6 :g} -{ hi / 1e6 :g} MHz ..." )
116+ try :
117+ rows = h .sweep_collect (lo , hi , num_sweeps = args .sweep_count )
118+ with open (sweep_path , "w" , newline = "\n " ) as f :
119+ f .write ("date,time,hz_low,hz_high,bin_width,num_samples,db...\n " )
120+ for r in rows :
121+ f .write (", " .join (
122+ [r ["date" ], r ["time" ], str (r ["hz_low" ]),
123+ str (r ["hz_high" ]), f"{ r ['bin_width' ]:.2f} " ,
124+ str (r ["num_samples" ])]
125+ + [f"{ d :.2f} " for d in r ["db" ]]) + "\n " )
126+ print (f" wrote { os .path .basename (sweep_path )} ({ len (rows )} rows)" )
127+ results .append (sweep_path )
128+ except HackRFError as e :
129+ print (f" sweep failed: { e } " , file = sys .stderr )
130+
131+ return results
132+
133+
134+ def write_readme (collected , args , det ):
135+ # A README for the sample library so the data is self-documenting.
136+ path = os .path .join (OUT_DIR , "README.md" )
137+ with open (path , "w" , newline = "\n " ) as f :
138+ f .write ("# hackrfpy sample data\n \n " )
139+ f .write ("Real recordings from a HackRF One, for trying the library "
140+ "and downstream processing without owning a board.\n \n " )
141+ f .write (f"- collected: "
142+ f"{ datetime .datetime .now ().isoformat (timespec = 'seconds' )} \n " )
143+ f .write (f"- device firmware: { det ['boards' ][0 ].get ('firmware' )} \n " )
144+ f .write (f"- tools: { det ['tools_version' ]} \n " )
145+ f .write (f"- sample rate: { args .sample_rate / 1e6 :g} Msps, "
146+ f"{ args .seconds } s per IQ capture\n \n " )
147+ f .write ("Each `.iq` is interleaved int8 I/Q (HackRF native) with a "
148+ "`.sigmf-meta` sidecar describing frequency, rate, and gains. "
149+ "Load with:\n \n " )
150+ f .write ("```python\n from hackrfpy import load_iq, read_sigmf_meta\n "
151+ "iq = load_iq('fm_2Msps.iq')\n "
152+ "meta = read_sigmf_meta('fm_2Msps.iq')\n ```\n \n " )
153+ f .write ("## Files\n \n " )
154+ for p in collected :
155+ f .write (f"- `{ os .path .basename (p )} `\n " )
156+ print (f"\n wrote { os .path .relpath (path , _HERE )} " )
157+
158+
159+ def main ():
160+ p = argparse .ArgumentParser (
161+ description = "Collect real sample datasets from a HackRF (READ-ONLY)." )
162+ p .add_argument ("--tools-dir" , default = None )
163+ p .add_argument ("--band" , action = "append" , choices = list (BANDS ),
164+ help = "band(s) to collect; repeatable. Default: fm" )
165+ p .add_argument ("--seconds" , type = float , default = 0.5 ,
166+ help = "capture duration per band (default 0.5s)" )
167+ p .add_argument ("--sample-rate" , type = float , default = 2e6 ,
168+ help = "sample rate in sps (default 2e6, small + USB-friendly)" )
169+ p .add_argument ("--sweep-count" , type = int , default = 1 ,
170+ help = "sweeps per band dataset (default 1)" )
171+ p .add_argument ("--no-sweep" , action = "store_true" ,
172+ help = "IQ captures only, skip sweep datasets" )
173+ args = p .parse_args ()
174+ bands = args .band or ["fm" ]
175+
176+ h = HackRF (tools_dir = args .tools_dir , verbose = False )
177+ print ("== confirming a real board before collecting ==" )
178+ det = h .detect ()
179+ if not det ["ready" ]:
180+ print (f" NO USABLE HACKRF: { det ['problem' ]} " , file = sys .stderr )
181+ return 1
182+ print (f" ready: firmware { det ['boards' ][0 ].get ('firmware' )} " )
183+ for w in det .get ("warnings" , []):
184+ print (f" ! { w } " )
185+
186+ collected = []
187+ try :
188+ for name in bands :
189+ collected += collect_band (h , name , args )
190+ except KeyboardInterrupt :
191+ print ("\n interrupted" , file = sys .stderr )
192+ if collected :
193+ write_readme (collected , args , det )
194+ total = sum (os .path .getsize (p ) for p in collected
195+ if p .endswith (".iq" )) / 1e6
196+ print (f"\n == done: { len (collected )} files, ~{ total :.1f} MB of IQ "
197+ f"in { os .path .relpath (OUT_DIR , _HERE )} ==" )
198+ return 0
199+
200+
201+ if __name__ == "__main__" :
202+ sys .exit (main ())
0 commit comments