|
| 1 | +#! /usr/bin/python3 |
| 2 | +##--------------------------------------------------------------------\ |
| 3 | +# hackrfpy 'examples/calibrate.py' |
| 4 | +# Calibration WORKFLOW (Levels 2-3) on top of the library's Level-1 |
| 5 | +# gain-normalized readings. This is an EXAMPLE, not library code, because |
| 6 | +# it depends on hardware you must supply: a signal of KNOWN power. |
| 7 | +# |
| 8 | +# What this can and cannot do: |
| 9 | +# * The HackRF is NOT a calibrated instrument. There is no factory |
| 10 | +# per-unit characterization of absolute power. |
| 11 | +# * Level 1 (in the library, relative_power_db): subtracts the gain chain |
| 12 | +# so readings are CONSISTENT across gain settings. Relative, honest. |
| 13 | +# * Level 3 (here): if you feed a KNOWN power into the antenna port, you |
| 14 | +# can compute a single-point OFFSET that makes readings approximately |
| 15 | +# absolute (dBm) -- good to maybe +/-2-3 dB, NOT lab-traceable, and only |
| 16 | +# valid for THIS board near the reference frequency/gain. |
| 17 | +# * Level 2 (here, optional): sweep a flat reference to characterize the |
| 18 | +# front-end's frequency response, producing a correction you can feed |
| 19 | +# back into relative_power_db(freq_correction=...). |
| 20 | +# |
| 21 | +# You need a reference source: a signal generator set to a known dBm, or a |
| 22 | +# characterized noise source. WITHOUT one, you cannot get absolute dBm -- |
| 23 | +# the script will still show you gain-consistent RELATIVE readings. |
| 24 | +# |
| 25 | +# SAFETY: read-only / receive-only. Never transmits. |
| 26 | +# |
| 27 | +# Usage (single-point offset at a known reference): |
| 28 | +# uv run python examples/calibrate.py --ref-freq 100e6 --ref-dbm -30 |
| 29 | +# Then use the printed offset: |
| 30 | +# h.relative_power_db(iq, offset_db=<printed offset>) |
| 31 | +##--------------------------------------------------------------------\ |
| 32 | +import argparse |
| 33 | +import json |
| 34 | +import sys |
| 35 | +import numpy as np |
| 36 | +from hackrfpy import HackRF |
| 37 | + |
| 38 | + |
| 39 | +def measure_dbfs(h, freq, rate, n, lna, vga, amp): |
| 40 | + iq = h.capture_array(freq, rate, n, lna=lna, vga=vga, amp=amp) |
| 41 | + return h.power_dbfs(iq) |
| 42 | + |
| 43 | + |
| 44 | +def single_point_offset(h, args): |
| 45 | + # Level 3: feed a KNOWN dBm at ref-freq, measure dBFS, derive offset such |
| 46 | + # that approx_dbm = relative_power_db(dbfs, ..., offset_db=offset). |
| 47 | + print(f"\n== single-point offset @ {args.ref_freq/1e6:g} MHz ==") |
| 48 | + print(f" FEED A KNOWN {args.ref_dbm} dBm SIGNAL into the antenna port now.") |
| 49 | + if not args.assume_ready: |
| 50 | + input(" press Enter when the reference is connected and on... ") |
| 51 | + |
| 52 | + dbfs = measure_dbfs(h, args.ref_freq, args.rate, args.samples, |
| 53 | + args.lna, args.vga, args.amp) |
| 54 | + # relative reading with zero offset = dbfs - gain_chain |
| 55 | + relative = h.relative_power_db(dbfs, lna=args.lna, vga=args.vga, |
| 56 | + amp=args.amp, offset_db=0.0) |
| 57 | + # we want: ref_dbm = relative + offset -> offset = ref_dbm - relative |
| 58 | + offset = args.ref_dbm - relative |
| 59 | + print(f" measured: {dbfs:.2f} dBFS (relative {relative:.2f} dB)") |
| 60 | + print(f" => offset_db = {offset:.2f}") |
| 61 | + print(f" sanity: relative_power_db(..., offset_db={offset:.2f}) now reads " |
| 62 | + f"~{args.ref_dbm} dBm at this freq/gain") |
| 63 | + return offset |
| 64 | + |
| 65 | + |
| 66 | +def freq_response_curve(h, args): |
| 67 | + # Level 2: characterize the front-end shape. IDEALLY you sweep a FLAT |
| 68 | + # reference (a broadband noise source with known-flat output). Lacking |
| 69 | + # that, this still captures the *relative* shape vs the reference freq, |
| 70 | + # which removes most of the "why is 2.4 GHz lower than 100 MHz" confound. |
| 71 | + print(f"\n== frequency-response characterization ==") |
| 72 | + print(" NOTE: only meaningful with a FLAT reference source connected.") |
| 73 | + if not args.assume_ready: |
| 74 | + input(" press Enter with the flat reference on (or Ctrl-C to skip)... ") |
| 75 | + |
| 76 | + freqs = np.linspace(args.fr_min, args.fr_max, args.fr_points) |
| 77 | + table = {} |
| 78 | + ref = None |
| 79 | + for f in freqs: |
| 80 | + dbfs = measure_dbfs(h, float(f), args.rate, args.samples, |
| 81 | + args.lna, args.vga, args.amp) |
| 82 | + rel = h.relative_power_db(dbfs, lna=args.lna, vga=args.vga, amp=args.amp) |
| 83 | + if ref is None: |
| 84 | + ref = rel |
| 85 | + # correction = how much this freq reads ABOVE the reference; subtract |
| 86 | + # it to flatten. Stored as freq -> correction_dB. |
| 87 | + table[float(f)] = rel - ref |
| 88 | + print(f" {f/1e6:8.1f} MHz : {rel:7.2f} dB (corr {table[float(f)]:+.2f})") |
| 89 | + return table |
| 90 | + |
| 91 | + |
| 92 | +def main(): |
| 93 | + p = argparse.ArgumentParser( |
| 94 | + description="HackRF calibration workflow (read-only). Levels 2-3 on " |
| 95 | + "top of the library's Level-1 relative readings.") |
| 96 | + p.add_argument("--tools-dir", default=None) |
| 97 | + p.add_argument("--rate", type=float, default=2e6) |
| 98 | + p.add_argument("--samples", type=int, default=200_000) |
| 99 | + p.add_argument("--lna", type=int, default=16) |
| 100 | + p.add_argument("--vga", type=int, default=20) |
| 101 | + p.add_argument("--amp", action="store_true") |
| 102 | + # Level 3 |
| 103 | + p.add_argument("--ref-freq", type=float, default=None, |
| 104 | + help="reference frequency Hz (enables single-point offset)") |
| 105 | + p.add_argument("--ref-dbm", type=float, default=None, |
| 106 | + help="known reference power in dBm at --ref-freq") |
| 107 | + # Level 2 |
| 108 | + p.add_argument("--freq-response", action="store_true", |
| 109 | + help="characterize front-end frequency response") |
| 110 | + p.add_argument("--fr-min", type=float, default=100e6) |
| 111 | + p.add_argument("--fr-max", type=float, default=1e9) |
| 112 | + p.add_argument("--fr-points", type=int, default=10) |
| 113 | + # misc |
| 114 | + p.add_argument("--assume-ready", action="store_true", |
| 115 | + help="skip the 'press Enter' prompts (reference already on)") |
| 116 | + p.add_argument("--out", default="calibration.json", |
| 117 | + help="where to save the derived calibration") |
| 118 | + args = p.parse_args() |
| 119 | + |
| 120 | + h = HackRF(tools_dir=args.tools_dir) |
| 121 | + det = h.detect() |
| 122 | + if not det["ready"]: |
| 123 | + print(f"no usable HackRF: {det['problem']}", file=sys.stderr) |
| 124 | + return 1 |
| 125 | + print(f"[*] board ready: firmware {det['boards'][0].get('firmware')}") |
| 126 | + print("[*] REMINDER: the HackRF is not a calibrated instrument; results " |
| 127 | + "are approximate and specific to this board.") |
| 128 | + |
| 129 | + cal = {"board_serial": det["boards"][0]["serial"], |
| 130 | + "gain": {"lna": args.lna, "vga": args.vga, "amp": args.amp}, |
| 131 | + "offset_db": None, "freq_correction": None} |
| 132 | + |
| 133 | + if args.ref_freq is not None and args.ref_dbm is not None: |
| 134 | + cal["offset_db"] = single_point_offset(h, args) |
| 135 | + cal["offset_ref_freq_hz"] = args.ref_freq |
| 136 | + else: |
| 137 | + print("\n[*] no --ref-freq/--ref-dbm given: skipping absolute offset " |
| 138 | + "(readings stay RELATIVE, gain-consistent).") |
| 139 | + |
| 140 | + if args.freq_response: |
| 141 | + try: |
| 142 | + cal["freq_correction"] = freq_response_curve(h, args) |
| 143 | + except KeyboardInterrupt: |
| 144 | + print("\n (skipped frequency-response step)") |
| 145 | + |
| 146 | + with open(args.out, "w") as f: |
| 147 | + json.dump(cal, f, indent=2) |
| 148 | + print(f"\n[*] saved calibration -> {args.out}") |
| 149 | + print("[*] use it like:") |
| 150 | + print(" cal = json.load(open('calibration.json'))") |
| 151 | + print(" db = h.relative_power_db(iq, offset_db=cal['offset_db'] or 0)") |
| 152 | + return 0 |
| 153 | + |
| 154 | + |
| 155 | +if __name__ == "__main__": |
| 156 | + sys.exit(main()) |
0 commit comments