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