From 25e5a4c6a6db4e250452df886132db67a6528efa Mon Sep 17 00:00:00 2001 From: ash Date: Sun, 5 Oct 2025 22:16:46 -0400 Subject: [PATCH] thinkrf --- pyproject.toml | 2 + scripts/fix_pyrf_python3.py | 44 +++++ src/ria_toolkit_oss/sdr/thinkrf.py | 291 +++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 scripts/fix_pyrf_python3.py create mode 100644 src/ria_toolkit_oss/sdr/thinkrf.py diff --git a/pyproject.toml b/pyproject.toml index e5f848a..fdf3af3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,12 +54,14 @@ pluto = ["pyadi-iio>=0.0.14"] usrp = [] # Requires system UHD installation hackrf = ["pyhackrf>=0.2.0"] bladerf = [] # Requires system libbladerf installation +thinkrf = ["pyrf>=2.8.0"] # NOTE: Requires lib2to3 post-install fix (see docs/) # All SDR hardware support all-sdr = [ "pyrtlsdr>=0.2.9", "pyadi-iio>=0.0.14", "pyhackrf>=0.2.0", + "pyrf>=2.8.0", ] [tool.poetry] diff --git a/scripts/fix_pyrf_python3.py b/scripts/fix_pyrf_python3.py new file mode 100644 index 0000000..4ee761d --- /dev/null +++ b/scripts/fix_pyrf_python3.py @@ -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") diff --git a/src/ria_toolkit_oss/sdr/thinkrf.py b/src/ria_toolkit_oss/sdr/thinkrf.py new file mode 100644 index 0000000..3d50cf7 --- /dev/null +++ b/src/ria_toolkit_oss/sdr/thinkrf.py @@ -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, + }