mirror of
https://github.com/smittix/intercept.git
synced 2026-07-06 00:28:12 -07:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
+16
-7
@@ -817,9 +817,8 @@ def start_adsb():
|
|||||||
bias_t=bias_t
|
bias_t=bias_t
|
||||||
)
|
)
|
||||||
|
|
||||||
# For RTL-SDR, ensure we use the found dump1090 path
|
# Ensure we use the resolved binary path for all SDR types
|
||||||
if sdr_type == SDRType.RTL_SDR:
|
cmd[0] = dump1090_path
|
||||||
cmd[0] = dump1090_path
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
|
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
|
||||||
@@ -860,12 +859,22 @@ def start_adsb():
|
|||||||
error_type = 'START_FAILED'
|
error_type = 'START_FAILED'
|
||||||
stderr_lower = stderr_output.lower()
|
stderr_lower = stderr_output.lower()
|
||||||
|
|
||||||
|
sdr_label = sdr_type.value
|
||||||
|
|
||||||
if 'usb_claim_interface' in stderr_lower or 'libusb_error_busy' in stderr_lower or 'device or resource busy' in stderr_lower:
|
if 'usb_claim_interface' in stderr_lower or 'libusb_error_busy' in stderr_lower or 'device or resource busy' in stderr_lower:
|
||||||
error_msg = 'SDR device is busy. Another process may be using it.'
|
error_msg = 'SDR device is busy. Another process may be using it.'
|
||||||
suggestion = 'Try: 1) Stop other SDR applications, 2) Run "pkill -f rtl_" to kill stale processes, or 3) Remove and reinsert the SDR device.'
|
suggestion = 'Try: 1) Stop other SDR applications, 2) Run "pkill -f rtl_" to kill stale processes, or 3) Remove and reinsert the SDR device.'
|
||||||
error_type = 'DEVICE_BUSY'
|
error_type = 'DEVICE_BUSY'
|
||||||
|
elif 'no hackrf boards found' in stderr_lower or 'hackrf_open' in stderr_lower:
|
||||||
|
error_msg = f'{sdr_label} device not found.'
|
||||||
|
suggestion = 'Ensure the HackRF is connected. Try removing and reinserting the device.'
|
||||||
|
error_type = 'DEVICE_NOT_FOUND'
|
||||||
|
elif 'soapysdr not found' in stderr_lower or 'soapy' in stderr_lower and 'not found' in stderr_lower:
|
||||||
|
error_msg = f'SoapySDR driver not found for {sdr_label}.'
|
||||||
|
suggestion = f'Install SoapySDR and the {sdr_label} module (e.g., soapysdr-module-hackrf).'
|
||||||
|
error_type = 'DRIVER_NOT_FOUND'
|
||||||
elif 'no supported devices' in stderr_lower or 'no rtl-sdr' in stderr_lower or 'failed to open' in stderr_lower:
|
elif 'no supported devices' in stderr_lower or 'no rtl-sdr' in stderr_lower or 'failed to open' in stderr_lower:
|
||||||
error_msg = 'RTL-SDR device not found.'
|
error_msg = f'{sdr_label} device not found.'
|
||||||
suggestion = 'Ensure the device is connected. Try removing and reinserting the SDR.'
|
suggestion = 'Ensure the device is connected. Try removing and reinserting the SDR.'
|
||||||
error_type = 'DEVICE_NOT_FOUND'
|
error_type = 'DEVICE_NOT_FOUND'
|
||||||
elif 'kernel driver is active' in stderr_lower or 'dvb' in stderr_lower:
|
elif 'kernel driver is active' in stderr_lower or 'dvb' in stderr_lower:
|
||||||
@@ -873,14 +882,14 @@ def start_adsb():
|
|||||||
suggestion = 'Blacklist the DVB drivers: Go to Settings > Hardware > "Blacklist DVB Drivers" or run "sudo rmmod dvb_usb_rtl28xxu".'
|
suggestion = 'Blacklist the DVB drivers: Go to Settings > Hardware > "Blacklist DVB Drivers" or run "sudo rmmod dvb_usb_rtl28xxu".'
|
||||||
error_type = 'KERNEL_DRIVER'
|
error_type = 'KERNEL_DRIVER'
|
||||||
elif 'permission' in stderr_lower or 'access' in stderr_lower:
|
elif 'permission' in stderr_lower or 'access' in stderr_lower:
|
||||||
error_msg = 'Permission denied accessing RTL-SDR device.'
|
error_msg = f'Permission denied accessing {sdr_label} device.'
|
||||||
suggestion = 'Run Intercept with sudo, or add udev rules for RTL-SDR devices.'
|
suggestion = f'Run Intercept with sudo, or add udev rules for {sdr_label} devices.'
|
||||||
error_type = 'PERMISSION_DENIED'
|
error_type = 'PERMISSION_DENIED'
|
||||||
elif sdr_type == SDRType.RTL_SDR:
|
elif sdr_type == SDRType.RTL_SDR:
|
||||||
error_msg = 'dump1090 failed to start.'
|
error_msg = 'dump1090 failed to start.'
|
||||||
suggestion = 'Try removing and reinserting the SDR device, or check if another application is using it.'
|
suggestion = 'Try removing and reinserting the SDR device, or check if another application is using it.'
|
||||||
else:
|
else:
|
||||||
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}.'
|
error_msg = f'ADS-B decoder failed to start for {sdr_label}.'
|
||||||
suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.'
|
suggestion = 'Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||||
|
|
||||||
full_msg = f'{error_msg} {suggestion}'
|
full_msg = f'{error_msg} {suggestion}'
|
||||||
|
|||||||
+185
-14
@@ -1890,19 +1890,179 @@ def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float |
|
|||||||
return timestamp, None, None, []
|
return timestamp, None, None, []
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_waterfall_error(message: str) -> None:
|
||||||
|
"""Push an error message onto the waterfall SSE queue."""
|
||||||
|
try:
|
||||||
|
waterfall_queue.put_nowait({
|
||||||
|
'type': 'waterfall_error',
|
||||||
|
'message': message,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _waterfall_loop():
|
def _waterfall_loop():
|
||||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
"""Continuous waterfall sweep loop emitting FFT data."""
|
||||||
global waterfall_running, waterfall_process
|
global waterfall_running, waterfall_process
|
||||||
|
|
||||||
def _queue_waterfall_error(message: str) -> None:
|
sdr_type_str = waterfall_config.get('sdr_type', 'rtlsdr')
|
||||||
try:
|
try:
|
||||||
waterfall_queue.put_nowait({
|
sdr_type = SDRType(sdr_type_str)
|
||||||
'type': 'waterfall_error',
|
except ValueError:
|
||||||
'message': message,
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
|
_waterfall_loop_rtl_power()
|
||||||
|
else:
|
||||||
|
_waterfall_loop_iq(sdr_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _waterfall_loop_iq(sdr_type: SDRType):
|
||||||
|
"""Waterfall loop using rx_sdr IQ capture + FFT for HackRF/SoapySDR devices."""
|
||||||
|
global waterfall_running, waterfall_process
|
||||||
|
|
||||||
|
start_freq = waterfall_config['start_freq']
|
||||||
|
end_freq = waterfall_config['end_freq']
|
||||||
|
gain = waterfall_config['gain']
|
||||||
|
device = waterfall_config['device']
|
||||||
|
interval = float(waterfall_config.get('interval', 0.4))
|
||||||
|
|
||||||
|
# Use center frequency and sample rate to cover the requested span
|
||||||
|
center_mhz = (start_freq + end_freq) / 2.0
|
||||||
|
span_hz = (end_freq - start_freq) * 1e6
|
||||||
|
# Pick a sample rate that covers the span (minimum 2 MHz for HackRF)
|
||||||
|
sample_rate = max(2000000, int(span_hz))
|
||||||
|
# Cap to sensible maximum
|
||||||
|
sample_rate = min(sample_rate, 20000000)
|
||||||
|
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
|
||||||
|
cmd = builder.build_iq_capture_command(
|
||||||
|
device=sdr_device,
|
||||||
|
frequency_mhz=center_mhz,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
gain=float(gain),
|
||||||
|
)
|
||||||
|
|
||||||
|
fft_size = min(int(waterfall_config.get('max_bins') or 1024), 4096)
|
||||||
|
|
||||||
|
try:
|
||||||
|
waterfall_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detect immediate startup failures
|
||||||
|
time.sleep(0.35)
|
||||||
|
if waterfall_process.poll() is not None:
|
||||||
|
stderr_text = ''
|
||||||
|
try:
|
||||||
|
if waterfall_process.stderr:
|
||||||
|
stderr_text = waterfall_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
|
except Exception:
|
||||||
|
stderr_text = ''
|
||||||
|
msg = stderr_text or f'IQ capture exited early (code {waterfall_process.returncode})'
|
||||||
|
logger.error(f"Waterfall startup failed: {msg}")
|
||||||
|
_queue_waterfall_error(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not waterfall_process.stdout:
|
||||||
|
_queue_waterfall_error('IQ capture stdout unavailable')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read IQ samples and compute FFT
|
||||||
|
# CU8 format: interleaved unsigned 8-bit I/Q pairs
|
||||||
|
bytes_per_sample = 2 # 1 byte I + 1 byte Q
|
||||||
|
chunk_bytes = fft_size * bytes_per_sample
|
||||||
|
received_any = False
|
||||||
|
|
||||||
|
while waterfall_running:
|
||||||
|
raw = waterfall_process.stdout.read(chunk_bytes)
|
||||||
|
if not raw or len(raw) < chunk_bytes:
|
||||||
|
if waterfall_process.poll() is not None:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
received_any = True
|
||||||
|
|
||||||
|
# Convert CU8 to complex float: center at 127.5
|
||||||
|
iq = struct.unpack(f'{fft_size * 2}B', raw)
|
||||||
|
# Compute power spectrum via FFT
|
||||||
|
real_parts = [(iq[i * 2] - 127.5) / 127.5 for i in range(fft_size)]
|
||||||
|
imag_parts = [(iq[i * 2 + 1] - 127.5) / 127.5 for i in range(fft_size)]
|
||||||
|
|
||||||
|
bins: list[float] = []
|
||||||
|
try:
|
||||||
|
# Try numpy if available for efficient FFT
|
||||||
|
import numpy as np
|
||||||
|
samples = np.array(real_parts, dtype=np.float32) + 1j * np.array(imag_parts, dtype=np.float32)
|
||||||
|
# Apply Hann window
|
||||||
|
window = np.hanning(fft_size)
|
||||||
|
samples *= window
|
||||||
|
spectrum = np.fft.fftshift(np.fft.fft(samples))
|
||||||
|
power_db = 10.0 * np.log10(np.abs(spectrum) ** 2 + 1e-10)
|
||||||
|
bins = power_db.tolist()
|
||||||
|
except ImportError:
|
||||||
|
# Fallback: compute magnitude without full FFT
|
||||||
|
# Just report raw magnitudes per sample as approximate power
|
||||||
|
for i in range(fft_size):
|
||||||
|
mag = math.sqrt(real_parts[i] ** 2 + imag_parts[i] ** 2)
|
||||||
|
power = 10.0 * math.log10(mag ** 2 + 1e-10)
|
||||||
|
bins.append(power)
|
||||||
|
|
||||||
|
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||||
|
if max_bins > 0 and len(bins) > max_bins:
|
||||||
|
bins = _downsample_bins(bins, max_bins)
|
||||||
|
|
||||||
|
msg = {
|
||||||
|
'type': 'waterfall_sweep',
|
||||||
|
'start_freq': start_freq,
|
||||||
|
'end_freq': end_freq,
|
||||||
|
'bins': bins,
|
||||||
'timestamp': datetime.now().isoformat(),
|
'timestamp': datetime.now().isoformat(),
|
||||||
})
|
}
|
||||||
except queue.Full:
|
try:
|
||||||
pass
|
waterfall_queue.put_nowait(msg)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
waterfall_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
waterfall_queue.put_nowait(msg)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Throttle to respect interval
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
if waterfall_running and not received_any:
|
||||||
|
_queue_waterfall_error(f'No IQ data received from {sdr_type.value}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Waterfall IQ loop error: {e}")
|
||||||
|
_queue_waterfall_error(f"Waterfall loop error: {e}")
|
||||||
|
finally:
|
||||||
|
waterfall_running = False
|
||||||
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
|
try:
|
||||||
|
waterfall_process.terminate()
|
||||||
|
waterfall_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
waterfall_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
waterfall_process = None
|
||||||
|
logger.info("Waterfall IQ loop stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def _waterfall_loop_rtl_power():
|
||||||
|
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||||
|
global waterfall_running, waterfall_process
|
||||||
|
|
||||||
rtl_power_path = find_rtl_power()
|
rtl_power_path = find_rtl_power()
|
||||||
if not rtl_power_path:
|
if not rtl_power_path:
|
||||||
@@ -2081,17 +2241,28 @@ def start_waterfall() -> Response:
|
|||||||
'config': waterfall_config,
|
'config': waterfall_config,
|
||||||
})
|
})
|
||||||
|
|
||||||
if not find_rtl_power():
|
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Determine SDR type
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
sdr_type_str = sdr_type.value
|
||||||
|
|
||||||
|
# RTL-SDR uses rtl_power; other types use rx_sdr via IQ capture
|
||||||
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
|
if not find_rtl_power():
|
||||||
|
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
||||||
|
|
||||||
try:
|
try:
|
||||||
waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
|
waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
|
||||||
waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
|
waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
|
||||||
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
||||||
waterfall_config['gain'] = int(data.get('gain', 40))
|
waterfall_config['gain'] = int(data.get('gain', 40))
|
||||||
waterfall_config['device'] = int(data.get('device', 0))
|
waterfall_config['device'] = int(data.get('device', 0))
|
||||||
|
waterfall_config['sdr_type'] = sdr_type_str
|
||||||
if data.get('interval') is not None:
|
if data.get('interval') is not None:
|
||||||
interval = float(data.get('interval', waterfall_config['interval']))
|
interval = float(data.get('interval', waterfall_config['interval']))
|
||||||
if interval < 0.1 or interval > 5:
|
if interval < 0.1 or interval > 5:
|
||||||
@@ -2116,12 +2287,12 @@ def start_waterfall() -> Response:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Claim SDR device
|
# Claim SDR device
|
||||||
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', 'rtlsdr')
|
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||||
|
|
||||||
waterfall_active_device = waterfall_config['device']
|
waterfall_active_device = waterfall_config['device']
|
||||||
waterfall_active_sdr_type = 'rtlsdr'
|
waterfall_active_sdr_type = sdr_type_str
|
||||||
waterfall_running = True
|
waterfall_running = True
|
||||||
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
|
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
|
||||||
waterfall_thread.start()
|
waterfall_thread.start()
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Tests for HackRF command builder."""
|
||||||
|
|
||||||
|
from utils.sdr.base import SDRDevice, SDRType
|
||||||
|
from utils.sdr.hackrf import HackRFCommandBuilder
|
||||||
|
|
||||||
|
|
||||||
|
def _make_device(serial: str = 'abc123') -> SDRDevice:
|
||||||
|
return SDRDevice(
|
||||||
|
sdr_type=SDRType.HACKRF,
|
||||||
|
index=0,
|
||||||
|
name='HackRF One',
|
||||||
|
serial=serial,
|
||||||
|
driver='hackrf',
|
||||||
|
capabilities=HackRFCommandBuilder.CAPABILITIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHackRFCapabilities:
|
||||||
|
def test_gain_max_reflects_combined_lna_vga(self):
|
||||||
|
"""gain_max should be LNA(40) + VGA(62) = 102."""
|
||||||
|
assert HackRFCommandBuilder.CAPABILITIES.gain_max == 102.0
|
||||||
|
|
||||||
|
def test_frequency_range(self):
|
||||||
|
caps = HackRFCommandBuilder.CAPABILITIES
|
||||||
|
assert caps.freq_min_mhz == 1.0
|
||||||
|
assert caps.freq_max_mhz == 6000.0
|
||||||
|
|
||||||
|
def test_tx_capable(self):
|
||||||
|
assert HackRFCommandBuilder.CAPABILITIES.tx_capable is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSplitGain:
|
||||||
|
def test_low_gain_all_to_lna(self):
|
||||||
|
builder = HackRFCommandBuilder()
|
||||||
|
lna, vga = builder._split_gain(30)
|
||||||
|
assert lna == 30
|
||||||
|
assert vga == 0
|
||||||
|
|
||||||
|
def test_gain_at_lna_max(self):
|
||||||
|
builder = HackRFCommandBuilder()
|
||||||
|
lna, vga = builder._split_gain(40)
|
||||||
|
assert lna == 40
|
||||||
|
assert vga == 0
|
||||||
|
|
||||||
|
def test_high_gain_splits_across_stages(self):
|
||||||
|
builder = HackRFCommandBuilder()
|
||||||
|
lna, vga = builder._split_gain(80)
|
||||||
|
assert lna == 40
|
||||||
|
assert vga == 40
|
||||||
|
|
||||||
|
def test_max_combined_gain(self):
|
||||||
|
builder = HackRFCommandBuilder()
|
||||||
|
lna, vga = builder._split_gain(102)
|
||||||
|
assert lna == 40
|
||||||
|
assert vga == 62
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildAdsbCommand:
|
||||||
|
def test_contains_soapysdr_device_type(self):
|
||||||
|
builder = HackRFCommandBuilder()
|
||||||
|
cmd = builder.build_adsb_command(_make_device(), gain=40)
|
||||||
|
assert '--device-type' in cmd
|
||||||
|
assert 'soapysdr' in cmd
|
||||||
|
|
||||||
|
def test_includes_serial_in_device_string(self):
|
||||||
|
builder = HackRFCommandBuilder()
|
||||||
|
cmd = builder.build_adsb_command(_make_device(serial='deadbeef'), gain=40)
|
||||||
|
device_idx = cmd.index('--device')
|
||||||
|
assert 'deadbeef' in cmd[device_idx + 1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildIQCaptureCommand:
|
||||||
|
def test_outputs_cu8_to_stdout(self):
|
||||||
|
builder = HackRFCommandBuilder()
|
||||||
|
cmd = builder.build_iq_capture_command(
|
||||||
|
_make_device(), frequency_mhz=100.0, sample_rate=2048000, gain=40
|
||||||
|
)
|
||||||
|
assert '-F' in cmd
|
||||||
|
assert 'CU8' in cmd
|
||||||
|
assert cmd[-1] == '-'
|
||||||
|
|
||||||
|
def test_gain_split_in_command(self):
|
||||||
|
builder = HackRFCommandBuilder()
|
||||||
|
cmd = builder.build_iq_capture_command(
|
||||||
|
_make_device(), frequency_mhz=100.0, gain=80
|
||||||
|
)
|
||||||
|
gain_idx = cmd.index('-g')
|
||||||
|
assert cmd[gain_idx + 1] == 'LNA=40,VGA=40'
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
"""Tests for RTL-SDR detection parsing."""
|
"""Tests for SDR detection parsing (RTL-SDR and HackRF)."""
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import utils.sdr.detection as detection_mod
|
||||||
from utils.sdr.base import SDRType
|
from utils.sdr.base import SDRType
|
||||||
from utils.sdr.detection import detect_rtlsdr_devices
|
from utils.sdr.detection import detect_hackrf_devices, detect_rtlsdr_devices
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_detection_caches():
|
||||||
|
"""Reset detection caches before each test."""
|
||||||
|
detection_mod._hackrf_cache = []
|
||||||
|
detection_mod._hackrf_cache_ts = 0.0
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@patch('utils.sdr.detection._check_tool', return_value=True)
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
@@ -44,3 +55,87 @@ def test_detect_rtlsdr_devices_uses_replace_decode_mode(mock_run, _mock_check_to
|
|||||||
assert kwargs["text"] is True
|
assert kwargs["text"] is True
|
||||||
assert kwargs["encoding"] == "utf-8"
|
assert kwargs["encoding"] == "utf-8"
|
||||||
assert kwargs["errors"] == "replace"
|
assert kwargs["errors"] == "replace"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- HackRF detection tests ----
|
||||||
|
|
||||||
|
HACKRF_INFO_OUTPUT = (
|
||||||
|
"hackrf_info version: 2024.02.1\n"
|
||||||
|
"libhackrf version: 2024.02.1 (0.9)\n"
|
||||||
|
"Found HackRF\n"
|
||||||
|
"Index: 0\n"
|
||||||
|
"Serial number: 0000000000000000a06063c8234e925f\n"
|
||||||
|
"Board ID Number: 2 (HackRF One)\n"
|
||||||
|
"Firmware Version: 2024.02.1 (API:1.08)\n"
|
||||||
|
"Part ID Number: 0xa000cb3c 0x00614764\n"
|
||||||
|
"Hardware Revision: r9\n"
|
||||||
|
"Hardware supported by installed firmware:\n"
|
||||||
|
" HackRF One\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
|
@patch('utils.sdr.detection.subprocess.run')
|
||||||
|
def test_detect_hackrf_from_stdout(mock_run, _mock_check_tool):
|
||||||
|
"""Parse HackRF device info from stdout."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = HACKRF_INFO_OUTPUT
|
||||||
|
mock_result.stderr = ""
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
devices = detect_hackrf_devices()
|
||||||
|
|
||||||
|
assert len(devices) == 1
|
||||||
|
assert devices[0].sdr_type == SDRType.HACKRF
|
||||||
|
assert devices[0].name == "HackRF One"
|
||||||
|
assert devices[0].serial == "0000000000000000a06063c8234e925f"
|
||||||
|
assert devices[0].index == 0
|
||||||
|
|
||||||
|
|
||||||
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
|
@patch('utils.sdr.detection.subprocess.run')
|
||||||
|
def test_detect_hackrf_from_stderr(mock_run, _mock_check_tool):
|
||||||
|
"""Parse HackRF device info when output goes to stderr (newer firmware)."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = ""
|
||||||
|
mock_result.stderr = HACKRF_INFO_OUTPUT
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
devices = detect_hackrf_devices()
|
||||||
|
|
||||||
|
assert len(devices) == 1
|
||||||
|
assert devices[0].sdr_type == SDRType.HACKRF
|
||||||
|
assert devices[0].name == "HackRF One"
|
||||||
|
assert devices[0].serial == "0000000000000000a06063c8234e925f"
|
||||||
|
|
||||||
|
|
||||||
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
|
@patch('utils.sdr.detection.subprocess.run')
|
||||||
|
def test_detect_hackrf_nonzero_exit_with_valid_output(mock_run, _mock_check_tool):
|
||||||
|
"""Parse HackRF info even when hackrf_info exits non-zero (device busy)."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 1
|
||||||
|
mock_result.stdout = ""
|
||||||
|
mock_result.stderr = HACKRF_INFO_OUTPUT
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
devices = detect_hackrf_devices()
|
||||||
|
|
||||||
|
assert len(devices) == 1
|
||||||
|
assert devices[0].name == "HackRF One"
|
||||||
|
|
||||||
|
|
||||||
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
|
@patch('utils.sdr.detection.subprocess.run')
|
||||||
|
def test_detect_hackrf_fallback_no_serial(mock_run, _mock_check_tool):
|
||||||
|
"""Fallback detection when serial is missing but 'Found HackRF' present."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = "Found HackRF\nBoard ID Number: 2 (HackRF One)\n"
|
||||||
|
mock_result.stderr = ""
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
devices = detect_hackrf_devices()
|
||||||
|
|
||||||
|
assert len(devices) == 1
|
||||||
|
assert devices[0].name == "HackRF One"
|
||||||
|
assert devices[0].serial == "Unknown"
|
||||||
|
|||||||
@@ -356,6 +356,11 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
|||||||
timeout=5
|
timeout=5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Combine stdout + stderr: newer firmware may print to stderr,
|
||||||
|
# and hackrf_info may exit non-zero when device is briefly busy
|
||||||
|
# but still output valid info.
|
||||||
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
# Parse hackrf_info output
|
# Parse hackrf_info output
|
||||||
# Extract board name from "Board ID Number: X (Name)" and serial
|
# Extract board name from "Board ID Number: X (Name)" and serial
|
||||||
from .hackrf import HackRFCommandBuilder
|
from .hackrf import HackRFCommandBuilder
|
||||||
@@ -363,8 +368,8 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
|||||||
serial_pattern = r'Serial number:\s*(\S+)'
|
serial_pattern = r'Serial number:\s*(\S+)'
|
||||||
board_pattern = r'Board ID Number:\s*\d+\s*\(([^)]+)\)'
|
board_pattern = r'Board ID Number:\s*\d+\s*\(([^)]+)\)'
|
||||||
|
|
||||||
serials_found = re.findall(serial_pattern, result.stdout)
|
serials_found = re.findall(serial_pattern, output)
|
||||||
boards_found = re.findall(board_pattern, result.stdout)
|
boards_found = re.findall(board_pattern, output)
|
||||||
|
|
||||||
for i, serial in enumerate(serials_found):
|
for i, serial in enumerate(serials_found):
|
||||||
board_name = boards_found[i] if i < len(boards_found) else 'HackRF'
|
board_name = boards_found[i] if i < len(boards_found) else 'HackRF'
|
||||||
@@ -378,8 +383,8 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Fallback: check if any HackRF found without serial
|
# Fallback: check if any HackRF found without serial
|
||||||
if not devices and 'Found HackRF' in result.stdout:
|
if not devices and 'Found HackRF' in output:
|
||||||
board_match = re.search(board_pattern, result.stdout)
|
board_match = re.search(board_pattern, output)
|
||||||
board_name = board_match.group(1) if board_match else 'HackRF'
|
board_name = board_match.group(1) if board_match else 'HackRF'
|
||||||
devices.append(SDRDevice(
|
devices.append(SDRDevice(
|
||||||
sdr_type=SDRType.HACKRF,
|
sdr_type=SDRType.HACKRF,
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ class HackRFCommandBuilder(CommandBuilder):
|
|||||||
freq_min_mhz=1.0, # 1 MHz
|
freq_min_mhz=1.0, # 1 MHz
|
||||||
freq_max_mhz=6000.0, # 6 GHz
|
freq_max_mhz=6000.0, # 6 GHz
|
||||||
gain_min=0.0,
|
gain_min=0.0,
|
||||||
gain_max=62.0, # LNA (0-40) + VGA (0-62)
|
gain_max=102.0, # LNA (0-40) + VGA (0-62)
|
||||||
sample_rates=[2000000, 4000000, 8000000, 10000000, 20000000],
|
sample_rates=[2000000, 4000000, 8000000, 10000000, 20000000],
|
||||||
supports_bias_t=True,
|
supports_bias_t=True,
|
||||||
supports_ppm=False,
|
supports_ppm=False,
|
||||||
|
|||||||
Reference in New Issue
Block a user