st_edits #6

Merged
madrigal merged 14 commits from st_edits into main 2025-10-24 10:21:12 -04:00
3 changed files with 337 additions and 0 deletions
Showing only changes of commit 25e5a4c6a6 - Show all commits

View File

@ -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]

View 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")

View 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,
}