st_edits #6
|
|
@ -54,12 +54,14 @@ pluto = ["pyadi-iio>=0.0.14"]
|
||||||
usrp = [] # Requires system UHD installation
|
usrp = [] # Requires system UHD installation
|
||||||
hackrf = ["pyhackrf>=0.2.0"]
|
hackrf = ["pyhackrf>=0.2.0"]
|
||||||
bladerf = [] # Requires system libbladerf installation
|
bladerf = [] # Requires system libbladerf installation
|
||||||
|
thinkrf = ["pyrf>=2.8.0"] # NOTE: Requires lib2to3 post-install fix (see docs/)
|
||||||
|
|
||||||
# All SDR hardware support
|
# All SDR hardware support
|
||||||
all-sdr = [
|
all-sdr = [
|
||||||
"pyrtlsdr>=0.2.9",
|
"pyrtlsdr>=0.2.9",
|
||||||
"pyadi-iio>=0.0.14",
|
"pyadi-iio>=0.0.14",
|
||||||
"pyhackrf>=0.2.0",
|
"pyhackrf>=0.2.0",
|
||||||
|
"pyrf>=2.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
|
|
|
||||||
44
scripts/fix_pyrf_python3.py
Normal file
44
scripts/fix_pyrf_python3.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fix pyrf Python 3 compatibility.
|
||||||
|
|
||||||
|
The pyrf library ships with Python 2 syntax in pyrf/devices/thinkrf.py.
|
||||||
|
This script uses lib2to3 to automatically convert it to Python 3.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/fix_pyrf_python3.py
|
||||||
|
|
||||||
|
Run this after installing pyrf:
|
||||||
|
pip install ria-toolkit-oss[thinkrf]
|
||||||
|
python scripts/fix_pyrf_python3.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from lib2to3.refactor import RefactoringTool, get_fixers_from_package
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pyrf
|
||||||
|
except ImportError:
|
||||||
|
print("ERROR: pyrf is not installed.")
|
||||||
|
print("Install with: pip install pyrf")
|
||||||
|
print("Or install ria with ThinkRF support: pip install ria-toolkit-oss[thinkrf]")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Find the thinkrf.py file in the pyrf package
|
||||||
|
thinkrf_path = Path(pyrf.__file__).resolve().parent / "devices" / "thinkrf.py"
|
||||||
|
|
||||||
|
if not thinkrf_path.exists():
|
||||||
|
print(f"ERROR: Could not find {thinkrf_path}")
|
||||||
|
print("Is pyrf installed correctly?")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print(f"Found pyrf ThinkRF module at: {thinkrf_path}")
|
||||||
|
|
||||||
|
# Apply lib2to3 fixes
|
||||||
|
print("Applying Python 3 compatibility fixes...")
|
||||||
|
fixers = get_fixers_from_package('lib2to3.fixes')
|
||||||
|
tool = RefactoringTool(fixers)
|
||||||
|
tool.refactor_file(str(thinkrf_path), write=True)
|
||||||
|
|
||||||
|
print(f"✅ Successfully patched {thinkrf_path} for Python 3 compatibility.")
|
||||||
|
print("\nYou can now use ria_toolkit_oss.sdr.thinkrf.ThinkRF")
|
||||||
291
src/ria_toolkit_oss/sdr/thinkrf.py
Normal file
291
src/ria_toolkit_oss/sdr/thinkrf.py
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
"""ThinkRF integration for the RIA toolkit."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyrf.devices.thinkrf import WSA
|
||||||
|
except ImportError as exc: # pragma: no cover - optional dependency
|
||||||
|
raise ImportError(
|
||||||
|
"pyrf is required to use the ThinkRF integration. "
|
||||||
|
"Install with: pip install ria-toolkit-oss[thinkrf]"
|
||||||
|
) from exc
|
||||||
|
except SyntaxError as exc: # pragma: no cover - Python 2/3 compatibility issue
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# pyrf ships with Python 2 syntax - try to auto-fix it
|
||||||
|
print("\033[93mWARNING: pyrf has Python 2 syntax. Attempting automatic fix...\033[0m")
|
||||||
|
try:
|
||||||
|
from lib2to3.refactor import RefactoringTool, get_fixers_from_package
|
||||||
|
import pyrf
|
||||||
|
|
||||||
|
thinkrf_path = Path(pyrf.__file__).resolve().parent / "devices" / "thinkrf.py"
|
||||||
|
print(f"Fixing: {thinkrf_path}")
|
||||||
|
|
||||||
|
fixers = get_fixers_from_package('lib2to3.fixes')
|
||||||
|
tool = RefactoringTool(fixers)
|
||||||
|
tool.refactor_file(str(thinkrf_path), write=True)
|
||||||
|
|
||||||
|
print("\033[92m✅ Fixed pyrf for Python 3. Please restart Python/reload the module.\033[0m")
|
||||||
|
print("Or run: python -m ria_toolkit_oss.sdr.thinkrf_fix")
|
||||||
|
sys.exit(1) # Exit so user can reload
|
||||||
|
except Exception as fix_exc:
|
||||||
|
print(f"\033[91m❌ Auto-fix failed: {fix_exc}\033[0m")
|
||||||
|
print("Manual fix: Run `python scripts/fix_pyrf_python3.py` from ria-toolkit-oss directory")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
from ria_toolkit_oss.sdr.sdr import SDR
|
||||||
|
|
||||||
|
|
||||||
|
class ThinkRF(SDR):
|
||||||
|
"""SDR adapter for ThinkRF analyzers using the PyRF API."""
|
||||||
|
|
||||||
|
BASE_SAMPLE_RATE = 125_000_000
|
||||||
|
SUPPORTED_DECIMATIONS = (1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024)
|
||||||
|
|
||||||
|
def __init__(self, identifier: Optional[str] = None):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
if identifier is None:
|
||||||
|
raise ValueError("ThinkRF requires an IP address or hostname identifier")
|
||||||
|
|
||||||
|
self.identifier = identifier
|
||||||
|
try:
|
||||||
|
self.radio = WSA()
|
||||||
|
self.radio.connect(identifier)
|
||||||
|
self.radio.request_read_perm()
|
||||||
|
print(f"Connected to ThinkRF at [{identifier}].")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to connect to ThinkRF at [{identifier}].")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
self.configure_frontend()
|
||||||
|
self._last_context: Optional[Any] = None
|
||||||
|
|
||||||
|
def configure_frontend(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
rfe_mode: str = "ZIF",
|
||||||
|
attenuation: int = 0,
|
||||||
|
gain_profile: str = "HIGH",
|
||||||
|
trigger_config: Optional[Dict[str, Any]] = None,
|
||||||
|
samples_per_packet: int = 65504,
|
||||||
|
packets_per_block: int = 1,
|
||||||
|
capture_mode: str = "block",
|
||||||
|
stream_id: int = 1,
|
||||||
|
min_stream_decimation: int = 16,
|
||||||
|
) -> None:
|
||||||
|
"""Persist settings applied during the next RX initialisation.
|
||||||
|
|
||||||
|
``capture_mode`` selects between buffered ``"block"`` captures that use
|
||||||
|
the analyser's onboard RAM and ``"stream"`` captures that push data over
|
||||||
|
GigE in real time. Streaming requires a sufficiently large decimation to
|
||||||
|
keep within the link budget; ``min_stream_decimation`` forms the lower
|
||||||
|
bound.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mode = capture_mode.lower()
|
||||||
|
if mode not in {"block", "stream"}:
|
||||||
|
raise ValueError("capture_mode must be either 'block' or 'stream'")
|
||||||
|
|
||||||
|
self._rfe_mode = rfe_mode
|
||||||
|
self._attenuation = int(max(0, min(attenuation, 30)))
|
||||||
|
self._gain_profile = gain_profile.upper()
|
||||||
|
self._trigger_config = trigger_config
|
||||||
|
self._samples_per_packet = int(samples_per_packet)
|
||||||
|
self._packets_per_block = max(1, int(packets_per_block))
|
||||||
|
self._capture_mode = mode
|
||||||
|
self._stream_id = int(stream_id)
|
||||||
|
self._min_stream_decimation = max(1, int(min_stream_decimation))
|
||||||
|
self._streaming_active = False
|
||||||
|
|
||||||
|
def init_rx(
|
||||||
|
self,
|
||||||
|
sample_rate: int | float,
|
||||||
|
center_frequency: int | float,
|
||||||
|
gain: int,
|
||||||
|
channel: int,
|
||||||
|
gain_mode: Optional[str] = "absolute",
|
||||||
|
):
|
||||||
|
if channel not in (0, None):
|
||||||
|
raise ValueError("ThinkRF devices expose a single receive channel")
|
||||||
|
|
||||||
|
stream_mode = getattr(self, "_capture_mode", "block") == "stream"
|
||||||
|
|
||||||
|
decimation = self._derive_decimation(sample_rate)
|
||||||
|
if stream_mode and decimation < self._min_stream_decimation:
|
||||||
|
enforced = self._min_stream_decimation
|
||||||
|
print(
|
||||||
|
"Requested ThinkRF sample rate exceeds typical GigE throughput; "
|
||||||
|
f"enforcing decimation {enforced} for streaming."
|
||||||
|
)
|
||||||
|
decimation = enforced
|
||||||
|
actual_sample_rate = self.BASE_SAMPLE_RATE / decimation
|
||||||
|
self._decimation = decimation
|
||||||
|
|
||||||
|
self.radio.reset()
|
||||||
|
self.radio.scpiset(":SYSTEM:FLUSH")
|
||||||
|
try:
|
||||||
|
self.radio.scpiset(":TRACE:STREAM:STOP")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.radio.rfe_mode(self._rfe_mode)
|
||||||
|
self.radio.freq(int(center_frequency))
|
||||||
|
attenuation = self._attenuation if gain is None else int(gain)
|
||||||
|
attenuation = max(0, min(attenuation, 30))
|
||||||
|
self.radio.attenuator(attenuation)
|
||||||
|
|
||||||
|
gain_profile = self._gain_profile
|
||||||
|
if gain_mode and isinstance(gain_mode, str) and gain_mode.upper() in {"LOW", "MEDIUM", "HIGH", "VLOW"}:
|
||||||
|
gain_profile = gain_mode.upper()
|
||||||
|
self.radio.psfm_gain(gain_profile)
|
||||||
|
|
||||||
|
self.radio.decimation(decimation)
|
||||||
|
if stream_mode:
|
||||||
|
self.radio.scpiset(f":SENSE:DECIMATION {decimation}")
|
||||||
|
trigger = self._trigger_config or self._default_trigger(center_frequency)
|
||||||
|
self.radio.trigger(trigger)
|
||||||
|
|
||||||
|
self.radio.scpiset(f":TRACE:SPP {self._samples_per_packet}")
|
||||||
|
if stream_mode:
|
||||||
|
self._streaming_active = False
|
||||||
|
else:
|
||||||
|
self.radio.scpiset(f":TRACE:BLOCK:PACKETS {self._packets_per_block}")
|
||||||
|
self.radio.scpiset(":TRACE:BLOCK:DATA?")
|
||||||
|
|
||||||
|
self.rx_sample_rate = actual_sample_rate
|
||||||
|
self.rx_center_frequency = center_frequency
|
||||||
|
self.rx_gain = {"attenuation_dB": attenuation, "profile": gain_profile}
|
||||||
|
self.rx_buffer_size = self._samples_per_packet
|
||||||
|
self.rx_channel = 0
|
||||||
|
|
||||||
|
self._rx_initialized = True
|
||||||
|
self._tx_initialized = False
|
||||||
|
|
||||||
|
def init_tx(
|
||||||
|
self,
|
||||||
|
sample_rate: int | float,
|
||||||
|
center_frequency: int | float,
|
||||||
|
gain: int,
|
||||||
|
channel: int,
|
||||||
|
gain_mode: Optional[str] = "absolute",
|
||||||
|
):
|
||||||
|
raise NotImplementedError("ThinkRF devices do not support transmit operations")
|
||||||
|
|
||||||
|
def _stream_rx(self, callback):
|
||||||
|
if not self._rx_initialized:
|
||||||
|
raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx() or record().")
|
||||||
|
|
||||||
|
print("ThinkRF Starting RX...")
|
||||||
|
self._enable_rx = True
|
||||||
|
packets_processed = 0
|
||||||
|
stream_mode = getattr(self, "_capture_mode", "block") == "stream"
|
||||||
|
|
||||||
|
if stream_mode and not self._streaming_active:
|
||||||
|
try:
|
||||||
|
self.radio.scpiset(f":TRACE:STREAM:START {self._stream_id}")
|
||||||
|
self._streaming_active = True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to start ThinkRF stream: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
while self._enable_rx:
|
||||||
|
try:
|
||||||
|
packet = self.radio.read()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ThinkRF read error: {exc}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if packet is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if packet.is_context_packet():
|
||||||
|
self._last_context = packet
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not packet.is_data_packet():
|
||||||
|
continue
|
||||||
|
|
||||||
|
iq_data = np.asarray(packet.data, dtype=np.float32)
|
||||||
|
if iq_data.ndim != 2 or iq_data.shape[1] != 2:
|
||||||
|
print("Unexpected ThinkRF packet format; skipping packet")
|
||||||
|
continue
|
||||||
|
|
||||||
|
complex_buffer = (iq_data[:, 0] + 1j * iq_data[:, 1]).astype(np.complex64, copy=False)
|
||||||
|
|
||||||
|
metadata = None
|
||||||
|
if hasattr(packet, "fields"):
|
||||||
|
metadata = packet.fields
|
||||||
|
if metadata.get("sample_loss"):
|
||||||
|
print("\033[93mWarning: ThinkRF sample overflow detected\033[0m")
|
||||||
|
|
||||||
|
callback(buffer=complex_buffer, metadata=metadata)
|
||||||
|
|
||||||
|
if stream_mode:
|
||||||
|
packets_processed += 1
|
||||||
|
else:
|
||||||
|
packets_processed += 1
|
||||||
|
if packets_processed >= self._packets_per_block:
|
||||||
|
packets_processed = 0
|
||||||
|
if self._enable_rx:
|
||||||
|
self.radio.scpiset(":TRACE:BLOCK:DATA?")
|
||||||
|
|
||||||
|
print("ThinkRF RX Completed.")
|
||||||
|
if stream_mode and self._streaming_active:
|
||||||
|
try:
|
||||||
|
self.radio.scpiset(":TRACE:STREAM:STOP")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._streaming_active = False
|
||||||
|
|
||||||
|
self.radio.scpiset(":SYSTEM:FLUSH")
|
||||||
|
|
||||||
|
def _stream_tx(self, callback):
|
||||||
|
raise NotImplementedError("ThinkRF devices do not support transmit operations")
|
||||||
|
|
||||||
|
def set_clock_source(self, source):
|
||||||
|
raise NotImplementedError("ThinkRF clock configuration is not implemented")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.radio.scpiset(":TRACE:STREAM:STOP")
|
||||||
|
except Exception: # pragma: no cover - best effort cleanup
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.radio.scpiset(":SYSTEM:FLUSH")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.radio.disconnect()
|
||||||
|
finally:
|
||||||
|
self._enable_rx = False
|
||||||
|
self._enable_tx = False
|
||||||
|
print(f"Disconnected from ThinkRF at [{self.identifier}].")
|
||||||
|
|
||||||
|
def supports_bias_tee(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_bias_tee(self, enable: bool): # pragma: no cover - interface compliance
|
||||||
|
raise NotImplementedError("ThinkRF radios do not expose a controllable bias-tee")
|
||||||
|
|
||||||
|
def _derive_decimation(self, target_sample_rate: int | float) -> int:
|
||||||
|
if not target_sample_rate:
|
||||||
|
return 1
|
||||||
|
requested = float(target_sample_rate)
|
||||||
|
if requested >= self.BASE_SAMPLE_RATE:
|
||||||
|
return 1
|
||||||
|
desired = self.BASE_SAMPLE_RATE / requested
|
||||||
|
best = min(self.SUPPORTED_DECIMATIONS, key=lambda dec: abs(dec - desired))
|
||||||
|
return int(best)
|
||||||
|
|
||||||
|
def _default_trigger(self, center_frequency: int | float) -> Dict[str, Any]:
|
||||||
|
span = 40_000_000
|
||||||
|
half = span // 2
|
||||||
|
return {
|
||||||
|
"type": "NONE",
|
||||||
|
"fstart": int(center_frequency) - half,
|
||||||
|
"fstop": int(center_frequency) + half,
|
||||||
|
"amplitude": -100,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user