Network tuner support - rtl_tcp (#2471)

* Add network tuner dialog

* wip: network tuner configs

* wip stash

* working tcp solution

* fix copyright headers

* fix: resolve rebase conflicts from jRTL library changeover

- Drop redundant JFrequencyControl digit-sizing fix now that upstream
  fixes the same issue via setColumns(1)
- Mark SUPPORTED_NETWORK_TUNERS final to match the new EnumSet style
- Replace removed SampleRate.getClosest() with a local nearest-match
  helper in NetworkTunerEditor

* Add mise config
This commit is contained in:
Jared Szechy
2026-11-15 03:32:02 -05:00
committed by GitHub
parent 2db41393dc
commit 9deabd0dcc
17 changed files with 1467 additions and 20 deletions

2
mise.toml Normal file
View File

@@ -0,0 +1,2 @@
[tools]
java = "liberica-javafx-27"

View File

@@ -57,10 +57,11 @@ public class ByteNativeBuffer extends AbstractNativeBuffer
public ByteNativeBuffer(byte[] samples, long timestamp, float averageDc, float samplesPerMillisecond)
{
super(timestamp, samplesPerMillisecond);
//Ensure we're an even multiple of the fragment size. Typically, this will be 64k or 128k
if(samples.length % FRAGMENT_SIZE != 0)
//Ensure we're an even multiple of the fragment size in bytes (2 bytes per I/Q sample). Typically, this
//will be 64k or 128k
if(samples.length % (FRAGMENT_SIZE * 2) != 0)
{
throw new IllegalArgumentException("Samples byte[] length [" + samples.length + "] must be an even multiple of " + FRAGMENT_SIZE);
throw new IllegalArgumentException("Samples byte[] length [" + samples.length + "] must be an even multiple of " + (FRAGMENT_SIZE * 2));
}
mSamples = samples;

View File

@@ -37,6 +37,7 @@ public enum TunerClass
RSP("RSP"),
TEST_TUNER("Test"),
RECORDING_TUNER("Recording"),
NETWORK_TUNER("Network"),
UNKNOWN("Unknown" );
private final String mDescription;

View File

@@ -60,6 +60,9 @@ import io.github.dsheirer.source.tuner.limesdr.LimeSdrTunerController;
import io.github.dsheirer.source.tuner.limesdr.LimeSdrTunerEditor;
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
import io.github.dsheirer.source.tuner.manager.TunerManager;
import io.github.dsheirer.source.tuner.network.NetworkTunerConfiguration;
import io.github.dsheirer.source.tuner.network.NetworkTunerDisabledEditor;
import io.github.dsheirer.source.tuner.network.NetworkTunerEditor;
import io.github.dsheirer.source.tuner.recording.RecordingTunerConfiguration;
import io.github.dsheirer.source.tuner.recording.RecordingTunerEditor;
import io.github.dsheirer.source.tuner.rtl.RTL2832Tuner;
@@ -380,6 +383,8 @@ public class TunerFactory
return new RspDuoTuner2Configuration(uniqueID);
case RSP_DX:
return new RspDxTunerConfiguration(uniqueID);
case RTL_TCP:
return new NetworkTunerConfiguration(uniqueID);
default:
throw new IllegalArgumentException("Unrecognized tuner type [" + type.name() + "]");
}
@@ -448,6 +453,12 @@ public class TunerFactory
discoveredTuner.getClass());
case RECORDING_TUNER:
return new RecordingTunerEditor(userPreferences, tunerManager, discoveredTuner);
case NETWORK_TUNER:
if(discoveredTuner.hasTuner())
{
return new NetworkTunerEditor(userPreferences, tunerManager, discoveredTuner);
}
return new NetworkTunerDisabledEditor(userPreferences, tunerManager, discoveredTuner);
case RTL2832:
if(discoveredTuner.hasTuner())
{

View File

@@ -50,6 +50,8 @@ public enum TunerType
RSP_DUO_2("RSPduo Tuner 2"),
RSP_DX("RSPdx"),
RTL_TCP("rtl-tcp"),
TEST("Test"),
RECORDING("Recording"),
UNKNOWN("Unknown");
@@ -84,6 +86,11 @@ public enum TunerType
*/
public static final EnumSet<TunerType> SUPPORTED_SOUND_CARD_TUNERS = EnumSet.of(FUNCUBE_DONGLE_PRO, FUNCUBE_DONGLE_PRO_PLUS);
/**
* Supported network tuner Types
*/
public static final EnumSet<TunerType> SUPPORTED_NETWORK_TUNERS = EnumSet.of(RTL_TCP);
/**
* Indicates if this tuner is supported as a USB tuner
*/
@@ -100,6 +107,14 @@ public enum TunerType
return SUPPORTED_SOUND_CARD_TUNERS.contains(this);
}
/**
* Indicates if this tuner type is supported as a network tuner
*/
public boolean isSupportedNetworkTuner()
{
return SUPPORTED_NETWORK_TUNERS.contains(this);
}
@Override
public String toString()
{

View File

@@ -32,6 +32,7 @@ import io.github.dsheirer.source.tuner.fcd.proplusV2.FCD2TunerConfiguration;
import io.github.dsheirer.source.tuner.hackrf.HackRFTunerConfiguration;
import io.github.dsheirer.source.tuner.hydrasdr.HydraSdrTunerConfiguration;
import io.github.dsheirer.source.tuner.limesdr.LimeSdrTunerConfiguration;
import io.github.dsheirer.source.tuner.network.NetworkTunerConfiguration;
import io.github.dsheirer.source.tuner.recording.RecordingTunerConfiguration;
import io.github.dsheirer.source.tuner.rtl.e4k.E4KTunerConfiguration;
import io.github.dsheirer.source.tuner.rtl.fc0012.FC0012TunerConfiguration;
@@ -62,6 +63,7 @@ import io.github.dsheirer.source.tuner.sdrplay.RspTunerConfiguration;
@JsonSubTypes.Type(value = R820TTunerConfiguration.class, name = "r820TTunerConfiguration"),
@JsonSubTypes.Type(value = R828DTunerConfiguration.class, name = "r828DTunerConfiguration"),
@JsonSubTypes.Type(value = RspTunerConfiguration.class, name = "rspTunerConfiguration"),
@JsonSubTypes.Type(value = NetworkTunerConfiguration.class, name = "networkTunerConfiguration"),
})
@JacksonXmlRootElement(localName = "tuner_configuration")
public abstract class TunerConfiguration

View File

@@ -168,7 +168,8 @@ public class TunerConfigurationManager implements IDiscoveredTunerStatusListener
{
TunerType tunerType = discoveredTuner.getTuner().getTunerType();
if(tunerType != TunerType.RECORDING)
//Recording and network tuners' getId() doesn't match the configuration's uniqueID scheme
if(tunerType != TunerType.RECORDING && tunerType != TunerType.RTL_TCP)
{
TunerConfiguration tunerConfiguration = getTunerConfiguration(tunerType, discoveredTuner.getId());
@@ -193,7 +194,8 @@ public class TunerConfigurationManager implements IDiscoveredTunerStatusListener
{
TunerType tunerType = discoveredTuner.getTuner().getTunerType();
if(tunerType != TunerType.RECORDING)
//See note in tunerStatusUpdated()
if(tunerType != TunerType.RECORDING && tunerType != TunerType.RTL_TCP)
{
TunerConfiguration tunerConfiguration = getTunerConfiguration(tunerType, discoveredTuner.getId());

View File

@@ -0,0 +1,73 @@
/*
* *****************************************************************************
* Copyright (C) 2026 Jared Szechy
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.manager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.source.SourceException;
import io.github.dsheirer.source.tuner.TunerClass;
import io.github.dsheirer.source.tuner.network.NetworkTuner;
import io.github.dsheirer.source.tuner.network.NetworkTunerConfiguration;
public class DiscoveredNetworkTuner extends DiscoveredTuner {
private static final Logger mLog = LoggerFactory.getLogger(DiscoveredNetworkTuner.class);
private UserPreferences mUserPreferences;
public DiscoveredNetworkTuner(UserPreferences userPreferences, NetworkTunerConfiguration networkTunerConfiguration) {
mUserPreferences = userPreferences;
setTunerConfiguration(networkTunerConfiguration);
// Default all network tuners to be disabled on startup
setEnabled(false);
}
public NetworkTunerConfiguration getNetworkTunerConfiguration() {
return (NetworkTunerConfiguration) getTunerConfiguration();
}
@Override
public TunerClass getTunerClass() {
return TunerClass.NETWORK_TUNER;
}
@Override
public String getId() {
return "RTL TCP " + getNetworkTunerConfiguration().getHost() + ":" + getNetworkTunerConfiguration().getPort();
}
@Override
public void start() {
if (!hasTuner()) {
mTuner = new NetworkTuner(mUserPreferences, this, getNetworkTunerConfiguration());
try {
mTuner.start();
} catch (SourceException se) {
setErrorMessage("Error - " + se.getMessage());
}
}
}
@Override
public String toString() {
return getId();
}
}

View File

@@ -47,6 +47,7 @@ import io.github.dsheirer.source.tuner.channel.TunerChannel;
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
import io.github.dsheirer.source.tuner.configuration.TunerConfiguration;
import io.github.dsheirer.source.tuner.configuration.TunerConfigurationManager;
import io.github.dsheirer.source.tuner.network.NetworkTunerConfiguration;
import io.github.dsheirer.source.tuner.recording.RecordingTunerConfiguration;
import io.github.dsheirer.source.tuner.sdrplay.DiscoveredRspTuner;
import io.github.dsheirer.source.tuner.sdrplay.rspDuo.DiscoveredRspDuoTuner1;
@@ -137,6 +138,7 @@ public class TunerManager implements IDiscoveredTunerStatusListener
}
discoverRecordingTuners();
discoverNetworkTuners();
}
/**
@@ -437,6 +439,33 @@ public class TunerManager implements IDiscoveredTunerStatusListener
}
}
/**
* Discover network (e.g. rtl_tcp) based tuners
*/
private void discoverNetworkTuners()
{
List<TunerConfiguration> tunerConfigurations = getTunerConfigurationManager().getTunerConfigurations(TunerType.RTL_TCP);
if(tunerConfigurations.size() > 0)
{
mLog.info("Discovered [" + tunerConfigurations.size() + "] network tuners");
}
for(TunerConfiguration tunerConfiguration: tunerConfigurations)
{
if(tunerConfiguration instanceof NetworkTunerConfiguration networkTunerConfiguration)
{
DiscoveredNetworkTuner discoveredNetworkTuner =
new DiscoveredNetworkTuner(mUserPreferences, networkTunerConfiguration);
discoveredNetworkTuner.addTunerStatusListener(this);
discoveredNetworkTuner.setEnabled(false);
mLog.info("Tuner Added: " + discoveredNetworkTuner);
mDiscoveredTunerModel.addDiscoveredTuner(discoveredNetworkTuner);
}
}
}
/**
* Handles tuner status change events. Events are sent to the tuner configuration manager so that it can save
* configuration updates and events are also monitored to detect when a user changes the tuner state of a tuner

View File

@@ -0,0 +1,182 @@
/*
* *****************************************************************************
* Copyright (C) 2026 Jared Szechy
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.network;
import java.awt.Dimension;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import net.miginfocom.swing.MigLayout;
import org.apache.commons.lang3.Validate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.source.tuner.configuration.TunerConfigurationManager;
import io.github.dsheirer.source.tuner.manager.DiscoveredNetworkTuner;
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JTextField;
public class AddNetworkTunerDialog extends JFrame {
private final static Logger mLog = LoggerFactory.getLogger(AddNetworkTunerDialog.class);
private UserPreferences mUserPreferences;
private DiscoveredTunerModel mDiscoveredTunerModel;
private TunerConfigurationManager mTunerConfigurationManager;
private JLabel mTypeLabel;
private JLabel mHostLabel;
private JLabel mPortLabel;
private JLabel mFrequencyLabel;
private JComboBox mTypeComboBox;
private JTextField mHostField;
private JTextField mPortField;
private JTextField mFrequencyField;
private JButton mAddButton;
private JButton mCancelButton;
public AddNetworkTunerDialog(UserPreferences userPreferences, DiscoveredTunerModel discoveredTunerModel,
TunerConfigurationManager tunerConfigurationManager) {
Validate.notNull(userPreferences, "UserPreferences cannot be null");
Validate.notNull(discoveredTunerModel, "TunerModel cannot be null");
Validate.notNull(tunerConfigurationManager, "TunerConfigurationManager cannot be null");
mDiscoveredTunerModel = discoveredTunerModel;
mUserPreferences = userPreferences;
mTunerConfigurationManager = tunerConfigurationManager;
setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
setTitle("Add Network Tuner");
setSize(new Dimension(500, 250));
JPanel content = new JPanel();
content.setLayout(new MigLayout("", "[align right][][]", "[][][][][grow][]"));
mTypeLabel = new JLabel("Type:");
content.add(mTypeLabel);
String[] options = { "rtl-tcp" };
mTypeComboBox = new JComboBox<>(options);
content.add(mTypeComboBox, "span2,grow,wrap");
mHostLabel = new JLabel("Host:");
content.add(mHostLabel);
mHostField = new JTextField("");
mHostField.setToolTipText("Enter the hostname or IP address of the network tuner");
content.add(mHostField, "span2,grow,wrap");
mPortLabel = new JLabel("Port:");
content.add(mPortLabel);
mPortField = new JTextField("");
mHostField.setToolTipText("Enter the port number of the network tuner");
content.add(mPortField, "span2,grow,wrap");
mFrequencyLabel = new JLabel("Frequency (Hz):");
content.add(mFrequencyLabel);
mFrequencyField = new JTextField("");
mFrequencyField.setToolTipText("Enter the center frequency of the network tuner in Hertz (Hz)");
content.add(mFrequencyField, "span2,grow,wrap");
content.add(new JLabel(""), "wrap");
content.add(new JLabel(""));
mAddButton = new JButton("Add");
// mAddButton.setEnabled(false);
mAddButton.addActionListener(e -> {
long frequency = getFrequency();
if (frequency <= 0 || frequency > Integer.MAX_VALUE) {
JOptionPane.showMessageDialog(AddNetworkTunerDialog.this,
"Please provide a center frequency (1Hz to 2.14 GHz)",
"Center Frequency Required",
JOptionPane.ERROR_MESSAGE);
return;
}
mLog.info("Adding network tuner = frequency [" + frequency + "] host [" + getHost() + ":" + getPort() + "]");
try {
NetworkTunerConfiguration config = NetworkTunerConfiguration.create();
config.setFrequency(frequency);
config.setHost(getHost());
config.setPort(getPort());
mTunerConfigurationManager.addTunerConfiguration(config);
DiscoveredNetworkTuner discoveredNetworkTuner = new DiscoveredNetworkTuner(mUserPreferences, config);
mDiscoveredTunerModel.addDiscoveredTuner(discoveredNetworkTuner);
} catch (Exception ex) {
mLog.error("Error adding network tuner", ex);
}
AddNetworkTunerDialog.this.setVisible(false);
});
content.add(mAddButton, "grow,push");
mCancelButton = new JButton("Cancel");
mCancelButton.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
AddNetworkTunerDialog.this.setVisible(false);
}
});
content.add(mCancelButton, "grow,push");
setContentPane(content);
}
private long getFrequency() {
String text = mFrequencyField.getText();
if (text != null && !text.isEmpty()) {
try {
return Long.parseLong(text);
} catch (Exception e) {
// Do nothing, we couldn't parse the frequency value
}
}
return 0l;
}
private String getHost() {
return mHostField.getText();
}
private short getPort() {
String text = mPortField.getText();
if (text != null && !text.isEmpty()) {
try {
return Short.parseShort(text);
} catch (Exception e) {
// Do nothing, we couldn't parse the port value
}
}
return 0;
}
}

View File

@@ -0,0 +1,75 @@
/*
* *****************************************************************************
* Copyright (C) 2026 Jared Szechy
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.network;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.source.tuner.ITunerErrorListener;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.TunerClass;
import io.github.dsheirer.source.tuner.TunerType;
public class NetworkTuner extends Tuner {
private final static Logger mLog = LoggerFactory.getLogger(NetworkTuner.class);
public NetworkTuner(UserPreferences userPreferences, ITunerErrorListener tunerErrorListener,
NetworkTunerConfiguration config) {
super(new NetworkTunerController(tunerErrorListener, config), tunerErrorListener,
userPreferences.getTunerPreference().getChannelizerType());
}
@Override
public String getPreferredName() {
return "RTL TCP " + getTunerController().getHost() + ":" + getTunerController().getPort();
}
public NetworkTunerController getTunerController() {
return (NetworkTunerController) super.getTunerController();
}
@Override
public String getUniqueID() {
return getPreferredName();
}
@Override
public TunerClass getTunerClass() {
return TunerClass.NETWORK_TUNER;
}
@Override
public TunerType getTunerType() {
return TunerType.RTL_TCP; // TODO: support more than this.
}
@Override
public double getSampleSize() {
// Note: although sample size is 8, we set it to 11 to align with the
// actual noise floor.
return 11.0;
}
@Override
public int getMaximumUSBBitsPerSecond() {
// 16 bits per sample * 2.4 MSPS
return 38400000;
}
}

View File

@@ -0,0 +1,132 @@
/*
* *****************************************************************************
* Copyright (C) 2026 Jared Szechy
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.network;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import io.github.dsheirer.source.tuner.TunerType;
import io.github.dsheirer.source.tuner.configuration.TunerConfiguration;
/**
* Network tuner configuration for using network connected tuner as a source
*/
public class NetworkTunerConfiguration extends TunerConfiguration {
private static final Logger mLog = LoggerFactory.getLogger(NetworkTunerConfiguration.class);
private String mHost;
private short mPort;
private boolean mBiasTEnabled = false;
private int mGain = 0;
private boolean mGainAuto = true;
private long mSampleRate = 2_048_000;
private boolean mAutoReconnect = false;
/**
* Jackson constructor
*/
public NetworkTunerConfiguration() {
super(52000000, 2200000000l);
}
/**
* Constructs an instance
*
* @param uniqueId to use for this configuration
*/
public NetworkTunerConfiguration(String uniqueId) {
this();
setUniqueID(uniqueId);
}
@JsonIgnore
@Override
public TunerType getTunerType() {
return TunerType.RTL_TCP; // TODO: support more than this.
}
@JacksonXmlProperty(isAttribute = true, localName = "host")
public String getHost() {
return mHost;
}
public void setHost(String host) {
mHost = host;
}
@JacksonXmlProperty(isAttribute = true, localName = "port")
public short getPort() {
return mPort;
}
public void setPort(short port) {
mPort = port;
}
@JacksonXmlProperty(isAttribute = true, localName = "bias_t_enabled")
public boolean isBiasTEnabled() {
return mBiasTEnabled;
}
public void setBiasTEnabled(boolean enabled) {
mBiasTEnabled = enabled;
}
@JacksonXmlProperty(isAttribute = true, localName = "gain_auto")
public boolean isGainAuto() {
return mGainAuto;
}
public void setGainAuto(boolean gainAuto) {
mGainAuto = gainAuto;
}
@JacksonXmlProperty(isAttribute = true, localName = "gain")
public int getGain() {
return mGain;
}
public void setGain(int gain) {
mGain = gain;
}
@JacksonXmlProperty(isAttribute = true, localName = "sample_rate")
public long getSampleRate() {
return mSampleRate;
}
public void setSampleRate(long sampleRate) {
mSampleRate = sampleRate;
}
@JacksonXmlProperty(isAttribute = true, localName = "auto_reconnect")
public boolean isAutoReconnect() {
return mAutoReconnect;
}
public void setAutoReconnect(boolean autoReconnect) {
mAutoReconnect = autoReconnect;
}
public static NetworkTunerConfiguration create() {
return new NetworkTunerConfiguration("Network " + System.currentTimeMillis());
}
}

View File

@@ -0,0 +1,383 @@
/*
* *****************************************************************************
* Copyright (C) 2026 Jared Szechy
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.network;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.util.Arrays;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.github.dsheirer.buffer.ByteNativeBufferFactory;
import io.github.dsheirer.source.SourceException;
import io.github.dsheirer.source.tuner.ITunerErrorListener;
import io.github.dsheirer.source.tuner.TunerController;
import io.github.dsheirer.source.tuner.TunerType;
/**
* Tuner controller for an rtl_tcp network tuner. Connects to a remote rtl_tcp server over TCP,
* performs the rtl_tcp handshake/command protocol and streams unsigned 8-bit interleaved I/Q
* samples from the server into the tuner's native buffer pipeline.
*/
public class NetworkTunerController extends TunerController {
private final static Logger mLog = LoggerFactory.getLogger(NetworkTunerController.class);
public static final int DC_NOISE_BANDWIDTH = 0;
public static final double USABLE_BANDWIDTH_PERCENTAGE = 1.00;
private static final int HANDSHAKE_LENGTH = 12;
private static final int CONNECT_TIMEOUT_MS = 5000;
private static final int READ_BUFFER_SIZE = 65536; //bytes - matches RTL2832's USB transfer buffer size
//ByteNativeBuffer's iterator consumes 8192 complex (I/Q) samples per fragment, i.e. 16384 bytes, so buffers
//broadcast to it must be an even multiple of 16384 bytes, not just 8192.
private static final int FRAGMENT_SIZE = 16384;
private static final long[] RECONNECT_DELAYS_MS = {1000, 2000, 5000, 10000};
//rtl_tcp client command identifiers (see librtlsdr rtl_tcp.c)
private static final int CMD_SET_FREQUENCY = 0x01;
private static final int CMD_SET_SAMPLE_RATE = 0x02;
private static final int CMD_SET_GAIN_MODE = 0x03;
private static final int CMD_SET_GAIN = 0x04;
private static final int CMD_SET_BIAS_TEE = 0x0E;
private final String mHost;
private final int mPort;
private long mCenterFrequency;
private long mSampleRate;
private boolean mGainAuto;
private int mGain;
private boolean mBiasTEnabled;
private boolean mAutoReconnect;
private final ByteNativeBufferFactory mNativeBufferFactory = new ByteNativeBufferFactory();
private volatile OutputStream mOutputStream;
private volatile boolean mRunning;
private Thread mConnectionThread;
public NetworkTunerController(ITunerErrorListener tunerErrorListener, NetworkTunerConfiguration config) {
super(tunerErrorListener);
mHost = config.getHost();
mPort = config.getPort() & 0xFFFF; //port is stored as a signed short - treat as unsigned
mCenterFrequency = config.getFrequency();
if (mCenterFrequency == 0) {
mCenterFrequency = 100000000; // Default to 100 MHz if no frequency is provided
}
mSampleRate = config.getSampleRate();
mGainAuto = config.isGainAuto();
mGain = config.getGain();
mBiasTEnabled = config.isBiasTEnabled();
mAutoReconnect = config.isAutoReconnect();
setMinimumFrequency(1000000L); // 1 MHz
setMaximumFrequency(3000000000L); // 3 GHz
setMiddleUnusableHalfBandwidth(DC_NOISE_BANDWIDTH);
setUsableBandwidthPercentage(USABLE_BANDWIDTH_PERCENTAGE);
mNativeBufferFactory.setSamplesPerMillisecond(mSampleRate / 1000.0f);
}
@Override
public void start() throws SourceException {
if (mRunning) {
return;
}
//Broadcasts the sample rate to downstream listeners (e.g. the channel source manager) - without
//this, getSampleRate() remains 0 and channelizing never starts.
mFrequencyController.setSampleRate((int) mSampleRate);
mRunning = true;
mConnectionThread = new Thread(this::runConnectionLoop, "network-tuner-" + mHost + ":" + mPort);
mConnectionThread.setDaemon(true);
mConnectionThread.start();
}
@Override
public void stop() {
mRunning = false;
Thread thread = mConnectionThread;
mConnectionThread = null;
if (thread != null) {
thread.interrupt();
}
}
/**
* Runs on a background thread for the lifetime of this controller (until stop() is called).
* Connects, streams samples until the connection drops or is closed, and - when auto-reconnect
* is enabled - retries with an increasing backoff delay. When auto-reconnect is disabled, a
* connection failure is reported as a tuner error and the loop exits.
*/
private void runConnectionLoop() {
int attempt = 0;
while (mRunning) {
try {
connectAndStream();
attempt = 0;
} catch (IOException ioe) {
if (!mRunning) {
return; //stop() was called - exit quietly
}
if (!mAutoReconnect) {
mLog.error("Network tuner [" + mHost + ":" + mPort + "] connection error", ioe);
setErrorMessage("Error - " + ioe.getMessage());
mRunning = false;
return;
}
long delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
attempt++;
mLog.warn("Network tuner [" + mHost + ":" + mPort + "] disconnected - reconnecting in " + delay
+ " ms - " + ioe.getMessage());
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
}
}
}
/**
* Connects to the rtl_tcp server, performs the handshake and initial command sequence, and then
* blocks streaming samples until the connection is closed (by either side) or stop() is called.
*/
private void connectAndStream() throws IOException {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(mHost, mPort), CONNECT_TIMEOUT_MS);
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
mOutputStream = out;
readAndValidateHandshake(in);
sendCommand(out, CMD_SET_SAMPLE_RATE, (int) mSampleRate);
sendCommand(out, CMD_SET_FREQUENCY, (int) mCenterFrequency);
sendCommand(out, CMD_SET_GAIN_MODE, mGainAuto ? 0 : 1);
if (!mGainAuto) {
sendCommand(out, CMD_SET_GAIN, mGain);
}
sendCommand(out, CMD_SET_BIAS_TEE, mBiasTEnabled ? 1 : 0);
mLog.info("Network tuner connected [" + mHost + ":" + mPort + "]");
byte[] readBuffer = new byte[READ_BUFFER_SIZE];
byte[] pending = new byte[0]; //leftover bytes (< FRAGMENT_SIZE) carried over from the previous read
int read;
while (mRunning && (read = in.read(readBuffer)) != -1) {
//Socket reads rarely align to FRAGMENT_SIZE (ByteNativeBuffer requires an even multiple of it),
//so accumulate leftover bytes across reads and only broadcast full FRAGMENT_SIZE-aligned chunks.
byte[] combined = new byte[pending.length + read];
System.arraycopy(pending, 0, combined, 0, pending.length);
System.arraycopy(readBuffer, 0, combined, pending.length, read);
int usableLength = combined.length - (combined.length % FRAGMENT_SIZE);
if (usableLength > 0) {
ByteBuffer samples = ByteBuffer.wrap(combined, 0, usableLength).slice();
broadcast(mNativeBufferFactory.getBuffer(samples, System.currentTimeMillis()));
}
pending = Arrays.copyOfRange(combined, usableLength, combined.length);
}
if (mRunning) {
throw new EOFException("Network tuner connection closed by remote host");
}
} finally {
mOutputStream = null;
}
}
/**
* Reads and validates the 12-byte rtl_tcp handshake header: 4-byte "RTL0" magic, followed by a
* 4-byte big endian tuner type and a 4-byte big endian tuner gain count (informational only).
*/
void readAndValidateHandshake(InputStream in) throws IOException {
byte[] header = readFully(in, HANDSHAKE_LENGTH);
if (header[0] != 'R' || header[1] != 'T' || header[2] != 'L' || header[3] != '0') {
throw new IOException("Unexpected rtl_tcp handshake magic from [" + mHost + ":" + mPort + "]");
}
}
/**
* Reads exactly length bytes from the stream, blocking until enough bytes are available.
*/
static byte[] readFully(InputStream in, int length) throws IOException {
byte[] buffer = new byte[length];
int offset = 0;
while (offset < length) {
int read = in.read(buffer, offset, length - offset);
if (read == -1) {
throw new EOFException("Unexpected end of stream reading rtl_tcp handshake");
}
offset += read;
}
return buffer;
}
/**
* Encodes an rtl_tcp client command as a 5-byte packet: 1-byte command id followed by a 4-byte
* big endian unsigned integer parameter.
*/
static byte[] encodeCommand(int commandId, int parameter) {
return ByteBuffer.allocate(5).put((byte) commandId).putInt(parameter).array();
}
private void sendCommand(OutputStream out, int commandId, int parameter) throws IOException {
out.write(encodeCommand(commandId, parameter));
out.flush();
}
/**
* Sends a command to the currently connected server, if any, ignoring (but logging and
* reporting) any error. Used for applying live configuration changes from the tuner editor.
*/
private void trySendCommand(int commandId, int parameter) {
OutputStream out = mOutputStream;
if (out != null) {
try {
sendCommand(out, commandId, parameter);
} catch (IOException ioe) {
mLog.error("Error sending command [" + commandId + "] to network tuner", ioe);
setErrorMessage("Error sending command to network tuner - " + ioe.getMessage());
}
}
}
@Override
public TunerType getTunerType() {
return TunerType.RTL_TCP; // TODO: support more than this.
}
public String getHost() {
return mHost;
}
public int getPort() {
return mPort;
}
@Override
public int getBufferSampleCount() {
return READ_BUFFER_SIZE / 2; //2 bytes (I,Q) per complex sample
}
@Override
public long getTunedFrequency() throws SourceException {
return mCenterFrequency;
}
@Override
public void setTunedFrequency(long frequency) throws SourceException {
mCenterFrequency = frequency;
trySendCommand(CMD_SET_FREQUENCY, (int) frequency);
}
@Override
public double getCurrentSampleRate() {
return mSampleRate;
}
public boolean isGainAuto() {
return mGainAuto;
}
/**
* Sets the gain mode and, when switching to manual, (re)sends the current gain value. Applies
* immediately if connected; otherwise takes effect on the next connection.
*/
public void setGainAuto(boolean gainAuto) {
mGainAuto = gainAuto;
trySendCommand(CMD_SET_GAIN_MODE, gainAuto ? 0 : 1);
if (!gainAuto) {
trySendCommand(CMD_SET_GAIN, mGain);
}
}
public int getGain() {
return mGain;
}
/**
* Sets the manual gain value (tenths of dB). Only sent to the server when gain mode is manual.
*/
public void setGain(int gain) {
mGain = gain;
if (!mGainAuto) {
trySendCommand(CMD_SET_GAIN, gain);
}
}
public boolean isBiasTEnabled() {
return mBiasTEnabled;
}
public void setBiasTEnabled(boolean enabled) {
mBiasTEnabled = enabled;
trySendCommand(CMD_SET_BIAS_TEE, enabled ? 1 : 0);
}
public boolean isAutoReconnect() {
return mAutoReconnect;
}
public void setAutoReconnect(boolean autoReconnect) {
mAutoReconnect = autoReconnect;
}
public long getSampleRateConfigured() {
return mSampleRate;
}
/**
* Sets the sample rate. Applies immediately if connected; otherwise takes effect on the next
* connection.
*/
public void setSampleRate(long sampleRate) {
mSampleRate = sampleRate;
mNativeBufferFactory.setSamplesPerMillisecond(sampleRate / 1000.0f);
try {
mFrequencyController.setSampleRate((int) sampleRate);
} catch (SourceException se) {
mLog.error("Error updating sample rate", se);
setErrorMessage("Error updating sample rate - " + se.getMessage());
}
trySendCommand(CMD_SET_SAMPLE_RATE, (int) sampleRate);
}
}

View File

@@ -0,0 +1,94 @@
/*
* *****************************************************************************
* Copyright (C) 2026 Jared Szechy
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.network;
import javax.swing.JLabel;
import javax.swing.JSeparator;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
import io.github.dsheirer.source.tuner.manager.TunerManager;
import io.github.dsheirer.source.tuner.ui.TunerEditor;
import net.miginfocom.swing.MigLayout;
/**
* Minimal tuner editor for a network (rtl_tcp) tuner that has not been started. Mirrors
* RTL2832UnknownTunerEditor's pattern: only the id/status labels and the button panel are shown -
* no frequency/sample-rate/gain controls, since there's no live tuner to configure yet.
*/
public class NetworkTunerDisabledEditor extends TunerEditor<NetworkTuner, NetworkTunerConfiguration> {
private static final long serialVersionUID = 1L;
public NetworkTunerDisabledEditor(UserPreferences userPreferences, TunerManager tunerManager,
DiscoveredTuner discoveredTuner) {
super(userPreferences, tunerManager, discoveredTuner);
init();
tunerStatusUpdated();
}
private void init() {
setLayout(new MigLayout("fill,wrap 3", "[right][grow,fill][fill]", "[][][][grow]"));
add(new JLabel("Tuner:"));
add(getTunerIdLabel(), "wrap");
add(new JLabel("Status:"));
add(getTunerStatusLabel(), "wrap");
add(getButtonPanel(), "span,align left");
add(new JSeparator(), "span,growx,push");
}
@Override
public long getMinimumTunableFrequency() {
//Bogus value - matches NetworkTunerEditor's range since there's no live tuner to query yet.
return 52000000;
}
@Override
public long getMaximumTunableFrequency() {
//Bogus value - matches NetworkTunerEditor's range since there's no live tuner to query yet.
return 2200000000L;
}
@Override
protected void save() {
//No-op
}
@Override
protected void tunerStatusUpdated() {
setLoading(true);
getTunerIdLabel().setText(getDiscoveredTuner().getId());
String status = getDiscoveredTuner().getTunerStatus().toString();
if (getDiscoveredTuner().hasErrorMessage()) {
status += " - " + getDiscoveredTuner().getErrorMessage();
}
getTunerStatusLabel().setText(status);
getButtonPanel().updateControls();
setLoading(false);
}
@Override
public void setTunerLockState(boolean locked) {
getFrequencyPanel().updateControls();
}
}

View File

@@ -0,0 +1,330 @@
/*
* *****************************************************************************
* Copyright (C) 2026 Jared Szechy
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.network;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JSeparator;
import javax.swing.JToggleButton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
import io.github.dsheirer.source.tuner.manager.TunerManager;
import io.github.dsheirer.source.tuner.rtl.RTL2832TunerController.SampleRate;
import io.github.dsheirer.source.tuner.ui.TunerEditor;
import net.miginfocom.swing.MigLayout;
public class NetworkTunerEditor extends TunerEditor<NetworkTuner, NetworkTunerConfiguration> {
private static final long serialVersionUID = 1L;
private static final Logger mLog = LoggerFactory.getLogger(NetworkTunerEditor.class);
//R820T total gain table (tenths of dB), from librtlsdr's rtlsdr_get_tuner_gains() - most rtl_tcp
//servers are backed by an R820T/R820T2 dongle and report this same gain table.
private static final Integer[] R820T_GAINS_TENTHS_DB = {0, 9, 14, 27, 37, 77, 87, 125, 144, 157, 166, 197, 207,
229, 254, 280, 297, 328, 338, 364, 372, 386, 402, 421, 434, 439, 445, 480, 496};
private JButton mTunerInfoButton;
private JToggleButton mBiasTButton;
private JComboBox<SampleRate> mSampleRateCombo;
private JCheckBox mGainAutoCheckBox;
private JComboBox<Integer> mGainCombo;
private JCheckBox mAutoReconnectCheckBox;
public NetworkTunerEditor(UserPreferences userPreferences, TunerManager tunerManager,
DiscoveredTuner discoveredTuner) {
super(userPreferences, tunerManager, discoveredTuner);
init();
tunerStatusUpdated();
}
@Override
public void setTunerLockState(boolean locked) {
getFrequencyPanel().updateControls();
//Matches the USB RTL2832/R8x tuner pattern: sample rate can't change while channels are active. Gain and
//Bias-T don't affect channelizing, so they remain adjustable regardless of lock state.
getSampleRateCombo().setEnabled(hasTuner() && !locked);
}
@Override
public long getMinimumTunableFrequency() {
return 52000000;
}
@Override
public long getMaximumTunableFrequency() {
return 2200000000l;
}
@Override
protected void tunerStatusUpdated() {
setLoading(true);
if (hasTuner()) {
getTunerIdLabel().setText(getTuner().getPreferredName());
} else {
getTunerIdLabel().setText(getDiscoveredTuner().getId());
}
String status = getDiscoveredTuner().getTunerStatus().toString();
if (getDiscoveredTuner().hasErrorMessage()) {
status += " - " + getDiscoveredTuner().getErrorMessage();
}
getTunerStatusLabel().setText(status);
getButtonPanel().updateControls();
getFrequencyPanel().updateControls();
getTunerInfoButton().setEnabled(hasTuner());
getBiasTButton().setEnabled(hasTuner());
getSampleRateCombo().setEnabled(hasTuner());
getGainAutoCheckBox().setEnabled(hasTuner());
getAutoReconnectCheckBox().setEnabled(hasTuner());
if (hasConfiguration()) {
getBiasTButton().setSelected(getConfiguration().isBiasTEnabled());
getSampleRateCombo().setSelectedItem(closestSampleRate(getConfiguration().getSampleRate()));
getGainAutoCheckBox().setSelected(getConfiguration().isGainAuto());
getGainCombo().setSelectedItem(closestGain(getConfiguration().getGain()));
getGainCombo().setEnabled(hasTuner() && !getConfiguration().isGainAuto());
getAutoReconnectCheckBox().setSelected(getConfiguration().isAutoReconnect());
}
setLoading(false);
}
private void init() {
setLayout(new MigLayout("fill,wrap 3", "[right][grow,fill][fill]", "[][][][][][][][][grow]"));
add(new JLabel("Tuner:"));
add(getTunerIdLabel());
add(getTunerInfoButton());
add(new JLabel("Status:"));
add(getTunerStatusLabel());
add(getBiasTButton(), "wrap");
add(getButtonPanel(), "span,align left");
add(new JSeparator(), "span,growx,push");
add(new JLabel("Frequency (MHz):"));
add(getFrequencyPanel(), "wrap");
add(new JLabel("Sample Rate:"));
add(getSampleRateCombo(), "wrap");
add(new JLabel("Gain:"));
add(getGainAutoCheckBox(), "split 2");
add(getGainCombo(), "wrap");
add(new JLabel("Auto-Reconnect:"));
add(getAutoReconnectCheckBox(), "wrap");
}
/**
* Bias-T toggle button, matching the USB RTL2832/R8x tuner editor's top-row placement.
*/
private JToggleButton getBiasTButton() {
if (mBiasTButton == null) {
mBiasTButton = new JToggleButton("Bias-T");
mBiasTButton.setEnabled(false);
mBiasTButton.addActionListener(e -> {
if (!isLoading()) {
boolean enabled = mBiasTButton.isSelected();
if (hasTuner()) {
getTuner().getTunerController().setBiasTEnabled(enabled);
}
save();
}
});
}
return mBiasTButton;
}
/**
* Hyperlink-style button that shows the network tuner's connection details.
*/
private JButton getTunerInfoButton() {
if (mTunerInfoButton == null) {
mTunerInfoButton = new JButton("Info");
mTunerInfoButton.setEnabled(false);
mTunerInfoButton.addActionListener(e -> JOptionPane.showMessageDialog(NetworkTunerEditor.this,
getTunerInfo(), "Tuner Info", JOptionPane.INFORMATION_MESSAGE));
}
return mTunerInfoButton;
}
private String getTunerInfo() {
StringBuilder sb = new StringBuilder();
sb.append("<html><h3>rtl_tcp Network Tuner</h3>");
sb.append("<b>Host: </b>").append(getConfiguration().getHost()).append("<br>");
sb.append("<b>Port: </b>").append(getConfiguration().getPort()).append("<br>");
sb.append("<b>Tuner Type: </b>rtl_tcp<br>");
return sb.toString();
}
private JComboBox<SampleRate> getSampleRateCombo() {
if (mSampleRateCombo == null) {
mSampleRateCombo = new JComboBox<>(SampleRate.values());
mSampleRateCombo.setEnabled(false);
mSampleRateCombo.addActionListener(e -> {
if (!isLoading()) {
SampleRate sampleRate = (SampleRate) mSampleRateCombo.getSelectedItem();
if (sampleRate != null) {
if (hasTuner()) {
getTuner().getTunerController().setSampleRate(sampleRate.getRate());
}
save();
}
}
});
}
return mSampleRateCombo;
}
private JCheckBox getGainAutoCheckBox() {
if (mGainAutoCheckBox == null) {
mGainAutoCheckBox = new JCheckBox("Auto");
mGainAutoCheckBox.addActionListener(e -> {
boolean gainAuto = mGainAutoCheckBox.isSelected();
getGainCombo().setEnabled(hasTuner() && !gainAuto);
if (!isLoading()) {
if (hasTuner()) {
getTuner().getTunerController().setGainAuto(gainAuto);
}
save();
}
});
}
return mGainAutoCheckBox;
}
private JComboBox<Integer> getGainCombo() {
if (mGainCombo == null) {
mGainCombo = new JComboBox<>(R820T_GAINS_TENTHS_DB);
mGainCombo.setEnabled(false);
mGainCombo.setToolTipText("Gain in tenths of dB. Uncheck Auto to enable manual gain.");
mGainCombo.addActionListener(e -> {
if (!isLoading()) {
Integer gain = (Integer) mGainCombo.getSelectedItem();
if (gain != null) {
if (hasTuner()) {
getTuner().getTunerController().setGain(gain);
}
save();
}
}
});
}
return mGainCombo;
}
private JCheckBox getAutoReconnectCheckBox() {
if (mAutoReconnectCheckBox == null) {
mAutoReconnectCheckBox = new JCheckBox();
mAutoReconnectCheckBox.setToolTipText("Automatically reconnect if the connection to the rtl_tcp server is lost");
mAutoReconnectCheckBox.addActionListener(e -> {
if (!isLoading()) {
boolean autoReconnect = mAutoReconnectCheckBox.isSelected();
if (hasTuner()) {
getTuner().getTunerController().setAutoReconnect(autoReconnect);
}
save();
}
});
}
return mAutoReconnectCheckBox;
}
/**
* Finds the SampleRate enum value closest to the specified sample rate (Hz).
*/
private static SampleRate closestSampleRate(long sampleRate) {
SampleRate closest = SampleRate.values()[0];
for (SampleRate candidate : SampleRate.values()) {
if (Math.abs(candidate.getRate() - sampleRate) < Math.abs(closest.getRate() - sampleRate)) {
closest = candidate;
}
}
return closest;
}
/**
* Finds the R820T gain table entry closest to the specified gain value (tenths of dB).
*/
private static int closestGain(int gain) {
int closest = R820T_GAINS_TENTHS_DB[0];
for (int candidate : R820T_GAINS_TENTHS_DB) {
if (Math.abs(candidate - gain) < Math.abs(closest - gain)) {
closest = candidate;
}
}
return closest;
}
@Override
public void save() {
if (hasConfiguration() && !isLoading()) {
NetworkTunerConfiguration config = getConfiguration();
config.setFrequency(getFrequencyControl().getFrequency());
getConfiguration().setMinimumFrequency(getMinimumFrequencyTextField().getFrequency());
getConfiguration().setMaximumFrequency(getMaximumFrequencyTextField().getFrequency());
SampleRate sampleRate = (SampleRate) getSampleRateCombo().getSelectedItem();
if (sampleRate != null) {
config.setSampleRate(sampleRate.getRate());
}
config.setGainAuto(getGainAutoCheckBox().isSelected());
Integer gain = (Integer) getGainCombo().getSelectedItem();
if (gain != null) {
config.setGain(gain);
}
config.setBiasTEnabled(getBiasTButton().isSelected());
config.setAutoReconnect(getAutoReconnectCheckBox().isSelected());
saveConfiguration();
}
}
}

View File

@@ -23,10 +23,12 @@ import com.jidesoft.swing.JideSplitPane;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.swing.JTableColumnWidthMonitor;
import io.github.dsheirer.source.tuner.configuration.TunerConfigurationManager;
import io.github.dsheirer.source.tuner.manager.DiscoveredNetworkTuner;
import io.github.dsheirer.source.tuner.manager.DiscoveredRecordingTuner;
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
import io.github.dsheirer.source.tuner.manager.TunerManager;
import io.github.dsheirer.source.tuner.manager.TunerStatus;
import io.github.dsheirer.source.tuner.network.AddNetworkTunerDialog;
import io.github.dsheirer.source.tuner.recording.AddRecordingTunerDialog;
import java.awt.Color;
import java.awt.Component;
@@ -70,7 +72,8 @@ public class TunerViewPanel extends JPanel
private TableRowSorter<DiscoveredTunerModel> mRowSorter;
private JideSplitPane mSplitPane;
private JButton mAddRecordingButton;
private JButton mRemoveRecordingButton;
private JButton mRemoveTunerButton;
private JButton mAddNetworkButton;
/**
* Constructs an instance
@@ -100,7 +103,7 @@ public class TunerViewPanel extends JPanel
mTunerTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
mTunerTable.getSelectionModel().addListSelectionListener(event ->
{
getRemoveRecordingButton().setEnabled(false);
getRemoveTunerButton().setEnabled(false);
if(!event.getValueIsAdjusting())
{
@@ -112,7 +115,7 @@ public class TunerViewPanel extends JPanel
DiscoveredTuner selected = mDiscoveredTunerModel.getDiscoveredTuner(modelRow);
mDiscoveredTunerEditor.setItem(selected);
getRemoveRecordingButton().setEnabled(selected instanceof DiscoveredRecordingTuner);
getRemoveTunerButton().setEnabled(selected instanceof DiscoveredRecordingTuner || selected instanceof DiscoveredNetworkTuner);
}
}
});
@@ -192,7 +195,8 @@ public class TunerViewPanel extends JPanel
JPanel buttonPanel = new JPanel();
buttonPanel.setLayout(new MigLayout("insets 0 1 3 0", "", ""));
buttonPanel.add(getAddRecordingButton());
buttonPanel.add(getRemoveRecordingButton());
buttonPanel.add(getAddNetworkButton());
buttonPanel.add(getRemoveTunerButton());
tunerTablePanel.add(buttonPanel);
tunerTablePanel.setPreferredSize(new Dimension(200,200));
@@ -226,13 +230,13 @@ public class TunerViewPanel extends JPanel
return mAddRecordingButton;
}
private JButton getRemoveRecordingButton()
private JButton getRemoveTunerButton()
{
if(mRemoveRecordingButton == null)
if(mRemoveTunerButton == null)
{
mRemoveRecordingButton = new JButton("Remove Recording Tuner");
mRemoveRecordingButton.setEnabled(false);
mRemoveRecordingButton.addActionListener(e -> {
mRemoveTunerButton = new JButton("Remove Tuner");
mRemoveTunerButton.setEnabled(false);
mRemoveTunerButton.addActionListener(e -> {
int[] indexes = mTunerTable.getSelectionModel().getSelectedIndices();
//With single selection mode this should always be length one
@@ -241,18 +245,34 @@ public class TunerViewPanel extends JPanel
int modelIndex = mTunerTable.convertRowIndexToModel(indexes[0]);
DiscoveredTuner selected = mDiscoveredTunerModel.getDiscoveredTuner(modelIndex);
if(selected instanceof DiscoveredRecordingTuner discoveredRecordingTuner)
if(selected instanceof DiscoveredRecordingTuner || selected instanceof DiscoveredNetworkTuner)
{
mLog.info("Removing Tuner: " + discoveredRecordingTuner);
discoveredRecordingTuner.stop();
mTunerConfigurationManager.removeTunerConfiguration(discoveredRecordingTuner.getTunerConfiguration());
EventQueue.invokeLater(() -> mDiscoveredTunerModel.removeDiscoveredTuner(discoveredRecordingTuner));
mLog.info("Removing Tuner: " + selected);
selected.stop();
mTunerConfigurationManager.removeTunerConfiguration(selected.getTunerConfiguration());
EventQueue.invokeLater(() -> mDiscoveredTunerModel.removeDiscoveredTuner(selected));
}
}
});
}
return mRemoveRecordingButton;
return mRemoveTunerButton;
}
private JButton getAddNetworkButton()
{
if (mAddNetworkButton == null)
{
mAddNetworkButton = new JButton("Add Network Tuner");
mAddNetworkButton.addActionListener(e ->
{
AddNetworkTunerDialog dialog = new AddNetworkTunerDialog(mUserPreferences, mDiscoveredTunerModel, mTunerConfigurationManager);
dialog.setLocationRelativeTo(TunerViewPanel.this);
EventQueue.invokeLater(() -> dialog.setVisible(true));
});
}
return mAddNetworkButton;
}
/**

View File

@@ -0,0 +1,95 @@
/*
* *****************************************************************************
* Copyright (C) 2026 Jared Szechy
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.network;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import io.github.dsheirer.source.tuner.ITunerErrorListener;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Unit tests for the rtl_tcp wire protocol helpers in {@link NetworkTunerController}: command
* encoding and handshake header validation.
*/
public class NetworkTunerControllerTest {
@Test
public void testEncodeCommandProducesFiveByteBigEndianPacket() {
//Command 0x01 (set frequency) with parameter 100_000_000 (100 MHz)
byte[] expected = ByteBuffer.allocate(5).put((byte) 0x01).putInt(100_000_000).array();
byte[] actual = NetworkTunerController.encodeCommand(0x01, 100_000_000);
assertEquals(5, actual.length);
assertArrayEquals(expected, actual);
}
@Test
public void testEncodeCommandNegativeParameterEncodesAsUnsignedBigEndian() {
//rtl_tcp treats the 4-byte parameter as an unsigned 32-bit value - verify negative Java
//ints (used to represent values > Integer.MAX_VALUE) round-trip through the same bytes
//as ByteBuffer.putInt() would produce.
byte[] expected = ByteBuffer.allocate(5).put((byte) 0x02).putInt(-1).array();
byte[] actual = NetworkTunerController.encodeCommand(0x02, -1);
assertArrayEquals(expected, actual);
}
@Test
public void testReadAndValidateHandshakeAcceptsValidMagic() throws IOException {
byte[] header = new byte[]{'R', 'T', 'L', '0', 0, 0, 0, 0, 0, 0, 0, 1};
NetworkTunerController controller = createController();
//Should not throw
controller.readAndValidateHandshake(new ByteArrayInputStream(header));
}
@Test
public void testReadAndValidateHandshakeRejectsInvalidMagic() {
byte[] header = new byte[]{'X', 'X', 'X', 'X', 0, 0, 0, 0, 0, 0, 0, 1};
NetworkTunerController controller = createController();
InputStream in = new ByteArrayInputStream(header);
assertThrows(IOException.class, () -> controller.readAndValidateHandshake(in));
}
@Test
public void testReadAndValidateHandshakeRejectsTruncatedHeader() {
byte[] header = new byte[]{'R', 'T', 'L', '0'};
NetworkTunerController controller = createController();
InputStream in = new ByteArrayInputStream(header);
assertThrows(IOException.class, () -> controller.readAndValidateHandshake(in));
}
private static NetworkTunerController createController() {
NetworkTunerConfiguration config = NetworkTunerConfiguration.create();
config.setHost("localhost");
config.setPort((short) 1234);
return new NetworkTunerController(new ITunerErrorListener() {
@Override
public void setErrorMessage(String errorMessage) {
}
@Override
public void tunerRemoved() {
}
}, config);
}
}