Skip to content

Commit be56d98

Browse files
committed
new tests added for hardware
1 parent 9a598ca commit be56d98

2 files changed

Lines changed: 262 additions & 1 deletion

File tree

hackrfpy/tests/test_calibration.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#! /usr/bin/python3
2+
3+
##--------------------------------------------------------------------\
4+
# hackrfpy 'tests/test_calibration.py'
5+
# Level-1 relative calibration helpers: power in dBFS, gain chain, and
6+
# gain-normalized relative power (with optional offset + freq correction).
7+
# Pure math, no hardware.
8+
##--------------------------------------------------------------------\
9+
10+
import numpy as np
11+
12+
from hackrfpy import HackRF
13+
from hackrfpy import constants as C
14+
15+
16+
def test_power_dbfs_known_amplitude():
17+
h = HackRF()
18+
# amplitude 1.0 -> power 1.0 -> 0 dBFS (full scale)
19+
iq = np.ones(100, dtype=np.complex64)
20+
assert abs(h.power_dbfs(iq) - 0.0) < 1e-6
21+
# amplitude 0.5 -> power 0.25 -> ~ -6.02 dBFS
22+
iq = np.full(100, 0.5 + 0j, dtype=np.complex64)
23+
assert abs(h.power_dbfs(iq) - (-6.0206)) < 1e-3
24+
25+
26+
def test_power_dbfs_empty_is_neg_inf():
27+
h = HackRF()
28+
assert h.power_dbfs(np.array([], dtype=np.complex64)) == float("-inf")
29+
30+
31+
def test_gain_db_sums_chain():
32+
h = HackRF()
33+
assert h.gain_db(16, 20, False) == 36.0
34+
assert h.gain_db(16, 20, True) == 36.0 + C.AMP_DB
35+
assert h.gain_db(0, 0, False) == 0.0
36+
37+
38+
def test_relative_power_normalizes_gain():
39+
# the core property: the SAME physical signal reads the same relative
40+
# value regardless of gain. Higher gain -> higher dBFS by exactly the gain
41+
# delta, which normalization removes.
42+
h = HackRF()
43+
r_low = h.relative_power_db(-60.0, lna=0, vga=0, amp=False)
44+
r_high = h.relative_power_db(-60.0 + 36.0, lna=16, vga=20, amp=False)
45+
assert abs(r_low - r_high) < 1e-9
46+
47+
48+
def test_relative_power_offset_applies():
49+
h = HackRF()
50+
base = h.relative_power_db(-6.0, lna=0, vga=0, amp=False)
51+
with_offset = h.relative_power_db(-6.0, lna=0, vga=0, amp=False,
52+
offset_db=-10.0)
53+
assert abs((with_offset - base) - (-10.0)) < 1e-9
54+
55+
56+
def test_relative_power_freq_correction_applies():
57+
h = HackRF()
58+
curve = lambda f: 3.0 if f > 2e9 else 0.0 # noqa: E731
59+
base = h.relative_power_db(-6.0, lna=0, vga=0, amp=False)
60+
corrected = h.relative_power_db(-6.0, lna=0, vga=0, amp=False,
61+
freq_hz=2.4e9, freq_correction=curve)
62+
assert abs((base - corrected) - 3.0) < 1e-9
63+
64+
65+
def test_relative_power_reads_from_last_params():
66+
# if gains aren't passed, they come from last_params (set by a capture)
67+
h = HackRF()
68+
h.last_params = {"lna_gain": 16, "vga_gain": 20, "amp": False}
69+
r = h.relative_power_db(-6.0)
70+
assert abs(r - (-6.0 - 36.0)) < 1e-9
71+
72+
73+
def test_relative_power_accepts_iq_block():
74+
# passing a complex64 block measures its power then normalizes
75+
h = HackRF()
76+
iq = np.full(100, 0.5 + 0j, dtype=np.complex64) # -6.02 dBFS
77+
r = h.relative_power_db(iq, lna=0, vga=0, amp=False)
78+
assert abs(r - (-6.0206)) < 1e-3

hackrfpy/tests/test_hardware.py

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,205 @@
33
##--------------------------------------------------------------------\
44
# hackrfpy 'tests/test_hardware.py'
55
# Tests that need a connected HackRF. Auto-skip when absent (see conftest).
6+
#
7+
# SAFETY: every test here is READ-ONLY / receive-only. None transmit, none
8+
# write firmware. They run automatically whenever a board is detected, so
9+
# they must never do anything that could affect hardware or the spectrum.
10+
#
11+
# These exist to validate the real device paths -- the things stubs and
12+
# frozen fixtures can't prove: that hackrf_info output matches the parser,
13+
# that a real capture yields the right sample count, that hackrf_sweep -N
14+
# actually terminates, that the reap paths stop a real child cleanly.
615
##--------------------------------------------------------------------\
716

17+
import time
18+
19+
import numpy as np
820
import pytest
9-
from hackrfpy import HackRF
1021

22+
from hackrfpy import HackRF, load_iq, read_sigmf_meta
23+
24+
# A small, USB-friendly capture config used across tests. 2 Msps keeps data
25+
# volume low; FM center is always legal to receive and usually has signal.
26+
RX_FREQ = 100_000_000
27+
RX_RATE = 2_000_000
28+
SWEEP_LO, SWEEP_HI = 88_000_000, 108_000_000
1129

30+
31+
# ---- identity / detection --------------------------------------------------
1232
@pytest.mark.hardware
1333
def test_info_returns_board():
1434
h = HackRF()
1535
parsed = h.info()
1636
assert parsed["boards"], "expected a connected board"
37+
b = parsed["boards"][0]
38+
# real device must report a serial and a HackRF board id
39+
assert b.get("serial_number")
40+
assert "hackrf" in (b.get("board_id_number", "")).lower()
41+
42+
43+
@pytest.mark.hardware
44+
def test_detect_reports_ready():
45+
h = HackRF()
46+
det = h.detect()
47+
assert det["found"] is True
48+
assert det["ready"] is True
49+
assert det["count"] >= 1
50+
assert det["boards"][0]["is_hackrf"] is True
51+
# firmware should be reported (string like "2024.02.1 (API:1.08)")
52+
assert det["boards"][0]["firmware"]
53+
54+
55+
@pytest.mark.hardware
56+
def test_identify_matches_detect():
57+
h = HackRF()
58+
board = h.identify()
59+
assert board is not None
60+
assert board["is_hackrf"] is True
61+
# identify by the real serial substring should find the same board
62+
serial = board["serial"]
63+
again = h.identify(serial[-8:])
64+
assert again is not None
65+
assert again["serial"] == serial
66+
67+
68+
@pytest.mark.hardware
69+
def test_from_device_probes_ok():
70+
# fail-fast constructor: should succeed with a board present
71+
h = HackRF.from_device()
72+
assert h is not None
1773

1874

75+
@pytest.mark.hardware
76+
def test_features_reports_capabilities():
77+
h = HackRF()
78+
feats = h.features()
79+
# a real modern board/tools should support streaming + sweep -N
80+
assert feats["stdout_streaming"] is True
81+
assert feats["sweep_num_sweeps"] is True
82+
83+
84+
# ---- doctor / preflight ----------------------------------------------------
1985
@pytest.mark.hardware
2086
def test_doctor_finds_tools():
2187
h = HackRF()
2288
report = h.doctor()
2389
assert report["tools"].get("hackrf_info")
2490
assert not report["problems"], report["problems"]
91+
92+
93+
@pytest.mark.hardware
94+
def test_preflight_clean_with_board():
95+
h = HackRF()
96+
report = h.preflight()
97+
# core tools present, board detected -> no problems
98+
assert not report["problems"], report["problems"]
99+
assert report["features"]["stdout_streaming"] is True
100+
101+
102+
# ---- receive: the real capture path ---------------------------------------
103+
@pytest.mark.hardware
104+
def test_capture_array_returns_exact_count():
105+
h = HackRF()
106+
n = 100_000
107+
iq = h.capture_array(RX_FREQ, RX_RATE, n)
108+
assert iq.dtype == np.complex64
109+
assert len(iq) == n # EXACTLY the requested count
110+
# real samples should not be all-zero (we're receiving something)
111+
assert not np.all(iq == 0)
112+
# int8 full-scale normalized to ~[-1,1)
113+
assert np.max(np.abs(iq)) <= 1.5
114+
115+
116+
@pytest.mark.hardware
117+
def test_capture_to_file_with_sigmf(tmp_path):
118+
h = HackRF()
119+
out = str(tmp_path / "cap.iq")
120+
h.capture(RX_FREQ, RX_RATE, num_samples=50_000, out=out, sigmf=True)
121+
iq = load_iq(out)
122+
assert len(iq) == 50_000
123+
meta = read_sigmf_meta(out)
124+
assert meta["captures"][0]["core:frequency"] == RX_FREQ
125+
assert meta["global"]["core:sample_rate"] == RX_RATE
126+
127+
128+
@pytest.mark.hardware
129+
def test_capture_callback_fires_on_real_stream():
130+
h = HackRF()
131+
blocks = {"n": 0, "samples": 0}
132+
133+
def on_block(iq, total):
134+
blocks["n"] += 1
135+
blocks["samples"] += len(iq)
136+
137+
total = h.capture_callback(RX_FREQ, RX_RATE, on_block=on_block,
138+
max_samples=200_000)
139+
assert blocks["n"] > 0 # callback actually fired
140+
assert total >= 200_000
141+
142+
143+
@pytest.mark.hardware
144+
def test_capture_stream_context_reaps():
145+
# the stream context manager must yield real blocks and stop cleanly
146+
h = HackRF()
147+
got = 0
148+
with h.capture_stream(RX_FREQ, RX_RATE) as blocks:
149+
for iq in blocks:
150+
got += len(iq)
151+
if got >= 200_000:
152+
break # early exit must reap the child
153+
assert got >= 200_000
154+
155+
156+
@pytest.mark.hardware
157+
def test_scan_frequencies_real():
158+
h = HackRF()
159+
freqs = [100_000_000, 433_920_000]
160+
out = h.scan_frequencies(freqs, RX_RATE, num_samples=20_000)
161+
assert set(out.keys()) == set(freqs)
162+
assert all(len(v) == 20_000 for v in out.values())
163+
164+
165+
# ---- sweep: the termination + ordering behavior ---------------------------
166+
@pytest.mark.hardware
167+
def test_sweep_collect_terminates():
168+
# the big one: a bounded sweep (-N) must actually TERMINATE, not hang.
169+
h = HackRF()
170+
t0 = time.time()
171+
rows = h.sweep_collect(SWEEP_LO, SWEEP_HI, num_sweeps=1)
172+
elapsed = time.time() - t0
173+
assert rows, "sweep returned no rows"
174+
assert elapsed < 30, "sweep did not terminate promptly"
175+
# each row has the expected structure
176+
r = rows[0]
177+
assert "hz_low" in r and "hz_high" in r and "db" in r
178+
assert isinstance(r["db"], list) and len(r["db"]) >= 1
179+
180+
181+
@pytest.mark.hardware
182+
def test_sweep_rows_share_timestamp_per_pass():
183+
# rows of one sweep pass share a timestamp (the waterfall relies on this)
184+
h = HackRF()
185+
rows = h.sweep_collect(SWEEP_LO, SWEEP_HI, num_sweeps=1)
186+
times = set(r["time"] for r in rows)
187+
# one pass -> ideally one timestamp; allow a little slack for boundary
188+
assert len(times) <= 2
189+
190+
191+
@pytest.mark.hardware
192+
def test_sweep_to_file_writes(tmp_path):
193+
h = HackRF()
194+
out = str(tmp_path / "sweep.csv")
195+
h.sweep_to_file(SWEEP_LO, SWEEP_HI, out, num_sweeps=1)
196+
import os
197+
assert os.path.getsize(out) > 0
198+
199+
200+
# ---- read-only device management -------------------------------------------
201+
@pytest.mark.hardware
202+
def test_operacake_list_runs():
203+
# no Opera Cake attached is fine -- it just must run without error
204+
h = HackRF()
205+
out = h.operacake_list()
206+
text = out[0] if isinstance(out, tuple) else str(out)
207+
assert isinstance(text, str)

0 commit comments

Comments
 (0)