An outstanding undergraduate researcher in my lab has developed RemoteRF, a platform that allows you to remotely interface with software-defined radios (SDRs) through the UCLA network. Put simply, a server is running in my lab that has physically connected to it multiple SDRs, each of which you may remotely access via Python through a simple API. To begin using the SDRs connected to RemoteRF, you must download a package via pip and then create an account through a terminal interface. After making an account, you can make a reservation for a particular SDR, allowing you (and only you) to access that SDR during your reservation period. Upon making a reservation, a token will be issued to you, which can then be inserted into your Python code, allowing you to remotely access the SDR as if it were physically connected to your local machine. Behind the scenes, all commands and data will be transferred between the you and the SDR over the UCLA network via the RemoteRF platform.

A functional block diagram of the RemoteRF platform.
A functional block diagram of the RemoteRF platform.

What is a Software-Defined Radio?

“Software-defined radio” is a term often used to describe a transceiver which can be highly reconfigured. Simply put, many of the transceiver’s parameters can be controlled and configured dynamically through software. SDRs are thus very convenient for hobbyists, researchers, and engineers, since one platform can be used flexibly for a wide range tasks. This is in contrast to application-specific transceivers, which are far less flexible yet often much more efficient and smaller in size than SDRs. Most SDRs have many tunable parameters, with perhaps the most relevant often being:

  • transmit carrier frequency
  • receive carrier frequency
  • transmit gain (i.e., transmit power)
  • receive gain
  • baseband sampling rate

It is perhaps easiest to think of an SDR as a transceiver onto which you can push complex baseband samples (via USB) for it to transmit and from which you can pull complex baseband samples that it received. As such, you can do all the fun baseband digital signal processing on your computer (often in Python or MATLAB), leaving the actual upconversion and downconversion (and other front-end functionalities) to the SDR. This is illustrated in the block diagram below, depicting the Pluto SDR (described next) interfaced with Python.

A simplified block diagram of the Pluto SDR interfaced with Python.
A simplified block diagram of the Pluto SDR interfaced with Python.

Which SDRs Are Connected to RemoteRF?

Currently, RemoteRF has connected to it five Pluto SDRs, manufactured by Analog Devices. These relatively affordable SDRs were designed to be simple, with education in mind. Each Pluto SDR is based on the AD936X series, a set of RF integrated circuits that are highly controllable through software. Out of the box, the Pluto can be tuned to operate at carrier frequencies ranging from 325 MHz to 3.8 GHz and can transmit and receive signals with up to 20 MHz of (effective) passband bandwidth. Each Pluto has 12-bit DACs and ADCs to generate and sample baseband signals. The Pluto has a transmitter and receiver, each with its own SMA port.

The Pluto SDR by Analog Devices.
The Pluto SDR by Analog Devices.

There are five Pluto SDRs currently connected to RemoteRF. Two of these five Plutos (Devices 0 and 1) are configured in a loopback fashion, where each Pluto’s transmit port is directly cabled into its own receive port (i.e., a loopback configuration). This will allow you to receive a nearly perfect copy of the transmitted signal, for the sake of familiarizing yourself with the Pluto and the implementation of any associated signal processing. The other three Plutos (Devices 2, 3, and 4) have antennas connected to their transmit and receive ports—i.e., an over-the-air (OTA) configuration. Given the transmit antenna and receive antenna are so close to one another, the received signal will also be a near-perfect copy of the transmitted signal but likely not quite as clean as that with a loopback. To summarize, the current configuration of each device connected to RemoteRF is as follows:

  • Device 0: Pluto SDR, loopback
  • Device 1: Pluto SDR, loopback
  • Device 2: Pluto SDR, over-the-air (OTA)
  • Device 3: Pluto SDR, over-the-air (OTA)
  • Device 4: Pluto SDR, over-the-air (OTA)

Getting Things Set Up

Network Connection

You need to be on the UCLA network for this to work. UCLA_WIFI has proven to work. eduroam has proven to work. UCLA_WEB does not appear to work. We have not yet tested wired connections across campus.

When not physically on campus, you may also use the UCLA VPN. stssl.vpn.ucla.edu has proven to work.

Python

You need Python 3.10 or newer. Only Python is currently supported.

For those using conda, you can create a new sdr environment with the following. Those not using conda may ignore this.

conda create -n sdr python=3.10 matplotlib

Package Installation

You need to install our custom package from pip using the following.

pip install remoteRF

Should any updates be pushed to this package, you can upgrade your installation with the following.

pip install remoteRF -U

Making an Account and Reserving an SDR

Making an Account

To begin using the RemoteRF platform and accessing one of its SDRs, you must first make an account with our reservation system. With the remoteRF package installed, simply run the following in your terminal.

remoterf-login

It will ask you to either enter l to login or r to register. When accessing the platform for the first time, enter r to register an account. This will produce an output similar to the following in your terminal, asking you to enter a username (must be at least five characters), a password, and your email. If you forget your password, you will need to create a new account, as there is no way for us to retrieve or reset it at this time.

Logging In to the RemoteRF Platform

Any time you access the RemoteRF platform after making your account, enter l to login. You will see output similar to the following, asking you to enter your username and password.

Upon successfully logging in, you should be greeted with a landing screen. Typing help or simply h will list all allowable commands, similar to that shown below.

Seeing Which Devices Are Online

To see which devices are currently connected to the RemoteRF platform and functional, simply type getdev. This will produce an output similar to below. In this particular case, five Pluto SDR devices are connected to the RemoteRF platform. Each device is identified by its index and name. Here, we have denoted which Plutos are loopback and which are over-the-air (OTA). Note that all devices are listed regardless of their reservation status.

User Permissions

By default, normal users can hold up to 3 active reservations, regardless of whether or not they are concurrent, and each reservation is 30 minutes long. This, along with other user permissions, can be viewed by typing perms, as shown below.

Reserving an SDR

With an account made, you now have the ability to reserve an SDR. To do so, simply type resdev, after which you will be asked which device you want to reserve. Enter its index, e.g., 0 for Device 0. You will then be asked to enter the number of days (including today) to list when offering you options for reservations. For example, as shown in the output below, upon inputting resdev at around 9:45 PM on February 25, 2025, the following 30-minute-long options were listed in chronological order, with start and stop times shown. Note that, as of writing this, reservations are 30 minutes long but may have since been changed.

Upon entering which time slot you wish to reserve (e.g., input 1 if you wish to reserve time slot 1), you will be asked to confirm your selection. To do so, enter y. To abort this reservation, enter n.

After confirming your reservation, a unique token will be issued to you. For instance, in the above output, the token was ajBMv80jMog. This token is all that will be necessary when actually accessing the RemoteRF platform through Python. Be sure to copy the token (avoid Ctrl+C!) and keep it safe, as it is not saved on the server’s side and cannot be regenerated or retrieved if lost.

Canceling a Reservation

If you wish to cancel a reservation that you made (which is useful if you happen lose one of the issued tokens), you can use the command cancelres. As illustrated below using the myres command to view active reservations, canceling a reservation amounts to simply indicating which of the listed reservations you wish to cancel. Here, initially one reservation was held and then none after canceling the lone reservation.

Using the RemoteRF Platform

Now that we have walked through how to make a reservation and obtain permission (via the issued token) to access a particular SDR at a particular time, let us now go through the necessary steps to actually interface with that SDR via the RemoteRF platform.

Two Line Changes — That’s It!

In creating the RemoteRF platform, our goal was to make it as seamless as possible to convert a traditional Python script that was written for locally interfacing with a Pluto SDR (via USB) into one which can be used to remotely interface with one via the RemoteRF platform. In this vein, we have made it such that using the RemoteRF platform requires only two line changes, compared to a traditional Python script.

To illustrate this, below shows what a traditional Python script starts off with in order to connect to a Pluto SDR which is connected directly to one’s computer via USB.

# for locally interfacing with Pluto via its IP address
import adi
sdr = adi.Pluto("ip:192.168.2.1")

In contrast, these two lines should be updated to the following when remotely interfacing with a Pluto via RemoteRF. Notice that the token issued upon making a reservation should been inserted when calling adi.Pluto(). This token is used by the RemoteRF platform to validate your reservation and to route subsequent interfacing to the correct Pluto SDR corresponding to your reservation.

# for remotely interfacing with Pluto
from remoteRF.drivers.adalm_pluto import *
sdr = adi.Pluto(token='ajBMv80jMog') # token issued when making a reservation

After these two lines, the rest of your Python script would follow exactly as if you were connected locally to a Pluto SDR via USB. Let us thus walk through creating your first Pluto script in Python.

Your First Script using RemoteRF

You should save and then run the below Python script in a new terminal window/tab via

python main_my_first_remoterf_script.py

If that doesn’t work, try the following instead.

python3 main_my_first_remoterf_script.py

The below Python script serves as a useful example demonstrating how to setup Pluto and then transmit and receive signals with it via the RemoteRF platform. In this example, a complex sinusoid at 100 kHz is generated in Python and then sent to Pluto to transmit. The complex sinusoid is upconverted to a carrier frequency of 915 MHz and then transmitted out of the transmit port of Pluto. Since the transmit buffer of Pluto is set to be cyclic in this case (tx_cyclic_buffer = True), the Pluto will continue transmitting indefinitely. The command sdr.rx() then fetches complex baseband samples received by Pluto. The FFT of these received samples are then plotted, showing a peak at 100 kHz as we would hope, as we are simply receiving the signal we transmitted.

Note that the two lines described in the previous step—which import our custom library and initialize an sdr object—have already been included in the code below. You should not need to change any of the code below except for the token you received upon making a reservation.

import numpy as np
import matplotlib.pyplot as plt

# for remotely interfacing with Pluto
from remoteRF.drivers.adalm_pluto import *

# ---------------------------------------------------------------
# Digital communication system parameters.
# ---------------------------------------------------------------
fs = 1e6     # baseband sampling rate (samples per second)
ts = 1 / fs  # baseband sampling period (seconds per sample)
sps = 10     # samples per data symbol
T = ts * sps # time between data symbols (seconds per symbol)

# ---------------------------------------------------------------
# Pluto system parameters.
# ---------------------------------------------------------------
sample_rate = fs                # sampling rate, between ~600e3 and 61e6
tx_carrier_freq_Hz = 915e6      # transmit carrier frequency, between 325 MHz to 3.8 GHz
rx_carrier_freq_Hz = 915e6      # receive carrier frequency, between 325 MHz to 3.8 GHz
tx_rf_bw_Hz = sample_rate * 1   # transmitter's RF bandwidth, between 200 kHz and 56 MHz
rx_rf_bw_Hz = sample_rate * 1   # receiver's RF bandwidth, between 200 kHz and 56 MHz
tx_gain_dB = -25                # transmit gain (in dB), beteween -89.75 to 0 dB with a resolution of 0.25 dB
rx_gain_dB = 40                 # receive gain (in dB), beteween 0 to 74.5 dB (only set if AGC is 'manual')
rx_agc_mode = 'manual'          # receiver's AGC mode: 'manual', 'slow_attack', or 'fast_attack'
rx_buffer_size = 100e3          # receiver's buffer size (in samples), length of data returned by sdr.rx()
tx_cyclic_buffer = True         # cyclic nature of transmitter's buffer (True -> continuously repeat transmission)

# ---------------------------------------------------------------
# Initialize Pluto object using issued token.
# ---------------------------------------------------------------
sdr = adi.Pluto(token='ajBMv80jMog') # create Pluto object
sdr.sample_rate = int(sample_rate)   # set baseband sampling rate of Pluto

# ---------------------------------------------------------------
# Setup Pluto's transmitter.
# ---------------------------------------------------------------
sdr.tx_destroy_buffer()                   # reset transmit data buffer to be safe
sdr.tx_rf_bandwidth = int(tx_rf_bw_Hz)    # set transmitter RF bandwidth
sdr.tx_lo = int(tx_carrier_freq_Hz)       # set carrier frequency for transmission
sdr.tx_hardwaregain_chan0 = tx_gain_dB    # set the transmit gain
sdr.tx_cyclic_buffer = tx_cyclic_buffer   # set the cyclic nature of the transmit buffer

# ---------------------------------------------------------------
# Setup Pluto's receiver.
# ---------------------------------------------------------------
sdr.rx_destroy_buffer()                   # reset receive data buffer to be safe
sdr.rx_lo = int(rx_carrier_freq_Hz)       # set carrier frequency for reception
sdr.rx_rf_bandwidth = int(sample_rate)    # set receiver RF bandwidth
sdr.rx_buffer_size = int(rx_buffer_size)  # set buffer size of receiver
sdr.gain_control_mode_chan0 = rx_agc_mode # set gain control mode
sdr.rx_hardwaregain_chan0 = rx_gain_dB    # set gain of receiver

# ---------------------------------------------------------------
# Create transmit signal.
# ---------------------------------------------------------------
N = 10000 # number of samples to transmit
t = np.arange(N) / sample_rate # time vector
tx_signal = 0.5*np.exp(2.0j*np.pi*100e3*t) # complex sinusoid at 100 kHz

# ---------------------------------------------------------------
# Transmit from Pluto!
# ---------------------------------------------------------------
tx_signal_scaled = tx_signal / np.max(np.abs(tx_signal)) * 2**14 # Pluto expects TX samples to be between -2^14 and 2^14 
sdr.tx(tx_signal_scaled) # will continuously transmit when cyclic buffer set to True

# ---------------------------------------------------------------
# Receive with Pluto!
# ---------------------------------------------------------------
sdr.rx_destroy_buffer() # reset receive data buffer to be safe
for i in range(1): # clear buffer to be safe
    rx_data_ = sdr.rx() # toss them out
    
rx_signal = sdr.rx() # capture raw samples from Pluto

# ---------------------------------------------------------------
# Take FFT of received signal.
# ---------------------------------------------------------------
rx_fft = np.abs(np.fft.fftshift(np.fft.fft(rx_signal)))
f = np.linspace(sample_rate/-2, sample_rate/2, len(rx_fft))

plt.figure()
plt.plot(f/1e3,rx_fft,color="black")
plt.xlabel("Frequency (kHz)")
plt.ylabel("Magnitude")
plt.title('FFT of Received Signal')
plt.grid(True)
plt.show()

Sample output upon running the above script is shown below. A peak is visible at 100 kHz, indicative of the transmitted complex exponential with frequency 100 kHz.

The FFT output by the script above.
The FFT output by the script above.

A Few Important Notes Regarding Pluto

Use Complex Signals

Pluto expects all baseband signals to be complex and will actually malfunction if this is not the case. As such, you will run into problems if you try to transmit and receive one-dimensional signals, like BPSK signals, M-PAM signals, or even real-valued sinusoids. Thus, I would advise you to at the very least transmit either QPSK signals or complex sinusoids.

Scaling Your Transmit Signal

As mentioned in the comments of the example code above, Pluto expects the real and imaginary components of transmitted signals to range from -2^14 to +2^14. As such, you should scale your transmit signal to occupy these bounds. Even if your signal is well within these bounds, the full range of the DAC will not be used and your transmitted signal will be not be full power unless you scale the signal up to occupy the entire range. I advise you to use these two lines when transmitting, where tx_signal is your signal before scaling.

tx_signal_scaled = tx_signal / np.max(np.abs(tx_signal)) * 2**14 
sdr.tx(tx_signal_scaled)

Scale of Receive Signals

Received samples from Pluto will range from -2048 to 2048. If you see your signals clipping around +/-2048, you are saturating your receiver’s ADC. In other words, the signal entering the ADC is too strong for the ADC input range. You should decrease your receive gain and/or transmit gain.

Receive Gain Control

The Pluto has three options for receive gain control: manual, slow_attack, and fast_attack. The latter two are forms of automatic gain control (AGC), which automatically adjusts the gain of the receiver’s front-end based on the strength of the received signal. With manual, the gain of the receiver is fixed according to sdr.rx_hardwaregain_chan0 = rx_gain_dB. By default, I would set it to manual and find a good receive gain level that is neither too low nor too high.

Flushing the Receive Buffer

It is not yet clear to me if this always absolutely necessary, but it seems to help to flush the receive buffer before receiving a desired signal. To do this, I destroy the receive buffer with sdr.rx_destroy_buffer() and then call sdr.rx() at least once before receiving the desired signal.

sdr.rx_destroy_buffer() # reset receive data buffer to be safe
for i in range(1): # clear buffer to be safe
    rx_data_ = sdr.rx() # toss them out

Detailed Block Diagram of Pluto’s RFIC

A detailed block diagram of the AD9361 RF integrated circuit, used in Pluto, is shown below.

A detailed functional block diagram of the AD9361 integrated circuit.
A detailed functional block diagram of the AD9361 integrated circuit.