Restore mcbladerf from 2026-02-08 working copy
Recovered from editor file-history snapshots; the original directory was never committed and has since been removed.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
dist/
|
||||
*.cf32
|
||||
*.cs16
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcbladerf": {
|
||||
"type": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "mcbladerf"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.PHONY: setup run test clean
|
||||
|
||||
# SoapySDR requires system Python with its bindings installed
|
||||
# This creates a venv with access to system site-packages
|
||||
setup:
|
||||
@echo "Creating venv with system site-packages (required for SoapySDR)..."
|
||||
uv venv --system-site-packages --python 3.14
|
||||
@echo "Installing dependencies..."
|
||||
uv pip install -e .
|
||||
@echo ""
|
||||
@echo "Setup complete! Run with: uv run mcbladerf"
|
||||
|
||||
run:
|
||||
uv run mcbladerf
|
||||
|
||||
test:
|
||||
uv run python -c "import SoapySDR; print('SoapySDR OK'); from mcbladerf import device; print(f'Devices: {len(device.list_devices())}')"
|
||||
|
||||
clean:
|
||||
rm -rf .venv __pycache__ src/mcbladerf/__pycache__ .ruff_cache
|
||||
@@ -0,0 +1,122 @@
|
||||
# mcbladerf
|
||||
|
||||
MCP server for Nuand BladeRF SDR control via SoapySDR.
|
||||
|
||||
## Overview
|
||||
|
||||
mcbladerf provides Model Context Protocol (MCP) tools for controlling Nuand BladeRF software-defined radios. It enables AI assistants to configure RF parameters, capture IQ samples, and perform spectrum analysis through a simple tool-based interface.
|
||||
|
||||
## Features
|
||||
|
||||
- **Device Management**: Enumerate, connect, and disconnect BladeRF devices
|
||||
- **RF Configuration**: Set/get frequency, sample rate, bandwidth, and gain stages
|
||||
- **IQ Capture**: Stream samples to files in CF32 or CS16 format
|
||||
- **Spectrum Analysis**: Capture and compute FFT power spectra
|
||||
- **Advanced Operations**: FPGA loading, firmware flashing, DC calibration
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.14+ (must match system SoapySDR installation)
|
||||
- SoapySDR with BladeRF support (system packages)
|
||||
- bladeRF CLI tools (for advanced operations)
|
||||
- BladeRF 1.0 or 2.0 hardware
|
||||
|
||||
### System Dependencies (Arch Linux)
|
||||
|
||||
```bash
|
||||
sudo pacman -S soapysdr soapysdr-bladerf bladerf
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
SoapySDR is a C++ library with Python bindings that must be installed system-wide. The venv needs access to system site-packages:
|
||||
|
||||
```bash
|
||||
cd mcbladerf
|
||||
|
||||
# One-time setup (creates venv with system site-packages access)
|
||||
make setup
|
||||
|
||||
# Or manually:
|
||||
uv venv --system-site-packages --python 3.14
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# After setup, run with:
|
||||
uv run mcbladerf
|
||||
|
||||
# Or via make:
|
||||
make run
|
||||
```
|
||||
|
||||
## Usage with Claude Code
|
||||
|
||||
```bash
|
||||
# Add to Claude Code (with full path)
|
||||
claude mcp add mcbladerf -- uv run --directory /path/to/mcbladerf mcbladerf
|
||||
```
|
||||
|
||||
The `.mcp.json` is already configured for local use:
|
||||
```bash
|
||||
claude -p "show bladerf status" --mcp-config .mcp.json --allowedTools "mcp__mcbladerf__*"
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Device Management
|
||||
- `bladerf_status` - Get comprehensive device status
|
||||
- `bladerf_list_devices` - Enumerate connected devices
|
||||
- `bladerf_connect` - Connect to a device by serial or index
|
||||
- `bladerf_disconnect` - Close the current connection
|
||||
|
||||
### RF Configuration
|
||||
- `bladerf_get_frequency` / `bladerf_set_frequency` - Center frequency
|
||||
- `bladerf_get_sample_rate` / `bladerf_set_sample_rate` - Sample rate
|
||||
- `bladerf_get_bandwidth` / `bladerf_set_bandwidth` - Analog bandwidth
|
||||
- `bladerf_get_gain` / `bladerf_set_gain` - Individual gain stages
|
||||
- `bladerf_list_gains` - Enumerate available gain stages
|
||||
- `bladerf_get_frequency_range` / `bladerf_get_sample_rate_range` - Hardware limits
|
||||
|
||||
### IQ Capture
|
||||
- `bladerf_capture_iq` - Stream samples to file (CF32/CS16)
|
||||
- `bladerf_capture_spectrum` - Capture and compute FFT
|
||||
- `bladerf_stream_status` - Check capture progress
|
||||
- `bladerf_stop_capture` - Abort running capture
|
||||
|
||||
### Advanced
|
||||
- `bladerf_load_fpga` - Load FPGA bitstream (RAM)
|
||||
- `bladerf_flash_firmware` - Flash FX3 firmware
|
||||
- `bladerf_calibrate_dc` - Run DC offset calibration
|
||||
- `bladerf_get_hardware_info` - Detailed hardware info
|
||||
- `bladerf_set_loopback` - Configure loopback mode
|
||||
- `bladerf_probe` - Device diagnostics
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
> Connect to my BladeRF and show status
|
||||
[Uses bladerf_connect and bladerf_status]
|
||||
|
||||
> Set frequency to 915 MHz with 2 MHz sample rate
|
||||
[Uses bladerf_set_frequency and bladerf_set_sample_rate]
|
||||
|
||||
> Capture 1 second of IQ data to /tmp/capture.cf32
|
||||
[Uses bladerf_capture_iq]
|
||||
|
||||
> Show me the spectrum around 100 MHz
|
||||
[Uses bladerf_capture_spectrum]
|
||||
```
|
||||
|
||||
## BladeRF 1.0 x40 Specifications
|
||||
|
||||
- Frequency: 237.5 MHz - 3.8 GHz
|
||||
- Sample rates: 80 Ksps - 40 Msps
|
||||
- RX Gain stages: lna (0/3/6 dB), rxvga1 (5-30 dB), rxvga2 (0-30 dB)
|
||||
- TX Gain stages: txvga1 (-35 to -4 dB), txvga2 (0-25 dB)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# mcbladerf launcher - ensures SoapySDR system package is accessible
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Get system Python's site-packages path for SoapySDR
|
||||
SYSTEM_SITE_PACKAGES=$(python3 -c "import site; print(':'.join(site.getsitepackages()))")
|
||||
|
||||
# Add system site-packages to PYTHONPATH so SoapySDR is found
|
||||
export PYTHONPATH="${SYSTEM_SITE_PACKAGES}:${PYTHONPATH}"
|
||||
|
||||
# Run the MCP server
|
||||
exec uv run --directory "$SCRIPT_DIR" mcbladerf "$@"
|
||||
@@ -0,0 +1,55 @@
|
||||
[project]
|
||||
name = "mcbladerf"
|
||||
version = "2026.02.08"
|
||||
description = "MCP server for Nuand BladeRF SDR control via SoapySDR"
|
||||
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["mcp", "sdr", "bladerf", "soapysdr", "radio"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Communications :: Ham Radio",
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"fastmcp>=2.0.0",
|
||||
"numpy>=1.24",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcbladerf = "mcbladerf.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mcbladerf"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"]
|
||||
ignore = ["E501"] # Line length handled by formatter
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.uv]
|
||||
# Prefer system Python to match SoapySDR installation
|
||||
python-preference = "system"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""MCP server for Nuand BladeRF SDR control via SoapySDR."""
|
||||
|
||||
__version__ = "2026.02.08"
|
||||
@@ -0,0 +1,476 @@
|
||||
"""IQ capture and spectrum analysis for BladeRF.
|
||||
|
||||
This module provides functions for capturing IQ samples to files
|
||||
and computing power spectra from captured data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import device
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
SampleFormat = Literal["cf32", "cs16"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptureResult:
|
||||
"""Result from an IQ capture operation."""
|
||||
|
||||
file_path: str
|
||||
sample_count: int
|
||||
duration_seconds: float
|
||||
sample_rate_hz: float
|
||||
frequency_hz: float
|
||||
format: SampleFormat
|
||||
file_size_bytes: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpectrumResult:
|
||||
"""Result from a spectrum capture operation."""
|
||||
|
||||
center_frequency_hz: float
|
||||
bandwidth_hz: float
|
||||
bin_count: int
|
||||
frequencies_hz: list[float]
|
||||
power_db: list[float]
|
||||
peak_frequency_hz: float
|
||||
peak_power_db: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptureProgress:
|
||||
"""Progress information for ongoing capture."""
|
||||
|
||||
samples_captured: int
|
||||
samples_total: int
|
||||
elapsed_seconds: float
|
||||
estimated_remaining_seconds: float
|
||||
bytes_written: int
|
||||
|
||||
|
||||
def _get_capture_state() -> dict[str, Any]:
|
||||
"""Get or create capture state in device state."""
|
||||
state = device.get_state()
|
||||
if not hasattr(state, "capture_info"):
|
||||
state.capture_info = {
|
||||
"active": False,
|
||||
"progress": None,
|
||||
"cancelled": False,
|
||||
}
|
||||
return state.capture_info
|
||||
|
||||
|
||||
def is_capture_active() -> bool:
|
||||
"""Check if a capture is currently in progress."""
|
||||
state = _get_capture_state()
|
||||
return state.get("active", False)
|
||||
|
||||
|
||||
def get_capture_progress() -> CaptureProgress | None:
|
||||
"""Get progress of the current capture.
|
||||
|
||||
Returns:
|
||||
CaptureProgress if capture is active, None otherwise.
|
||||
"""
|
||||
state = _get_capture_state()
|
||||
return state.get("progress")
|
||||
|
||||
|
||||
def cancel_capture() -> bool:
|
||||
"""Request cancellation of the current capture.
|
||||
|
||||
Returns:
|
||||
True if a capture was active and cancellation was requested.
|
||||
"""
|
||||
state = _get_capture_state()
|
||||
if state.get("active"):
|
||||
state["cancelled"] = True
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def capture_iq_sync(
|
||||
frequency_hz: float,
|
||||
sample_rate_hz: float,
|
||||
duration_seconds: float,
|
||||
output_path: str | Path,
|
||||
format: SampleFormat = "cf32",
|
||||
gains: dict[str, float] | None = None,
|
||||
bandwidth_hz: float | None = None,
|
||||
progress_callback: Callable[[CaptureProgress], None] | None = None,
|
||||
) -> CaptureResult:
|
||||
"""Capture IQ samples to a file (synchronous version).
|
||||
|
||||
Args:
|
||||
frequency_hz: Center frequency in Hz
|
||||
sample_rate_hz: Sample rate in Hz
|
||||
duration_seconds: Capture duration in seconds
|
||||
output_path: Output file path
|
||||
format: Sample format ("cf32" or "cs16")
|
||||
gains: Optional dict of gain settings {"lna": 6, "rxvga1": 25}
|
||||
bandwidth_hz: Optional bandwidth (defaults to sample_rate * 0.8)
|
||||
progress_callback: Optional callback for progress updates
|
||||
|
||||
Returns:
|
||||
CaptureResult with file info and statistics.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If capture fails.
|
||||
"""
|
||||
device.ensure_connected()
|
||||
state = _get_capture_state()
|
||||
|
||||
if state["active"]:
|
||||
raise RuntimeError("A capture is already in progress")
|
||||
|
||||
state["active"] = True
|
||||
state["cancelled"] = False
|
||||
|
||||
try:
|
||||
# Configure RF parameters
|
||||
device.set_frequency("rx", frequency_hz)
|
||||
device.set_sample_rate("rx", sample_rate_hz)
|
||||
|
||||
if bandwidth_hz is None:
|
||||
bandwidth_hz = sample_rate_hz * 0.8
|
||||
device.set_bandwidth("rx", bandwidth_hz)
|
||||
|
||||
# Set gains if provided
|
||||
if gains:
|
||||
for stage, gain_db in gains.items():
|
||||
device.set_gain("rx", stage, gain_db)
|
||||
|
||||
# Get actual configured values
|
||||
actual_freq = device.get_frequency("rx")
|
||||
actual_rate = device.get_sample_rate("rx")
|
||||
|
||||
# Calculate sample count
|
||||
total_samples = int(duration_seconds * actual_rate)
|
||||
|
||||
# Set up stream
|
||||
soapy_format = "CF32" if format == "cf32" else "CS16"
|
||||
device.setup_rx_stream(soapy_format)
|
||||
device.activate_rx_stream()
|
||||
|
||||
output_path = Path(output_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Buffer size: 64K samples per read (good balance of latency/efficiency)
|
||||
buffer_size = 65536
|
||||
samples_captured = 0
|
||||
bytes_written = 0
|
||||
start_time = time.time()
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
while samples_captured < total_samples:
|
||||
if state["cancelled"]:
|
||||
break
|
||||
|
||||
# Read chunk
|
||||
samples_to_read = min(buffer_size, total_samples - samples_captured)
|
||||
samples, flags = device.read_stream(samples_to_read)
|
||||
|
||||
if len(samples) == 0:
|
||||
continue
|
||||
|
||||
# Write to file
|
||||
if format == "cf32":
|
||||
# Complex float32: 8 bytes per sample (real + imag as float32)
|
||||
f.write(samples.astype(np.complex64).tobytes())
|
||||
bytes_written += len(samples) * 8
|
||||
else:
|
||||
# Complex int16: 4 bytes per sample (real + imag as int16)
|
||||
# Scale and convert
|
||||
scaled = samples * 2047 # Scale to int16 range
|
||||
int_samples = np.zeros(len(samples) * 2, dtype=np.int16)
|
||||
int_samples[0::2] = scaled.real.astype(np.int16)
|
||||
int_samples[1::2] = scaled.imag.astype(np.int16)
|
||||
f.write(int_samples.tobytes())
|
||||
bytes_written += len(samples) * 4
|
||||
|
||||
samples_captured += len(samples)
|
||||
|
||||
# Update progress
|
||||
elapsed = time.time() - start_time
|
||||
remaining = (elapsed / samples_captured) * (total_samples - samples_captured) if samples_captured > 0 else 0
|
||||
|
||||
progress = CaptureProgress(
|
||||
samples_captured=samples_captured,
|
||||
samples_total=total_samples,
|
||||
elapsed_seconds=elapsed,
|
||||
estimated_remaining_seconds=remaining,
|
||||
bytes_written=bytes_written,
|
||||
)
|
||||
state["progress"] = progress
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(progress)
|
||||
|
||||
# Clean up stream
|
||||
device.deactivate_rx_stream()
|
||||
device.close_rx_stream()
|
||||
|
||||
actual_duration = time.time() - start_time
|
||||
|
||||
return CaptureResult(
|
||||
file_path=str(output_path),
|
||||
sample_count=samples_captured,
|
||||
duration_seconds=actual_duration,
|
||||
sample_rate_hz=actual_rate,
|
||||
frequency_hz=actual_freq,
|
||||
format=format,
|
||||
file_size_bytes=bytes_written,
|
||||
)
|
||||
|
||||
finally:
|
||||
state["active"] = False
|
||||
state["progress"] = None
|
||||
state["cancelled"] = False
|
||||
|
||||
|
||||
async def capture_iq(
|
||||
frequency_hz: float,
|
||||
sample_rate_hz: float,
|
||||
duration_seconds: float,
|
||||
output_path: str | Path,
|
||||
format: SampleFormat = "cf32",
|
||||
gains: dict[str, float] | None = None,
|
||||
bandwidth_hz: float | None = None,
|
||||
) -> CaptureResult:
|
||||
"""Capture IQ samples to a file (async version).
|
||||
|
||||
Runs the capture in a thread pool to avoid blocking the event loop.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: capture_iq_sync(
|
||||
frequency_hz=frequency_hz,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
duration_seconds=duration_seconds,
|
||||
output_path=output_path,
|
||||
format=format,
|
||||
gains=gains,
|
||||
bandwidth_hz=bandwidth_hz,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def compute_spectrum(
|
||||
samples: np.ndarray,
|
||||
sample_rate_hz: float,
|
||||
center_frequency_hz: float,
|
||||
fft_size: int = 1024,
|
||||
window: str = "hann",
|
||||
averaging: int = 1,
|
||||
) -> SpectrumResult:
|
||||
"""Compute power spectrum from IQ samples.
|
||||
|
||||
Args:
|
||||
samples: Complex IQ samples (numpy array)
|
||||
sample_rate_hz: Sample rate of the data
|
||||
center_frequency_hz: Center frequency
|
||||
fft_size: FFT size (power of 2 recommended)
|
||||
window: Window function ("hann", "blackman", "hamming", "none")
|
||||
averaging: Number of FFTs to average
|
||||
|
||||
Returns:
|
||||
SpectrumResult with frequency bins and power levels.
|
||||
"""
|
||||
# Apply window
|
||||
if window == "hann":
|
||||
win = np.hanning(fft_size)
|
||||
elif window == "blackman":
|
||||
win = np.blackman(fft_size)
|
||||
elif window == "hamming":
|
||||
win = np.hamming(fft_size)
|
||||
else:
|
||||
win = np.ones(fft_size)
|
||||
|
||||
# Compute averaged spectrum
|
||||
num_ffts = min(averaging, len(samples) // fft_size)
|
||||
if num_ffts == 0:
|
||||
num_ffts = 1
|
||||
fft_size = len(samples)
|
||||
win = np.hanning(fft_size) if window != "none" else np.ones(fft_size)
|
||||
|
||||
power_sum = np.zeros(fft_size)
|
||||
|
||||
for i in range(num_ffts):
|
||||
start = i * fft_size
|
||||
chunk = samples[start : start + fft_size]
|
||||
if len(chunk) < fft_size:
|
||||
chunk = np.pad(chunk, (0, fft_size - len(chunk)))
|
||||
|
||||
# Apply window and FFT
|
||||
windowed = chunk * win
|
||||
spectrum = np.fft.fftshift(np.fft.fft(windowed))
|
||||
power = np.abs(spectrum) ** 2
|
||||
power_sum += power
|
||||
|
||||
# Average and convert to dB
|
||||
power_avg = power_sum / num_ffts
|
||||
power_db = 10 * np.log10(power_avg + 1e-12) # Add small value to avoid log(0)
|
||||
|
||||
# Frequency axis
|
||||
freq_bins = np.fft.fftshift(np.fft.fftfreq(fft_size, 1.0 / sample_rate_hz))
|
||||
frequencies = center_frequency_hz + freq_bins
|
||||
|
||||
# Find peak
|
||||
peak_idx = np.argmax(power_db)
|
||||
peak_freq = frequencies[peak_idx]
|
||||
peak_power = power_db[peak_idx]
|
||||
|
||||
return SpectrumResult(
|
||||
center_frequency_hz=center_frequency_hz,
|
||||
bandwidth_hz=sample_rate_hz,
|
||||
bin_count=fft_size,
|
||||
frequencies_hz=frequencies.tolist(),
|
||||
power_db=power_db.tolist(),
|
||||
peak_frequency_hz=float(peak_freq),
|
||||
peak_power_db=float(peak_power),
|
||||
)
|
||||
|
||||
|
||||
def capture_spectrum_sync(
|
||||
frequency_hz: float,
|
||||
sample_rate_hz: float,
|
||||
fft_size: int = 1024,
|
||||
averaging: int = 10,
|
||||
gains: dict[str, float] | None = None,
|
||||
bandwidth_hz: float | None = None,
|
||||
window: str = "hann",
|
||||
) -> SpectrumResult:
|
||||
"""Capture samples and compute spectrum (synchronous).
|
||||
|
||||
Args:
|
||||
frequency_hz: Center frequency in Hz
|
||||
sample_rate_hz: Sample rate in Hz
|
||||
fft_size: FFT size (power of 2 recommended)
|
||||
averaging: Number of FFTs to average
|
||||
gains: Optional gain settings
|
||||
bandwidth_hz: Optional bandwidth
|
||||
window: Window function
|
||||
|
||||
Returns:
|
||||
SpectrumResult with frequency bins and power levels.
|
||||
"""
|
||||
device.ensure_connected()
|
||||
|
||||
# Configure RF
|
||||
device.set_frequency("rx", frequency_hz)
|
||||
device.set_sample_rate("rx", sample_rate_hz)
|
||||
|
||||
if bandwidth_hz is None:
|
||||
bandwidth_hz = sample_rate_hz * 0.8
|
||||
device.set_bandwidth("rx", bandwidth_hz)
|
||||
|
||||
if gains:
|
||||
for stage, gain_db in gains.items():
|
||||
device.set_gain("rx", stage, gain_db)
|
||||
|
||||
# Get actual values
|
||||
actual_freq = device.get_frequency("rx")
|
||||
actual_rate = device.get_sample_rate("rx")
|
||||
|
||||
# Capture enough samples for averaging
|
||||
total_samples = fft_size * averaging
|
||||
|
||||
device.setup_rx_stream("CF32")
|
||||
device.activate_rx_stream()
|
||||
|
||||
try:
|
||||
# Collect samples
|
||||
all_samples = []
|
||||
remaining = total_samples
|
||||
|
||||
while remaining > 0:
|
||||
samples, flags = device.read_stream(min(remaining, 65536))
|
||||
all_samples.append(samples)
|
||||
remaining -= len(samples)
|
||||
|
||||
samples = np.concatenate(all_samples)[:total_samples]
|
||||
|
||||
finally:
|
||||
device.deactivate_rx_stream()
|
||||
device.close_rx_stream()
|
||||
|
||||
return compute_spectrum(
|
||||
samples=samples,
|
||||
sample_rate_hz=actual_rate,
|
||||
center_frequency_hz=actual_freq,
|
||||
fft_size=fft_size,
|
||||
window=window,
|
||||
averaging=averaging,
|
||||
)
|
||||
|
||||
|
||||
async def capture_spectrum(
|
||||
frequency_hz: float,
|
||||
sample_rate_hz: float,
|
||||
fft_size: int = 1024,
|
||||
averaging: int = 10,
|
||||
gains: dict[str, float] | None = None,
|
||||
bandwidth_hz: float | None = None,
|
||||
window: str = "hann",
|
||||
) -> SpectrumResult:
|
||||
"""Capture samples and compute spectrum (async version)."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: capture_spectrum_sync(
|
||||
frequency_hz=frequency_hz,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
fft_size=fft_size,
|
||||
averaging=averaging,
|
||||
gains=gains,
|
||||
bandwidth_hz=bandwidth_hz,
|
||||
window=window,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def read_iq_file(
|
||||
file_path: str | Path,
|
||||
format: SampleFormat = "cf32",
|
||||
max_samples: int | None = None,
|
||||
) -> np.ndarray:
|
||||
"""Read IQ samples from a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the IQ file
|
||||
format: Sample format ("cf32" or "cs16")
|
||||
max_samples: Maximum number of samples to read (None for all)
|
||||
|
||||
Returns:
|
||||
Complex numpy array of samples.
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
if format == "cf32":
|
||||
# Complex float32: 8 bytes per sample
|
||||
samples = np.frombuffer(data, dtype=np.complex64)
|
||||
else:
|
||||
# Complex int16: 4 bytes per sample
|
||||
int_data = np.frombuffer(data, dtype=np.int16)
|
||||
samples = (int_data[0::2] + 1j * int_data[1::2]).astype(np.complex64) / 2047.0
|
||||
|
||||
if max_samples is not None:
|
||||
samples = samples[:max_samples]
|
||||
|
||||
return samples
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Fallback operations using bladeRF-cli subprocess.
|
||||
|
||||
Some operations (firmware flash, FPGA loading, calibration) are not fully
|
||||
exposed through SoapySDR. This module provides subprocess wrappers for
|
||||
bladeRF-cli commands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _find_bladerfcli() -> str:
|
||||
"""Find the bladeRF-cli executable.
|
||||
|
||||
Returns:
|
||||
Path to bladeRF-cli.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If bladeRF-cli is not found.
|
||||
"""
|
||||
path = shutil.which("bladeRF-cli")
|
||||
if path is None:
|
||||
raise RuntimeError(
|
||||
"bladeRF-cli not found in PATH. "
|
||||
"Install bladeRF tools: https://github.com/Nuand/bladeRF"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _run_cli(commands: list[str], serial: str | None = None, timeout: int = 30) -> str:
|
||||
"""Run bladeRF-cli with the given commands.
|
||||
|
||||
Args:
|
||||
commands: List of CLI commands to execute
|
||||
serial: Device serial (uses first device if None)
|
||||
timeout: Timeout in seconds
|
||||
|
||||
Returns:
|
||||
Combined stdout output.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If command fails.
|
||||
"""
|
||||
cli_path = _find_bladerfcli()
|
||||
|
||||
# Build command line
|
||||
cmd = [cli_path]
|
||||
if serial:
|
||||
cmd.extend(["-d", f"*:serial={serial}"])
|
||||
|
||||
# Add each command with -e flag
|
||||
for command in commands:
|
||||
cmd.extend(["-e", command])
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"bladeRF-cli failed: {result.stderr or result.stdout}")
|
||||
|
||||
return result.stdout
|
||||
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError(f"bladeRF-cli timed out after {timeout}s") from e
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError(f"bladeRF-cli not found: {e}") from e
|
||||
|
||||
|
||||
@dataclass
|
||||
class CLIDeviceInfo:
|
||||
"""Device info from bladeRF-cli."""
|
||||
|
||||
serial: str
|
||||
fpga_size: str
|
||||
fpga_loaded: bool
|
||||
fpga_version: str | None
|
||||
fw_version: str
|
||||
usb_speed: str
|
||||
backend: str
|
||||
|
||||
|
||||
def get_device_info(serial: str | None = None) -> CLIDeviceInfo:
|
||||
"""Get detailed device info via bladeRF-cli.
|
||||
|
||||
This provides information not always available through SoapySDR.
|
||||
|
||||
Args:
|
||||
serial: Device serial (uses first device if None)
|
||||
|
||||
Returns:
|
||||
CLIDeviceInfo with detailed device information.
|
||||
"""
|
||||
output = _run_cli(["info", "version"], serial=serial)
|
||||
|
||||
# Parse output
|
||||
info = {
|
||||
"serial": "",
|
||||
"fpga_size": "",
|
||||
"fpga_loaded": False,
|
||||
"fpga_version": None,
|
||||
"fw_version": "",
|
||||
"usb_speed": "",
|
||||
"backend": "",
|
||||
}
|
||||
|
||||
# Parse serial
|
||||
match = re.search(r"Serial\s*#:\s*(\S+)", output)
|
||||
if match:
|
||||
info["serial"] = match.group(1)
|
||||
|
||||
# Parse FPGA size
|
||||
match = re.search(r"FPGA size:\s*(\S+)", output)
|
||||
if match:
|
||||
info["fpga_size"] = match.group(1)
|
||||
|
||||
# Parse FPGA loaded
|
||||
match = re.search(r"FPGA loaded:\s*(\S+)", output)
|
||||
if match:
|
||||
info["fpga_loaded"] = match.group(1).lower() == "yes"
|
||||
|
||||
# Parse FPGA version
|
||||
match = re.search(r"FPGA version:\s*(\S+)", output)
|
||||
if match:
|
||||
info["fpga_version"] = match.group(1)
|
||||
|
||||
# Parse firmware version
|
||||
match = re.search(r"FW version:\s*(\S+)", output)
|
||||
if match:
|
||||
info["fw_version"] = match.group(1)
|
||||
|
||||
# Parse USB speed
|
||||
match = re.search(r"USB speed:\s*(\S+)", output)
|
||||
if match:
|
||||
info["usb_speed"] = match.group(1)
|
||||
|
||||
# Parse backend
|
||||
match = re.search(r"Backend:\s*(\S+)", output)
|
||||
if match:
|
||||
info["backend"] = match.group(1)
|
||||
|
||||
return CLIDeviceInfo(**info)
|
||||
|
||||
|
||||
def load_fpga(rbf_path: str | Path, serial: str | None = None) -> dict[str, str]:
|
||||
"""Load FPGA bitstream from file (RAM, not persistent).
|
||||
|
||||
Args:
|
||||
rbf_path: Path to .rbf FPGA bitstream file
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with status and loaded file info.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If loading fails.
|
||||
"""
|
||||
rbf_path = Path(rbf_path)
|
||||
if not rbf_path.exists():
|
||||
raise RuntimeError(f"FPGA file not found: {rbf_path}")
|
||||
|
||||
if not rbf_path.suffix.lower() == ".rbf":
|
||||
raise RuntimeError(f"Expected .rbf file, got: {rbf_path.suffix}")
|
||||
|
||||
output = _run_cli([f"load fpga {rbf_path}"], serial=serial, timeout=60)
|
||||
|
||||
# Check for success
|
||||
if "Successfully loaded" in output or "loaded" in output.lower():
|
||||
return {
|
||||
"status": "success",
|
||||
"file": str(rbf_path),
|
||||
"message": "FPGA bitstream loaded to RAM (not persistent across power cycles)",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "unknown",
|
||||
"file": str(rbf_path),
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def flash_fpga(rbf_path: str | Path, serial: str | None = None) -> dict[str, str]:
|
||||
"""Flash FPGA bitstream to device (persistent across power cycles).
|
||||
|
||||
WARNING: This writes to flash memory. Use with caution.
|
||||
|
||||
Args:
|
||||
rbf_path: Path to .rbf FPGA bitstream file
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with status.
|
||||
"""
|
||||
rbf_path = Path(rbf_path)
|
||||
if not rbf_path.exists():
|
||||
raise RuntimeError(f"FPGA file not found: {rbf_path}")
|
||||
|
||||
output = _run_cli([f"flash fpga {rbf_path}"], serial=serial, timeout=120)
|
||||
|
||||
if "Successfully" in output or "written" in output.lower():
|
||||
return {
|
||||
"status": "success",
|
||||
"file": str(rbf_path),
|
||||
"message": "FPGA bitstream flashed (persistent)",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "unknown",
|
||||
"file": str(rbf_path),
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def flash_firmware(img_path: str | Path, serial: str | None = None) -> dict[str, str]:
|
||||
"""Flash FX3 firmware to device.
|
||||
|
||||
WARNING: This writes firmware to the device. Use with caution.
|
||||
|
||||
Args:
|
||||
img_path: Path to firmware image file
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with status.
|
||||
"""
|
||||
img_path = Path(img_path)
|
||||
if not img_path.exists():
|
||||
raise RuntimeError(f"Firmware file not found: {img_path}")
|
||||
|
||||
output = _run_cli([f"flash firmware {img_path}"], serial=serial, timeout=120)
|
||||
|
||||
if "Successfully" in output or "written" in output.lower():
|
||||
return {
|
||||
"status": "success",
|
||||
"file": str(img_path),
|
||||
"message": "Firmware flashed. Device may need to be power-cycled.",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "unknown",
|
||||
"file": str(img_path),
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def calibrate_dc(serial: str | None = None) -> dict[str, str]:
|
||||
"""Run DC offset calibration.
|
||||
|
||||
Args:
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with calibration status.
|
||||
"""
|
||||
output = _run_cli(["cal dc"], serial=serial, timeout=60)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def calibrate_gain(serial: str | None = None) -> dict[str, str]:
|
||||
"""Run gain calibration.
|
||||
|
||||
Args:
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with calibration status.
|
||||
"""
|
||||
output = _run_cli(["cal gain"], serial=serial, timeout=60)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def get_calibration_table(serial: str | None = None) -> dict[str, str]:
|
||||
"""Get current calibration table status.
|
||||
|
||||
Args:
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with calibration table info.
|
||||
"""
|
||||
output = _run_cli(["print calibration"], serial=serial)
|
||||
|
||||
return {
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def set_loopback(mode: str, serial: str | None = None) -> dict[str, str]:
|
||||
"""Set loopback mode for testing.
|
||||
|
||||
Args:
|
||||
mode: Loopback mode (none, firmware, bb_txlpf_rxvga2, bb_txvga1_rxvga2,
|
||||
bb_txlpf_rxlpf, bb_txvga1_rxlpf, rf_lna1, rf_lna2, rf_lna3)
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with status.
|
||||
"""
|
||||
valid_modes = [
|
||||
"none", "firmware", "bb_txlpf_rxvga2", "bb_txvga1_rxvga2",
|
||||
"bb_txlpf_rxlpf", "bb_txvga1_rxlpf", "rf_lna1", "rf_lna2", "rf_lna3"
|
||||
]
|
||||
|
||||
if mode.lower() not in valid_modes:
|
||||
raise RuntimeError(f"Invalid loopback mode. Valid modes: {valid_modes}")
|
||||
|
||||
output = _run_cli([f"set loopback {mode}"], serial=serial)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"mode": mode,
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def probe_device(serial: str | None = None) -> dict[str, str]:
|
||||
"""Probe device for detailed diagnostics.
|
||||
|
||||
Args:
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with probe results.
|
||||
"""
|
||||
output = _run_cli(["probe"], serial=serial)
|
||||
|
||||
return {
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def list_devices_cli() -> list[dict[str, str]]:
|
||||
"""List devices using bladeRF-cli.
|
||||
|
||||
Returns:
|
||||
List of device info dicts.
|
||||
"""
|
||||
try:
|
||||
output = _run_cli(["probe"], timeout=10)
|
||||
except RuntimeError:
|
||||
return []
|
||||
|
||||
# Parse probe output for device list
|
||||
devices = []
|
||||
# Simple parsing - each device block starts with "Probing for"
|
||||
# This is a basic parser; the actual output format may vary
|
||||
for line in output.split("\n"):
|
||||
if "Serial" in line:
|
||||
match = re.search(r"Serial\s*#?\s*:?\s*(\S+)", line)
|
||||
if match:
|
||||
devices.append({"serial": match.group(1)})
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def erase_fpga_metadata(serial: str | None = None) -> dict[str, str]:
|
||||
"""Erase FPGA metadata from flash.
|
||||
|
||||
WARNING: This erases flash memory content.
|
||||
|
||||
Args:
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with status.
|
||||
"""
|
||||
output = _run_cli(["erase fpga"], serial=serial, timeout=60)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def recover_device(fw_path: str | Path) -> dict[str, str]:
|
||||
"""Recover a device with corrupted firmware.
|
||||
|
||||
This uses the special recovery boot mode.
|
||||
|
||||
Args:
|
||||
fw_path: Path to firmware image
|
||||
|
||||
Returns:
|
||||
Dict with recovery status.
|
||||
"""
|
||||
fw_path = Path(fw_path)
|
||||
if not fw_path.exists():
|
||||
raise RuntimeError(f"Firmware file not found: {fw_path}")
|
||||
|
||||
cli_path = _find_bladerfcli()
|
||||
|
||||
# Recovery mode uses different command line
|
||||
cmd = [cli_path, "-r", str(fw_path)]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "completed" if result.returncode == 0 else "failed",
|
||||
"output": result.stdout + result.stderr,
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"status": "timeout",
|
||||
"message": "Recovery operation timed out",
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
"""BladeRF device wrapper using SoapySDR bindings.
|
||||
|
||||
This module provides a stateful wrapper around SoapySDR that maintains
|
||||
device connection and stream state across MCP tool calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import SoapySDR
|
||||
from SoapySDR import SOAPY_SDR_RX, SOAPY_SDR_TX
|
||||
|
||||
SOAPY_AVAILABLE = True
|
||||
except ImportError:
|
||||
SOAPY_AVAILABLE = False
|
||||
SOAPY_SDR_RX = 0
|
||||
SOAPY_SDR_TX = 1
|
||||
|
||||
|
||||
DirectionType = Literal["rx", "tx"]
|
||||
|
||||
|
||||
def direction_to_soapy(direction: DirectionType) -> int:
|
||||
"""Convert string direction to SoapySDR constant."""
|
||||
return SOAPY_SDR_RX if direction == "rx" else SOAPY_SDR_TX
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Information about a BladeRF device."""
|
||||
|
||||
serial: str
|
||||
driver: str
|
||||
label: str
|
||||
product: str | None = None
|
||||
manufacturer: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_soapy_dict(cls, d: dict[str, str]) -> DeviceInfo:
|
||||
return cls(
|
||||
serial=d.get("serial", "unknown"),
|
||||
driver=d.get("driver", "bladerf"),
|
||||
label=d.get("label", ""),
|
||||
product=d.get("product"),
|
||||
manufacturer=d.get("manufacturer"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RFConfig:
|
||||
"""Current RF configuration for a channel."""
|
||||
|
||||
frequency_hz: float
|
||||
sample_rate_hz: float
|
||||
bandwidth_hz: float
|
||||
gains: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BladeRFState:
|
||||
"""Persistent state for the BladeRF device connection."""
|
||||
|
||||
device: Any = None # SoapySDR.Device
|
||||
serial: str | None = None
|
||||
active_rx_stream: Any = None
|
||||
active_tx_stream: Any = None
|
||||
capture_task: asyncio.Task | None = None
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
# Global state singleton
|
||||
_state = BladeRFState()
|
||||
|
||||
|
||||
def get_state() -> BladeRFState:
|
||||
"""Get the global device state."""
|
||||
return _state
|
||||
|
||||
|
||||
def check_soapy_available() -> None:
|
||||
"""Raise if SoapySDR is not available."""
|
||||
if not SOAPY_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"SoapySDR Python bindings not available. "
|
||||
"Install with: pip install soapysdr (or system package)"
|
||||
)
|
||||
|
||||
|
||||
def list_devices() -> list[DeviceInfo]:
|
||||
"""List all available BladeRF devices.
|
||||
|
||||
Returns:
|
||||
List of DeviceInfo for each detected BladeRF device.
|
||||
"""
|
||||
check_soapy_available()
|
||||
|
||||
results = SoapySDR.Device.enumerate({"driver": "bladerf"})
|
||||
return [DeviceInfo.from_soapy_dict(dict(r)) for r in results]
|
||||
|
||||
|
||||
def connect(serial: str | None = None, device_index: int | None = None) -> DeviceInfo:
|
||||
"""Connect to a BladeRF device.
|
||||
|
||||
Args:
|
||||
serial: Device serial number to connect to.
|
||||
device_index: Device index (0-based) if serial not provided.
|
||||
|
||||
Returns:
|
||||
DeviceInfo for the connected device.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If connection fails or no device found.
|
||||
"""
|
||||
check_soapy_available()
|
||||
state = get_state()
|
||||
|
||||
# Close existing connection if any
|
||||
if state.device is not None:
|
||||
disconnect()
|
||||
|
||||
# Build device arguments string
|
||||
# Note: SoapySDR Python bindings expect a string, not a dict
|
||||
# Format: "key1=value1,key2=value2" or empty string for default device
|
||||
args_parts = []
|
||||
if serial:
|
||||
args_parts.append(f"serial={serial}")
|
||||
elif device_index is not None:
|
||||
# Enumerate and pick by index
|
||||
devices = list_devices()
|
||||
if device_index >= len(devices):
|
||||
raise RuntimeError(f"Device index {device_index} out of range (found {len(devices)})")
|
||||
args_parts.append(f"serial={devices[device_index].serial}")
|
||||
|
||||
args_str = ",".join(args_parts)
|
||||
|
||||
try:
|
||||
state.device = SoapySDR.Device(args_str)
|
||||
# Get the actual serial from hardware info
|
||||
# Note: getHardwareInfo returns SoapySDRKwargs, convert to dict
|
||||
hw_info = dict(state.device.getHardwareInfo())
|
||||
state.serial = hw_info.get("serial", serial or "unknown")
|
||||
|
||||
return DeviceInfo(
|
||||
serial=state.serial,
|
||||
driver="bladerf",
|
||||
label=hw_info.get("label", ""),
|
||||
product=hw_info.get("product", "BladeRF"),
|
||||
manufacturer=hw_info.get("vendor", "Nuand"),
|
||||
)
|
||||
except Exception as e:
|
||||
state.device = None
|
||||
state.serial = None
|
||||
raise RuntimeError(f"Failed to connect to BladeRF: {e}") from e
|
||||
|
||||
|
||||
def disconnect() -> None:
|
||||
"""Disconnect from the current BladeRF device."""
|
||||
state = get_state()
|
||||
|
||||
# Clean up streams
|
||||
if state.active_rx_stream is not None:
|
||||
try:
|
||||
state.device.deactivateStream(state.active_rx_stream)
|
||||
state.device.closeStream(state.active_rx_stream)
|
||||
except Exception:
|
||||
pass
|
||||
state.active_rx_stream = None
|
||||
|
||||
if state.active_tx_stream is not None:
|
||||
try:
|
||||
state.device.deactivateStream(state.active_tx_stream)
|
||||
state.device.closeStream(state.active_tx_stream)
|
||||
except Exception:
|
||||
pass
|
||||
state.active_tx_stream = None
|
||||
|
||||
# Cancel any running capture
|
||||
if state.capture_task is not None and not state.capture_task.done():
|
||||
state.capture_task.cancel()
|
||||
state.capture_task = None
|
||||
|
||||
# Close device
|
||||
if state.device is not None:
|
||||
state.device = None
|
||||
state.serial = None
|
||||
|
||||
|
||||
def is_connected() -> bool:
|
||||
"""Check if a device is currently connected."""
|
||||
return get_state().device is not None
|
||||
|
||||
|
||||
def ensure_connected() -> None:
|
||||
"""Raise if no device is connected."""
|
||||
if not is_connected():
|
||||
raise RuntimeError("No BladeRF device connected. Use bladerf_connect first.")
|
||||
|
||||
|
||||
def get_device() -> Any:
|
||||
"""Get the SoapySDR device object.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no device is connected.
|
||||
"""
|
||||
ensure_connected()
|
||||
return get_state().device
|
||||
|
||||
|
||||
def get_hardware_info() -> dict[str, Any]:
|
||||
"""Get detailed hardware information.
|
||||
|
||||
Returns:
|
||||
Dict with serial, firmware version, FPGA version, USB speed, etc.
|
||||
"""
|
||||
device = get_device()
|
||||
hw_info = dict(device.getHardwareInfo())
|
||||
|
||||
# Add driver info
|
||||
info = {
|
||||
"serial": hw_info.get("serial", get_state().serial),
|
||||
"product": hw_info.get("product", "BladeRF"),
|
||||
"vendor": hw_info.get("vendor", "Nuand"),
|
||||
"fw_version": hw_info.get("fw_version", "unknown"),
|
||||
"fpga_version": hw_info.get("fpga_version", "unknown"),
|
||||
"fpga_size": hw_info.get("fpga_size", "unknown"),
|
||||
"usb_speed": hw_info.get("usb_speed", "unknown"),
|
||||
}
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def get_frequency(direction: DirectionType, channel: int = 0) -> float:
|
||||
"""Get current center frequency.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
channel: Channel index (0 for BladeRF 1.0)
|
||||
|
||||
Returns:
|
||||
Current frequency in Hz.
|
||||
"""
|
||||
device = get_device()
|
||||
return device.getFrequency(direction_to_soapy(direction), channel)
|
||||
|
||||
|
||||
def set_frequency(direction: DirectionType, frequency_hz: float, channel: int = 0) -> float:
|
||||
"""Set center frequency.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
frequency_hz: Desired frequency in Hz
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Actual frequency set (may differ due to PLL resolution).
|
||||
"""
|
||||
device = get_device()
|
||||
device.setFrequency(direction_to_soapy(direction), channel, frequency_hz)
|
||||
return device.getFrequency(direction_to_soapy(direction), channel)
|
||||
|
||||
|
||||
def get_sample_rate(direction: DirectionType, channel: int = 0) -> float:
|
||||
"""Get current sample rate.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Current sample rate in Hz.
|
||||
"""
|
||||
device = get_device()
|
||||
return device.getSampleRate(direction_to_soapy(direction), channel)
|
||||
|
||||
|
||||
def set_sample_rate(direction: DirectionType, sample_rate_hz: float, channel: int = 0) -> float:
|
||||
"""Set sample rate.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
sample_rate_hz: Desired sample rate in Hz
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Actual sample rate set.
|
||||
"""
|
||||
device = get_device()
|
||||
device.setSampleRate(direction_to_soapy(direction), channel, sample_rate_hz)
|
||||
return device.getSampleRate(direction_to_soapy(direction), channel)
|
||||
|
||||
|
||||
def get_bandwidth(direction: DirectionType, channel: int = 0) -> float:
|
||||
"""Get current analog bandwidth.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Current bandwidth in Hz.
|
||||
"""
|
||||
device = get_device()
|
||||
return device.getBandwidth(direction_to_soapy(direction), channel)
|
||||
|
||||
|
||||
def set_bandwidth(direction: DirectionType, bandwidth_hz: float, channel: int = 0) -> float:
|
||||
"""Set analog bandwidth.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
bandwidth_hz: Desired bandwidth in Hz
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Actual bandwidth set.
|
||||
"""
|
||||
device = get_device()
|
||||
device.setBandwidth(direction_to_soapy(direction), channel, bandwidth_hz)
|
||||
return device.getBandwidth(direction_to_soapy(direction), channel)
|
||||
|
||||
|
||||
def list_gains(direction: DirectionType, channel: int = 0) -> list[dict[str, Any]]:
|
||||
"""List available gain stages and their ranges.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
List of dicts with stage name, min, max, step for each gain stage.
|
||||
"""
|
||||
device = get_device()
|
||||
soapy_dir = direction_to_soapy(direction)
|
||||
stages = device.listGains(soapy_dir, channel)
|
||||
|
||||
result = []
|
||||
for stage in stages:
|
||||
gain_range = device.getGainRange(soapy_dir, channel, stage)
|
||||
result.append({
|
||||
"stage": stage,
|
||||
"min_db": gain_range.minimum(),
|
||||
"max_db": gain_range.maximum(),
|
||||
"step_db": gain_range.step() if gain_range.step() > 0 else 1.0,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_gain(direction: DirectionType, stage: str, channel: int = 0) -> float:
|
||||
"""Get gain for a specific stage.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
stage: Gain stage name (e.g., "lna", "rxvga1")
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Current gain in dB.
|
||||
"""
|
||||
device = get_device()
|
||||
return device.getGain(direction_to_soapy(direction), channel, stage)
|
||||
|
||||
|
||||
def set_gain(direction: DirectionType, stage: str, gain_db: float, channel: int = 0) -> float:
|
||||
"""Set gain for a specific stage.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
stage: Gain stage name
|
||||
gain_db: Desired gain in dB
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Actual gain set.
|
||||
"""
|
||||
device = get_device()
|
||||
soapy_dir = direction_to_soapy(direction)
|
||||
device.setGain(soapy_dir, channel, stage, gain_db)
|
||||
return device.getGain(soapy_dir, channel, stage)
|
||||
|
||||
|
||||
def get_frequency_range(direction: DirectionType, channel: int = 0) -> dict[str, float]:
|
||||
"""Get supported frequency range.
|
||||
|
||||
Returns:
|
||||
Dict with min_hz, max_hz.
|
||||
"""
|
||||
device = get_device()
|
||||
ranges = device.getFrequencyRange(direction_to_soapy(direction), channel)
|
||||
# SoapySDR returns list of ranges, usually just one for BladeRF
|
||||
if ranges:
|
||||
return {
|
||||
"min_hz": ranges[0].minimum(),
|
||||
"max_hz": ranges[0].maximum(),
|
||||
}
|
||||
return {"min_hz": 237.5e6, "max_hz": 3.8e9} # BladeRF 1.0 defaults
|
||||
|
||||
|
||||
def get_sample_rate_range(direction: DirectionType, channel: int = 0) -> dict[str, float]:
|
||||
"""Get supported sample rate range.
|
||||
|
||||
Returns:
|
||||
Dict with min_hz, max_hz.
|
||||
"""
|
||||
device = get_device()
|
||||
ranges = device.getSampleRateRange(direction_to_soapy(direction), channel)
|
||||
if ranges:
|
||||
return {
|
||||
"min_hz": ranges[0].minimum(),
|
||||
"max_hz": ranges[0].maximum(),
|
||||
}
|
||||
return {"min_hz": 80e3, "max_hz": 40e6} # BladeRF 1.0 defaults
|
||||
|
||||
|
||||
def get_rf_config(direction: DirectionType, channel: int = 0) -> RFConfig:
|
||||
"""Get complete RF configuration for a channel.
|
||||
|
||||
Returns:
|
||||
RFConfig with frequency, sample rate, bandwidth, and all gains.
|
||||
"""
|
||||
device = get_device()
|
||||
soapy_dir = direction_to_soapy(direction)
|
||||
|
||||
gains = {}
|
||||
for stage in device.listGains(soapy_dir, channel):
|
||||
gains[stage] = device.getGain(soapy_dir, channel, stage)
|
||||
|
||||
return RFConfig(
|
||||
frequency_hz=device.getFrequency(soapy_dir, channel),
|
||||
sample_rate_hz=device.getSampleRate(soapy_dir, channel),
|
||||
bandwidth_hz=device.getBandwidth(soapy_dir, channel),
|
||||
gains=gains,
|
||||
)
|
||||
|
||||
|
||||
def setup_rx_stream(format_str: str = "CF32", channel: int = 0) -> Any:
|
||||
"""Set up an RX stream for capturing samples.
|
||||
|
||||
Args:
|
||||
format_str: Sample format ("CF32" for complex float32, "CS16" for complex int16)
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Stream handle.
|
||||
"""
|
||||
state = get_state()
|
||||
device = get_device()
|
||||
|
||||
# Close existing stream if any
|
||||
if state.active_rx_stream is not None:
|
||||
try:
|
||||
device.deactivateStream(state.active_rx_stream)
|
||||
device.closeStream(state.active_rx_stream)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stream = device.setupStream(SOAPY_SDR_RX, format_str, [channel])
|
||||
state.active_rx_stream = stream
|
||||
return stream
|
||||
|
||||
|
||||
def activate_rx_stream() -> None:
|
||||
"""Activate the RX stream for sample capture."""
|
||||
state = get_state()
|
||||
device = get_device()
|
||||
|
||||
if state.active_rx_stream is None:
|
||||
raise RuntimeError("No RX stream set up. Call setup_rx_stream first.")
|
||||
|
||||
device.activateStream(state.active_rx_stream)
|
||||
|
||||
|
||||
def deactivate_rx_stream() -> None:
|
||||
"""Deactivate the RX stream."""
|
||||
state = get_state()
|
||||
device = get_device()
|
||||
|
||||
if state.active_rx_stream is not None:
|
||||
device.deactivateStream(state.active_rx_stream)
|
||||
|
||||
|
||||
def read_stream(
|
||||
num_samples: int, timeout_us: int = 100000
|
||||
) -> tuple[np.ndarray, int]:
|
||||
"""Read samples from the active RX stream.
|
||||
|
||||
Args:
|
||||
num_samples: Number of complex samples to read
|
||||
timeout_us: Timeout in microseconds
|
||||
|
||||
Returns:
|
||||
Tuple of (samples array, flags).
|
||||
"""
|
||||
state = get_state()
|
||||
device = get_device()
|
||||
|
||||
if state.active_rx_stream is None:
|
||||
raise RuntimeError("No RX stream active.")
|
||||
|
||||
# Allocate buffer - SoapySDR expects list of buffers, one per channel
|
||||
buff = np.zeros(num_samples, dtype=np.complex64)
|
||||
sr = device.readStream(state.active_rx_stream, [buff], num_samples, timeoutUs=timeout_us)
|
||||
|
||||
if sr.ret < 0:
|
||||
raise RuntimeError(f"Stream read error: {sr.ret}")
|
||||
|
||||
return buff[:sr.ret], sr.flags
|
||||
|
||||
|
||||
def close_rx_stream() -> None:
|
||||
"""Close the RX stream."""
|
||||
state = get_state()
|
||||
device = get_device()
|
||||
|
||||
if state.active_rx_stream is not None:
|
||||
try:
|
||||
device.deactivateStream(state.active_rx_stream)
|
||||
device.closeStream(state.active_rx_stream)
|
||||
except Exception:
|
||||
pass
|
||||
state.active_rx_stream = None
|
||||
@@ -0,0 +1,787 @@
|
||||
"""FastMCP server for Nuand BladeRF SDR control.
|
||||
|
||||
This server provides comprehensive device management, RF configuration,
|
||||
and IQ data capture capabilities through the Model Context Protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from . import device, capture, cli_fallback
|
||||
|
||||
# Create FastMCP server instance
|
||||
mcp = FastMCP(
|
||||
"mcbladerf",
|
||||
instructions="MCP server for Nuand BladeRF SDR control via SoapySDR",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Device Management Tools
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_status() -> dict:
|
||||
"""Get comprehensive BladeRF device status.
|
||||
|
||||
Returns device identification (serial, board name), firmware and FPGA
|
||||
versions, USB connection speed, and current RF configuration for RX/TX.
|
||||
|
||||
Returns:
|
||||
Dict with device info and current configuration.
|
||||
"""
|
||||
if not device.is_connected():
|
||||
return {
|
||||
"connected": False,
|
||||
"message": "No device connected. Use bladerf_connect first.",
|
||||
}
|
||||
|
||||
hw_info = device.get_hardware_info()
|
||||
rx_config = device.get_rf_config("rx")
|
||||
tx_config = device.get_rf_config("tx")
|
||||
|
||||
return {
|
||||
"connected": True,
|
||||
"serial": hw_info.get("serial"),
|
||||
"product": hw_info.get("product"),
|
||||
"vendor": hw_info.get("vendor"),
|
||||
"fw_version": hw_info.get("fw_version"),
|
||||
"fpga_version": hw_info.get("fpga_version"),
|
||||
"fpga_size": hw_info.get("fpga_size"),
|
||||
"usb_speed": hw_info.get("usb_speed"),
|
||||
"rx_config": {
|
||||
"frequency_hz": rx_config.frequency_hz,
|
||||
"sample_rate_hz": rx_config.sample_rate_hz,
|
||||
"bandwidth_hz": rx_config.bandwidth_hz,
|
||||
"gains": rx_config.gains,
|
||||
},
|
||||
"tx_config": {
|
||||
"frequency_hz": tx_config.frequency_hz,
|
||||
"sample_rate_hz": tx_config.sample_rate_hz,
|
||||
"bandwidth_hz": tx_config.bandwidth_hz,
|
||||
"gains": tx_config.gains,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_list_devices() -> dict:
|
||||
"""Enumerate all connected BladeRF devices.
|
||||
|
||||
Scans for BladeRF devices via SoapySDR and returns information about each.
|
||||
|
||||
Returns:
|
||||
Dict with list of detected devices and count.
|
||||
"""
|
||||
try:
|
||||
devices = device.list_devices()
|
||||
return {
|
||||
"count": len(devices),
|
||||
"devices": [
|
||||
{
|
||||
"serial": d.serial,
|
||||
"driver": d.driver,
|
||||
"label": d.label,
|
||||
"product": d.product,
|
||||
"manufacturer": d.manufacturer,
|
||||
}
|
||||
for d in devices
|
||||
],
|
||||
}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e), "count": 0, "devices": []}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_connect(
|
||||
serial: Annotated[str | None, "Device serial number (uses first device if None)"] = None,
|
||||
device_index: Annotated[int | None, "Device index (0-based) if serial not provided"] = None,
|
||||
) -> dict:
|
||||
"""Connect to a BladeRF device.
|
||||
|
||||
Establishes connection to a specific device by serial number or index.
|
||||
The connection persists across tool calls until explicitly disconnected.
|
||||
|
||||
Args:
|
||||
serial: Device serial number to connect to.
|
||||
device_index: Device index (0-based) if serial not provided.
|
||||
|
||||
Returns:
|
||||
Dict with connection status and device info.
|
||||
"""
|
||||
try:
|
||||
info = device.connect(serial=serial, device_index=device_index)
|
||||
return {
|
||||
"status": "connected",
|
||||
"serial": info.serial,
|
||||
"product": info.product,
|
||||
"manufacturer": info.manufacturer,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_disconnect() -> dict:
|
||||
"""Disconnect from the current BladeRF device.
|
||||
|
||||
Closes the connection and cleans up any active streams.
|
||||
|
||||
Returns:
|
||||
Dict with disconnection status.
|
||||
"""
|
||||
was_connected = device.is_connected()
|
||||
device.disconnect()
|
||||
return {
|
||||
"status": "disconnected",
|
||||
"was_connected": was_connected,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RF Configuration Tools
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_get_frequency(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX for receive, TX for transmit"],
|
||||
channel: Annotated[int, "Channel index (0 for BladeRF 1.0)"] = 0,
|
||||
) -> dict:
|
||||
"""Get current center frequency.
|
||||
|
||||
Args:
|
||||
direction: "rx" for receive, "tx" for transmit
|
||||
channel: Channel index (0 for BladeRF 1.0 single-channel)
|
||||
|
||||
Returns:
|
||||
Dict with current frequency in Hz.
|
||||
"""
|
||||
try:
|
||||
freq = device.get_frequency(direction, channel)
|
||||
return {"frequency_hz": freq, "direction": direction, "channel": channel}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_set_frequency(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX for receive, TX for transmit"],
|
||||
frequency_hz: Annotated[float, "Center frequency in Hz (237.5 MHz - 3.8 GHz)"],
|
||||
channel: Annotated[int, "Channel index"] = 0,
|
||||
) -> dict:
|
||||
"""Set center frequency for RX or TX.
|
||||
|
||||
BladeRF 1.0 supports 237.5 MHz to 3.8 GHz.
|
||||
|
||||
Args:
|
||||
direction: "rx" for receive, "tx" for transmit
|
||||
frequency_hz: Center frequency in Hz
|
||||
channel: Channel index (0 for BladeRF 1.0)
|
||||
|
||||
Returns:
|
||||
Dict with actual frequency set (may differ due to PLL resolution).
|
||||
"""
|
||||
try:
|
||||
actual = device.set_frequency(direction, frequency_hz, channel)
|
||||
return {
|
||||
"requested_hz": frequency_hz,
|
||||
"actual_hz": actual,
|
||||
"direction": direction,
|
||||
"channel": channel,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_get_sample_rate(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX or TX"],
|
||||
channel: Annotated[int, "Channel index"] = 0,
|
||||
) -> dict:
|
||||
"""Get current sample rate.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Dict with current sample rate in Hz.
|
||||
"""
|
||||
try:
|
||||
rate = device.get_sample_rate(direction, channel)
|
||||
return {"sample_rate_hz": rate, "direction": direction, "channel": channel}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_set_sample_rate(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX or TX"],
|
||||
sample_rate_hz: Annotated[float, "Sample rate in Hz (80 Ksps - 40 Msps)"],
|
||||
channel: Annotated[int, "Channel index"] = 0,
|
||||
) -> dict:
|
||||
"""Set sample rate.
|
||||
|
||||
BladeRF 1.0 supports 80 Ksps to 40 Msps.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
sample_rate_hz: Sample rate in Hz
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Dict with actual sample rate set.
|
||||
"""
|
||||
try:
|
||||
actual = device.set_sample_rate(direction, sample_rate_hz, channel)
|
||||
return {
|
||||
"requested_hz": sample_rate_hz,
|
||||
"actual_hz": actual,
|
||||
"direction": direction,
|
||||
"channel": channel,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_get_bandwidth(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX or TX"],
|
||||
channel: Annotated[int, "Channel index"] = 0,
|
||||
) -> dict:
|
||||
"""Get current analog bandwidth.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Dict with current bandwidth in Hz.
|
||||
"""
|
||||
try:
|
||||
bw = device.get_bandwidth(direction, channel)
|
||||
return {"bandwidth_hz": bw, "direction": direction, "channel": channel}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_set_bandwidth(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX or TX"],
|
||||
bandwidth_hz: Annotated[float, "Analog bandwidth in Hz"],
|
||||
channel: Annotated[int, "Channel index"] = 0,
|
||||
) -> dict:
|
||||
"""Set analog bandwidth.
|
||||
|
||||
The analog filter bandwidth affects signal quality and aliasing.
|
||||
Typically set to ~80% of sample rate.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
bandwidth_hz: Bandwidth in Hz
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Dict with actual bandwidth set.
|
||||
"""
|
||||
try:
|
||||
actual = device.set_bandwidth(direction, bandwidth_hz, channel)
|
||||
return {
|
||||
"requested_hz": bandwidth_hz,
|
||||
"actual_hz": actual,
|
||||
"direction": direction,
|
||||
"channel": channel,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_list_gains(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX or TX"],
|
||||
channel: Annotated[int, "Channel index"] = 0,
|
||||
) -> dict:
|
||||
"""List available gain stages and their ranges.
|
||||
|
||||
BladeRF 1.0 gain stages:
|
||||
- RX: lna (0/3/6 dB), rxvga1 (5-30 dB), rxvga2 (0-30 dB)
|
||||
- TX: txvga1 (-35 to -4 dB), txvga2 (0-25 dB)
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Dict with list of gain stages and their min/max/step values.
|
||||
"""
|
||||
try:
|
||||
stages = device.list_gains(direction, channel)
|
||||
return {"direction": direction, "channel": channel, "stages": stages}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_get_gain(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX or TX"],
|
||||
stage: Annotated[str, "Gain stage name (lna, rxvga1, rxvga2, txvga1, txvga2)"],
|
||||
channel: Annotated[int, "Channel index"] = 0,
|
||||
) -> dict:
|
||||
"""Get gain for a specific amplifier stage.
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
stage: Gain stage name
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Dict with current gain in dB.
|
||||
"""
|
||||
try:
|
||||
gain = device.get_gain(direction, stage, channel)
|
||||
return {"gain_db": gain, "stage": stage, "direction": direction, "channel": channel}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_set_gain(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX or TX"],
|
||||
stage: Annotated[str, "Gain stage name"],
|
||||
gain_db: Annotated[float, "Gain in dB"],
|
||||
channel: Annotated[int, "Channel index"] = 0,
|
||||
) -> dict:
|
||||
"""Set gain for a specific amplifier stage.
|
||||
|
||||
BladeRF 1.0 gain stages:
|
||||
- RX: lna (0/3/6 dB), rxvga1 (5-30 dB), rxvga2 (0-30 dB)
|
||||
- TX: txvga1 (-35 to -4 dB), txvga2 (0-25 dB)
|
||||
|
||||
Args:
|
||||
direction: "rx" or "tx"
|
||||
stage: Gain stage name
|
||||
gain_db: Desired gain in dB
|
||||
channel: Channel index
|
||||
|
||||
Returns:
|
||||
Dict with actual gain set.
|
||||
"""
|
||||
try:
|
||||
actual = device.set_gain(direction, stage, gain_db, channel)
|
||||
return {
|
||||
"requested_db": gain_db,
|
||||
"actual_db": actual,
|
||||
"stage": stage,
|
||||
"direction": direction,
|
||||
"channel": channel,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_get_frequency_range(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX or TX"],
|
||||
channel: Annotated[int, "Channel index"] = 0,
|
||||
) -> dict:
|
||||
"""Get supported frequency range.
|
||||
|
||||
Returns:
|
||||
Dict with min_hz and max_hz for the supported frequency range.
|
||||
"""
|
||||
try:
|
||||
range_info = device.get_frequency_range(direction, channel)
|
||||
return {"direction": direction, "channel": channel, **range_info}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_get_sample_rate_range(
|
||||
direction: Annotated[Literal["rx", "tx"], "RX or TX"],
|
||||
channel: Annotated[int, "Channel index"] = 0,
|
||||
) -> dict:
|
||||
"""Get supported sample rate range.
|
||||
|
||||
Returns:
|
||||
Dict with min_hz and max_hz for the supported sample rate range.
|
||||
"""
|
||||
try:
|
||||
range_info = device.get_sample_rate_range(direction, channel)
|
||||
return {"direction": direction, "channel": channel, **range_info}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# IQ Capture Tools
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_capture_iq(
|
||||
frequency_hz: Annotated[float, "Center frequency in Hz"],
|
||||
sample_rate_hz: Annotated[float, "Sample rate in Hz"],
|
||||
duration_seconds: Annotated[float, "Capture duration in seconds"],
|
||||
output_path: Annotated[str, "Output file path (.cf32 or .cs16)"],
|
||||
format: Annotated[Literal["cf32", "cs16"], "Sample format"] = "cf32",
|
||||
lna_gain_db: Annotated[float | None, "LNA gain in dB (0, 3, or 6)"] = None,
|
||||
rxvga1_gain_db: Annotated[float | None, "RXVGA1 gain in dB (5-30)"] = None,
|
||||
rxvga2_gain_db: Annotated[float | None, "RXVGA2 gain in dB (0-30)"] = None,
|
||||
bandwidth_hz: Annotated[float | None, "Analog bandwidth (defaults to 0.8 * sample_rate)"] = None,
|
||||
) -> dict:
|
||||
"""Capture IQ samples to a file.
|
||||
|
||||
Configures the receiver and streams samples to disk. Supports complex
|
||||
float32 (CF32, 8 bytes/sample) or complex int16 (CS16, 4 bytes/sample).
|
||||
|
||||
Args:
|
||||
frequency_hz: Center frequency
|
||||
sample_rate_hz: Sample rate (80 Ksps - 40 Msps)
|
||||
duration_seconds: Capture duration
|
||||
output_path: Output file path
|
||||
format: Sample format - cf32 or cs16
|
||||
lna_gain_db: LNA gain (0, 3, or 6 dB)
|
||||
rxvga1_gain_db: RXVGA1 gain (5-30 dB)
|
||||
rxvga2_gain_db: RXVGA2 gain (0-30 dB)
|
||||
bandwidth_hz: Analog bandwidth
|
||||
|
||||
Returns:
|
||||
Dict with file path, sample count, duration, and file size.
|
||||
"""
|
||||
# Build gains dict from individual parameters
|
||||
gains = {}
|
||||
if lna_gain_db is not None:
|
||||
gains["lna"] = lna_gain_db
|
||||
if rxvga1_gain_db is not None:
|
||||
gains["rxvga1"] = rxvga1_gain_db
|
||||
if rxvga2_gain_db is not None:
|
||||
gains["rxvga2"] = rxvga2_gain_db
|
||||
|
||||
try:
|
||||
result = capture.capture_iq_sync(
|
||||
frequency_hz=frequency_hz,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
duration_seconds=duration_seconds,
|
||||
output_path=output_path,
|
||||
format=format,
|
||||
gains=gains if gains else None,
|
||||
bandwidth_hz=bandwidth_hz,
|
||||
)
|
||||
return {
|
||||
"status": "completed",
|
||||
"file_path": result.file_path,
|
||||
"sample_count": result.sample_count,
|
||||
"duration_seconds": result.duration_seconds,
|
||||
"sample_rate_hz": result.sample_rate_hz,
|
||||
"frequency_hz": result.frequency_hz,
|
||||
"format": result.format,
|
||||
"file_size_bytes": result.file_size_bytes,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_capture_spectrum(
|
||||
frequency_hz: Annotated[float, "Center frequency in Hz"],
|
||||
sample_rate_hz: Annotated[float, "Sample rate (determines bandwidth)"],
|
||||
fft_size: Annotated[int, "FFT size (power of 2 recommended)"] = 1024,
|
||||
averaging: Annotated[int, "Number of FFTs to average"] = 10,
|
||||
lna_gain_db: Annotated[float | None, "LNA gain in dB"] = None,
|
||||
rxvga1_gain_db: Annotated[float | None, "RXVGA1 gain in dB"] = None,
|
||||
rxvga2_gain_db: Annotated[float | None, "RXVGA2 gain in dB"] = None,
|
||||
window: Annotated[Literal["hann", "blackman", "hamming", "none"], "Window function"] = "hann",
|
||||
) -> dict:
|
||||
"""Capture and compute power spectrum (FFT).
|
||||
|
||||
Captures IQ samples and computes an averaged power spectrum.
|
||||
Returns frequency bins and power levels in dB.
|
||||
|
||||
Args:
|
||||
frequency_hz: Center frequency
|
||||
sample_rate_hz: Sample rate (determines visible bandwidth)
|
||||
fft_size: FFT size (more bins = finer frequency resolution)
|
||||
averaging: Number of FFTs to average (reduces noise floor)
|
||||
lna_gain_db: LNA gain
|
||||
rxvga1_gain_db: RXVGA1 gain
|
||||
rxvga2_gain_db: RXVGA2 gain
|
||||
window: Window function for spectral leakage reduction
|
||||
|
||||
Returns:
|
||||
Dict with frequency bins, power levels, and peak detection.
|
||||
"""
|
||||
gains = {}
|
||||
if lna_gain_db is not None:
|
||||
gains["lna"] = lna_gain_db
|
||||
if rxvga1_gain_db is not None:
|
||||
gains["rxvga1"] = rxvga1_gain_db
|
||||
if rxvga2_gain_db is not None:
|
||||
gains["rxvga2"] = rxvga2_gain_db
|
||||
|
||||
try:
|
||||
result = capture.capture_spectrum_sync(
|
||||
frequency_hz=frequency_hz,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
fft_size=fft_size,
|
||||
averaging=averaging,
|
||||
gains=gains if gains else None,
|
||||
window=window,
|
||||
)
|
||||
return {
|
||||
"status": "completed",
|
||||
"center_frequency_hz": result.center_frequency_hz,
|
||||
"bandwidth_hz": result.bandwidth_hz,
|
||||
"bin_count": result.bin_count,
|
||||
"frequencies_hz": result.frequencies_hz,
|
||||
"power_db": result.power_db,
|
||||
"peak_frequency_hz": result.peak_frequency_hz,
|
||||
"peak_power_db": result.peak_power_db,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_stream_status() -> dict:
|
||||
"""Check if a capture is in progress.
|
||||
|
||||
Returns:
|
||||
Dict with capture status and progress if active.
|
||||
"""
|
||||
if capture.is_capture_active():
|
||||
progress = capture.get_capture_progress()
|
||||
if progress:
|
||||
return {
|
||||
"active": True,
|
||||
"samples_captured": progress.samples_captured,
|
||||
"samples_total": progress.samples_total,
|
||||
"progress_percent": (progress.samples_captured / progress.samples_total) * 100,
|
||||
"elapsed_seconds": progress.elapsed_seconds,
|
||||
"estimated_remaining_seconds": progress.estimated_remaining_seconds,
|
||||
"bytes_written": progress.bytes_written,
|
||||
}
|
||||
return {"active": True}
|
||||
return {"active": False}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_stop_capture() -> dict:
|
||||
"""Abort an in-progress capture.
|
||||
|
||||
Returns:
|
||||
Dict with cancellation status.
|
||||
"""
|
||||
cancelled = capture.cancel_capture()
|
||||
return {
|
||||
"cancelled": cancelled,
|
||||
"message": "Capture cancelled" if cancelled else "No capture was active",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Advanced / Utility Tools
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_load_fpga(
|
||||
rbf_path: Annotated[str, "Path to FPGA bitstream (.rbf file)"],
|
||||
serial: Annotated[str | None, "Device serial (uses connected device if None)"] = None,
|
||||
) -> dict:
|
||||
"""Load FPGA bitstream from file (RAM, not flash).
|
||||
|
||||
Loads the FPGA configuration to RAM. This is NOT persistent across
|
||||
power cycles. Use bladerf_flash_fpga for persistent storage.
|
||||
|
||||
Args:
|
||||
rbf_path: Path to .rbf FPGA bitstream file
|
||||
serial: Device serial (uses first/connected device if None)
|
||||
|
||||
Returns:
|
||||
Dict with load status.
|
||||
"""
|
||||
try:
|
||||
# Use connected device's serial if not specified
|
||||
if serial is None and device.is_connected():
|
||||
serial = device.get_state().serial
|
||||
|
||||
result = cli_fallback.load_fpga(rbf_path, serial)
|
||||
return result
|
||||
except RuntimeError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_flash_firmware(
|
||||
img_path: Annotated[str, "Path to firmware image file"],
|
||||
serial: Annotated[str | None, "Device serial"] = None,
|
||||
) -> dict:
|
||||
"""Flash FX3 firmware to device (persistent).
|
||||
|
||||
WARNING: This writes firmware to the device. Use official firmware
|
||||
images from Nuand. Device may need power cycling after flash.
|
||||
|
||||
Args:
|
||||
img_path: Path to firmware image file
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with flash status.
|
||||
"""
|
||||
try:
|
||||
if serial is None and device.is_connected():
|
||||
serial = device.get_state().serial
|
||||
|
||||
result = cli_fallback.flash_firmware(img_path, serial)
|
||||
return result
|
||||
except RuntimeError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_calibrate_dc(
|
||||
serial: Annotated[str | None, "Device serial"] = None,
|
||||
) -> dict:
|
||||
"""Run DC offset calibration.
|
||||
|
||||
Calibrates the DC offset to reduce LO leakage and improve signal quality.
|
||||
|
||||
Args:
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with calibration status.
|
||||
"""
|
||||
try:
|
||||
if serial is None and device.is_connected():
|
||||
serial = device.get_state().serial
|
||||
|
||||
result = cli_fallback.calibrate_dc(serial)
|
||||
return result
|
||||
except RuntimeError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_get_hardware_info(
|
||||
serial: Annotated[str | None, "Device serial"] = None,
|
||||
) -> dict:
|
||||
"""Get detailed hardware information via bladeRF-cli.
|
||||
|
||||
Returns more detailed info than bladerf_status, including FPGA size,
|
||||
USB backend, and loading status.
|
||||
|
||||
Args:
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with detailed hardware information.
|
||||
"""
|
||||
try:
|
||||
if serial is None and device.is_connected():
|
||||
serial = device.get_state().serial
|
||||
|
||||
info = cli_fallback.get_device_info(serial)
|
||||
return {
|
||||
"serial": info.serial,
|
||||
"fpga_size": info.fpga_size,
|
||||
"fpga_loaded": info.fpga_loaded,
|
||||
"fpga_version": info.fpga_version,
|
||||
"fw_version": info.fw_version,
|
||||
"usb_speed": info.usb_speed,
|
||||
"backend": info.backend,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_set_loopback(
|
||||
mode: Annotated[
|
||||
Literal[
|
||||
"none", "firmware", "bb_txlpf_rxvga2", "bb_txvga1_rxvga2",
|
||||
"bb_txlpf_rxlpf", "bb_txvga1_rxlpf", "rf_lna1", "rf_lna2", "rf_lna3"
|
||||
],
|
||||
"Loopback mode"
|
||||
],
|
||||
serial: Annotated[str | None, "Device serial"] = None,
|
||||
) -> dict:
|
||||
"""Set loopback mode for testing.
|
||||
|
||||
Loopback modes route TX signal back to RX for self-test:
|
||||
- none: Normal operation
|
||||
- firmware: Digital loopback in firmware
|
||||
- bb_*: Baseband analog loopback
|
||||
- rf_*: RF loopback through LNA
|
||||
|
||||
Args:
|
||||
mode: Loopback mode name
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with status.
|
||||
"""
|
||||
try:
|
||||
if serial is None and device.is_connected():
|
||||
serial = device.get_state().serial
|
||||
|
||||
result = cli_fallback.set_loopback(mode, serial)
|
||||
return result
|
||||
except RuntimeError as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bladerf_probe(
|
||||
serial: Annotated[str | None, "Device serial"] = None,
|
||||
) -> dict:
|
||||
"""Probe device for detailed diagnostics.
|
||||
|
||||
Runs bladeRF-cli probe command for comprehensive device information.
|
||||
|
||||
Args:
|
||||
serial: Device serial
|
||||
|
||||
Returns:
|
||||
Dict with probe output.
|
||||
"""
|
||||
try:
|
||||
if serial is None and device.is_connected():
|
||||
serial = device.get_state().serial
|
||||
|
||||
result = cli_fallback.probe_device(serial)
|
||||
return result
|
||||
except RuntimeError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Server Entry Point
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main():
|
||||
"""Start the mcbladerf MCP server."""
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
package_version = version("mcbladerf")
|
||||
except Exception:
|
||||
from . import __version__
|
||||
package_version = __version__
|
||||
|
||||
print(f"📡 mcbladerf v{package_version}")
|
||||
print(" MCP server for Nuand BladeRF SDR control")
|
||||
print(" Using SoapySDR for device access")
|
||||
print()
|
||||
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for mcbladerf MCP server."""
|
||||
Reference in New Issue
Block a user