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:
2026-10-24 20:33:27 -06:00
commit c767166984
13 changed files with 4305 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.pyc
.ruff_cache/
.pytest_cache/
dist/
*.cf32
*.cs16
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"mcbladerf": {
"type": "stdio",
"command": "uv",
"args": ["run", "mcbladerf"]
}
}
}
+20
View File
@@ -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
+122
View File
@@ -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
View File
@@ -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 "$@"
+55
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
"""MCP server for Nuand BladeRF SDR control via SoapySDR."""
__version__ = "2026.02.08"
+476
View File
@@ -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
+429
View File
@@ -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",
}
+527
View File
@@ -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
+787
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Tests for mcbladerf MCP server."""
Generated
+1855
View File
File diff suppressed because it is too large Load Diff