292 lines
10 KiB
Python
292 lines
10 KiB
Python
"""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,
|
|
}
|