ria-toolkit-oss/src/ria_toolkit_oss/sdr/pluto.py

503 lines
19 KiB
Python
Raw Normal View History

2025-09-12 11:32:49 -04:00
import threading
import time
import traceback
import warnings
from typing import Optional
import adi
import numpy as np
from ria_toolkit_oss.datatypes.recording import Recording
from ria_toolkit_oss.sdr.sdr import SDR
class Pluto(SDR):
def __init__(self, identifier=None):
"""
Initialize a Pluto SDR device object and connect to the SDR hardware.
This software supports the ADALAM Pluto SDR created by Analog Devices.
:param identifier: The value of the parameter that identifies the device.
:type identifier: str = "192.168.3.1", "pluto.local", etc
If no identifier is provided, it will select the first device found, with a warning.
If more than one device is found with the identifier, it will select the first of those devices.
"""
print(f"Initializing Pluto radio with identifier [{identifier}].")
try:
super().__init__()
if identifier is None:
uri = "ip:pluto.local"
else:
uri = f"ip:{identifier}"
self.radio = adi.ad9361(uri)
print(f"Successfully found Pluto radio with identifier [{identifier}].")
except Exception as e:
print(f"Failed to find Pluto radio with identifier [{identifier}].")
raise e
def init_rx(
self,
sample_rate: int | float,
center_frequency: int | float,
gain: int,
channel: int,
gain_mode: Optional[str] = "absolute",
):
"""
Initializes the Pluto for receiving.
:param sample_rate: The sample rate for receiving.
:type sample_rate: int or float
:param center_frequency: The center frequency of the recording.
:type center_frequency: int or float
:param gain: The gain set for receiving on the Pluto
:type gain: int
:param channel: The channel the Pluto is set to. Must be 0 or 1. 0 enables channel 1, 1 enables both channels.
:type channel: int
:param buffer_size: The buffer size during receive. Defaults to 10000.
:type buffer_size: int
"""
print("Initializing RX")
self.set_rx_sample_rate(sample_rate=int(sample_rate))
print(f"Pluto sample rate = {self.radio.sample_rate}")
self.set_rx_center_frequency(center_frequency=int(center_frequency))
print(f"Pluto center frequency = {self.radio.rx_lo}")
if channel == 0:
self.radio.rx_enabled_channels = [0]
print(f"Pluto channel(s) = {self.radio.rx_enabled_channels}")
elif channel == 1:
self.radio.rx_enabled_channels = [0, 1]
print(f"Pluto channel(s) = {self.radio.rx_enabled_channels}")
else:
raise ValueError("Channel must be either 0 or 1.")
rx_gain_min = 0
rx_gain_max = 74
if gain_mode == "relative":
if gain > 0:
raise ValueError(
"When gain_mode = 'relative', gain must be < 0. This sets \
the gain relative to the maximum possible gain."
)
else:
abs_gain = rx_gain_max + gain
else:
abs_gain = gain
if abs_gain < rx_gain_min or abs_gain > rx_gain_max:
abs_gain = min(max(gain, rx_gain_min), rx_gain_max)
print(f"Gain {gain} out of range for Pluto.")
print(f"Gain range: {rx_gain_min} to {rx_gain_max} dB")
self.set_rx_gain(gain=abs_gain, channel=channel)
if channel == 0:
print(f"Pluto gain = {self.radio.rx_hardwaregain_chan0}")
elif channel == 1:
self.set_tx_gain(gain=abs_gain, channel=0)
print(f"Pluto gain = {self.radio.rx_hardwaregain_chan0}, {self.radio.rx_hardwaregain_chan1}")
self.radio.rx_buffer_size = 1024 # TODO deal with this for zmq
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",
):
"""
Initializes the Pluto for transmitting. Will transmit garbage during
center frequency tuning and setting the sample rate.
:param sample_rate: The sample rate for transmitting.
:type sample_rate: int or float
:param center_frequency: The center frequency of the recording.
:type center_frequency: int or float
:param gain: The gain set for transmitting on the Pluto
:type gain: int
:param channel: The channel the Pluto is set to. Must be 0 or 1. 0 enables channel 1, 1 enables both channels.
:type channel: int
:param buffer_size: The buffer size during transmit. Defaults to 10000.
:type buffer_size: int
"""
print("Initializing TX")
self.set_tx_sample_rate(sample_rate=int(sample_rate))
print(f"Pluto sample rate = {self.radio.sample_rate}")
self.set_tx_center_frequency(center_frequency=int(center_frequency))
print(f"Pluto center frequency = {self.radio.tx_lo}")
if channel == 1:
self.radio.tx_enabled_channels = [0, 1]
print(f"Pluto channel(s) = {self.radio.tx_enabled_channels}")
elif channel == 0:
self.radio.tx_enabled_channels = [0]
print(f"Pluto channel(s) = {self.radio.tx_enabled_channels}")
else:
raise ValueError("Channel must be either 0 or 1.")
tx_gain_min = -89
tx_gain_max = 0
if gain_mode == "relative":
if gain > 0:
raise ValueError(
"When gain_mode = 'relative', gain must be < 0. This sets\
the gain relative to the maximum possible gain."
)
else:
abs_gain = tx_gain_max + gain
else:
abs_gain = gain
if abs_gain < tx_gain_min or abs_gain > tx_gain_max:
abs_gain = min(max(gain, tx_gain_min), tx_gain_max)
print(f"Gain {gain} out of range for Pluto.")
print(f"Gain range: {tx_gain_min} to {tx_gain_max} dB")
self.set_tx_gain(gain=abs_gain, channel=channel)
if channel == 0:
print(f"Pluto gain = {self.radio.tx_hardwaregain_chan0}")
elif channel == 1:
self.set_tx_gain(gain=abs_gain, channel=0)
print(f"Pluto gain = {self.radio.tx_hardwaregain_chan0}, {self.radio.tx_hardwaregain_chan1}")
self._tx_initialized = True
self._rx_initialized = False
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("Starting rx...")
self._enable_rx = True
while self._enable_rx is True:
signal = self.radio.rx()
signal = self._convert_rx_samples(signal)
# send callback complex signal
callback(buffer=signal, metadata=None)
def record(self, num_samples: Optional[int] = None, rx_time: Optional[int | float] = None):
"""
Create a radio recording (iq samples and metadata) of a given length from the SDR.
Either num_samples or rx_time must be provided.
init_rx() must be called before record()
:param num_samples: The number of samples to record. Pluto max = 16M.
:type num_samples: int, optional
:param rx_time: The time to record.
:type rx_time: int or float, optional
returns: Recording object (iq samples and metadata)
"""
if not self._rx_initialized:
raise RuntimeError("RX was not initialized. init_rx() must be called before _stream_rx() or record()")
if num_samples is not None and rx_time is not None:
raise ValueError("Only input one of num_samples or rx_time")
elif num_samples is not None:
self._num_samples_to_record = num_samples
elif rx_time is not None:
self._num_samples_to_record = int(rx_time * self.rx_sample_rate)
else:
raise ValueError("Must provide input of one of num_samples or rx_time")
if self._num_samples_to_record > 16000000:
raise NotImplementedError("Pluto record for num_samples>16M not implemented yet.")
self.radio.rx_buffer_size = self._num_samples_to_record
print("Pluto Starting RX...")
samples = self.radio.rx()
if self.radio.tx_enabled_channels == [0]:
samples = self._convert_rx_samples(samples)
samples = [samples]
else:
channel1 = self._convert_rx_samples(samples[0])
channel2 = self._convert_rx_samples(samples[1])
samples = [channel1, channel2]
print("Pluto RX Completed.")
metadata = {
"source": self.__class__.__name__,
"sample_rate": self.rx_sample_rate,
"center_frequency": self.rx_center_frequency,
"gain": self.rx_gain,
}
recording = Recording(data=samples, metadata=metadata)
return recording
def _format_tx_data(self, recording: Recording | np.ndarray | list):
if isinstance(recording, np.ndarray):
data = [self._convert_tx_samples(samples=recording)]
elif isinstance(recording, Recording):
if self.radio.tx_enabled_channels == [0]:
samples = recording.data[0]
data = [self._convert_tx_samples(samples=samples)]
if len(recording.data) > 1:
warnings.warn("Recording object is multichannel, only channel 0 data was used for transmission")
else:
if len(recording.data) == 1:
warnings.warn(
"Recording has only 1 channel, the same data will be transmitted over both Pluto channels"
)
samples = recording.data[0]
data = [self._convert_tx_samples(samples), self._convert_tx_samples(samples)]
else:
if len(recording) > 2:
warnings.warn(
"More recordings were provided than channels in the Pluto. \
Only the first two recordings will be used"
)
sample0 = self._convert_tx_samples(recording.data[0])
sample1 = self._convert_tx_samples(recording.data[1])
data = [sample0, sample1]
elif isinstance(recording, list):
if len(recording) > 2:
warnings.warn(
"More recordings were provided than channels in the Pluto. \
Only the first two recordings will be used"
)
if isinstance(recording[0], np.ndarray):
data = [self._convert_tx_samples(recording[0]), self._convert_tx_samples(recording[1])]
elif isinstance(recording[0], Recording):
sample0 = self._convert_tx_samples(recording[0].data[0])
sample1 = self._convert_tx_samples(recording[1].data[0])
data = [sample0, sample1]
return data
def _timeout_cyclic_buffer(self, timeout):
time.sleep(timeout)
self.radio.tx_destroy_buffer()
self.radio.tx_cyclic_buffer = False
print("Pluto TX Completed.")
def interrupt_transmit(self):
self.radio.tx_destroy_buffer()
self.radio.tx_cyclic_buffer = False
print("Pluto TX Completed.")
def close(self):
if self.radio.tx_cyclic_buffer:
self.radio.tx_destroy_buffer()
del self.radio
def tx_recording(self, recording: Recording | np.ndarray | list, num_samples=None, tx_time=None, mode="timed"):
"""
Transmit the given iq samples from the provided recording.
init_tx() must be called before this function.
:param recording: The recording(s) to transmit.
:type recording: Recording, np.ndarray, list[Recording, np.ndarray]
:param num_samples: The number of samples to transmit, will repeat or
truncate the recording to this length. Defaults to None.
:type num_samples: int, optional
:param tx_time: The time to transmit, will repeat or truncate the
recording to this length. Defaults to None.
:type tx_time: int or float, optional
:param mode: The mode of transmission, either timed or continuous. Defaults to timed.
:type mode: str, optional
"""
if num_samples is not None and tx_time is not None:
raise ValueError("Only input one of num_samples or tx_time")
elif num_samples is not None:
tx_time = num_samples / self.tx_sample_rate
elif tx_time is not None:
pass
else:
tx_time = len(recording) / self.tx_sample_rate
data = self._format_tx_data(recording=recording)
try:
if self.radio.tx_cyclic_buffer:
print("Destroying existing TX buffer...")
self.radio.tx_destroy_buffer()
self.radio.tx_cyclic_buffer = False
except Exception as e:
print(f"Error while destroying TX buffer: {e}")
self.radio.tx_cyclic_buffer = True
print("Pluto Starting TX...")
self.radio.tx(data_np=data)
if mode == "timed":
timeout_thread = threading.Thread(target=self._timeout_cyclic_buffer, args=([tx_time]))
timeout_thread.start()
timeout_thread.join()
def _stream_tx(self, callback):
if self._tx_initialized is False:
raise RuntimeError("TX was not initialized, init_tx must be called before _stream_tx")
num_samples = 10000
# TODO remove hardcode
self._enable_tx = True
while self._enable_tx is True:
buffer = self._convert_tx_samples(callback(num_samples))
self.radio.tx(buffer[0])
def set_rx_center_frequency(self, center_frequency):
try:
self.radio.rx_lo = int(center_frequency)
self.rx_center_frequency = center_frequency
except OSError as e:
_handle_OSError(e)
except ValueError as e:
_handle_OSError(e)
def set_rx_sample_rate(self, sample_rate):
self.rx_sample_rate = sample_rate
# TODO add logic for limiting sample rate
try:
self.radio.sample_rate = int(sample_rate)
# set the front end filter width
self.radio.rx_rf_bandwidth = int(sample_rate)
except OSError as e:
_handle_OSError(e)
except ValueError as e:
_handle_OSError(e)
def set_rx_gain(self, gain, channel=0):
self.rx_gain = gain
try:
if channel == 0:
if gain is None:
self.radio.gain_control_mode_chan0 = "automatic"
print("Using Pluto Automatic Gain Control.")
else:
self.radio.gain_control_mode_chan0 = "manual"
self.radio.rx_hardwaregain_chan0 = gain # dB
elif channel == 1:
try:
if gain is None:
self.radio.gain_control_mode_chan1 = "automatic"
print("Using Pluto Automatic Gain Control.")
else:
self.radio.gain_control_mode_chan1 = "manual"
self.radio.rx_hardwaregain_chan1 = gain # dB
except Exception as e:
print("Failed to use channel 1 on the PlutoSDR. \nThis is only available for revC versions.")
raise e
else:
raise ValueError(f"Pluto channel must be 0 or 1 but was {channel}.")
except OSError as e:
_handle_OSError(e)
except ValueError as e:
_handle_OSError(e)
def set_rx_channel(self, channel):
self.rx_channel = channel
def set_rx_buffer_size(self, buffer_size):
raise NotImplementedError
def set_tx_center_frequency(self, center_frequency):
try:
self.radio.tx_lo = int(center_frequency)
self.tx_center_frequency = center_frequency
except OSError as e:
_handle_OSError(e)
except ValueError as e:
_handle_OSError(e)
def set_tx_sample_rate(self, sample_rate):
try:
self.radio.sample_rate = sample_rate
self.tx_sample_rate = sample_rate
except OSError as e:
_handle_OSError(e)
except ValueError as e:
_handle_OSError(e)
def set_tx_gain(self, gain, channel=0):
try:
self.tx_gain = gain
if channel == 0:
self.radio.tx_hardwaregain_chan0 = int(gain)
elif channel == 1:
self.radio.tx_hardwaregain_chan1 = int(gain)
else:
raise ValueError(f"Pluto channel must be 0 or 1 but was {channel}.")
except OSError as e:
_handle_OSError(e)
except ValueError as e:
_handle_OSError(e)
def set_tx_channel(self, channel):
raise NotImplementedError
def set_tx_buffer_size(self, buffer_size):
raise NotImplementedError
def shutdown(self):
del self.radio
def _convert_rx_samples(self, samples):
return samples / (2**11)
def _convert_tx_samples(self, samples):
return samples.astype(np.complex64) * (2**14)
def set_clock_source(self, source):
raise NotImplementedError
def _handle_OSError(e):
# process a common difficult to read error message into a more intuitive format
print("PlutoSDR valid arguments:")
print("Standard: ")
print("\tCenter frequency: 325-3800Mhz")
print("\tSample rate: 521kHz-20Mhz")
print("\tGain: -90-0")
print("Hacked:")
print("\tCenter frequency: 70-6000Mhz")
print("\tSample rate: 521kHz-56Mhz")
print("\tGain: -90-0")
stack_trace = traceback.format_exc()
print(stack_trace)
if "sampling_frequency" in stack_trace or "sample rates" in stack_trace:
raise ValueError("The sample rate was out of range for the Pluto SDR.\n")
if "tx_lo" in stack_trace or "rx_lo" in stack_trace:
raise ValueError("The center frequency was out of range for the Pluto SDR.\n")
if "hardwaregain" in stack_trace:
raise ValueError("The gain was out of range for the Pluto SDR.\n")