diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..240a94ad --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +java = "liberica-javafx-27" diff --git a/src/main/java/io/github/dsheirer/buffer/ByteNativeBuffer.java b/src/main/java/io/github/dsheirer/buffer/ByteNativeBuffer.java index da293361..de84a068 100644 --- a/src/main/java/io/github/dsheirer/buffer/ByteNativeBuffer.java +++ b/src/main/java/io/github/dsheirer/buffer/ByteNativeBuffer.java @@ -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; diff --git a/src/main/java/io/github/dsheirer/source/tuner/TunerClass.java b/src/main/java/io/github/dsheirer/source/tuner/TunerClass.java index ee760585..bf144fe8 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/TunerClass.java +++ b/src/main/java/io/github/dsheirer/source/tuner/TunerClass.java @@ -37,6 +37,7 @@ public enum TunerClass RSP("RSP"), TEST_TUNER("Test"), RECORDING_TUNER("Recording"), + NETWORK_TUNER("Network"), UNKNOWN("Unknown" ); private final String mDescription; diff --git a/src/main/java/io/github/dsheirer/source/tuner/TunerFactory.java b/src/main/java/io/github/dsheirer/source/tuner/TunerFactory.java index 7be34d3f..36852dda 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/TunerFactory.java +++ b/src/main/java/io/github/dsheirer/source/tuner/TunerFactory.java @@ -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()) { diff --git a/src/main/java/io/github/dsheirer/source/tuner/TunerType.java b/src/main/java/io/github/dsheirer/source/tuner/TunerType.java index 2c867ae6..0a61e73b 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/TunerType.java +++ b/src/main/java/io/github/dsheirer/source/tuner/TunerType.java @@ -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 SUPPORTED_SOUND_CARD_TUNERS = EnumSet.of(FUNCUBE_DONGLE_PRO, FUNCUBE_DONGLE_PRO_PLUS); + /** + * Supported network tuner Types + */ + public static final EnumSet 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() { diff --git a/src/main/java/io/github/dsheirer/source/tuner/configuration/TunerConfiguration.java b/src/main/java/io/github/dsheirer/source/tuner/configuration/TunerConfiguration.java index cf923e40..dde23f22 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/configuration/TunerConfiguration.java +++ b/src/main/java/io/github/dsheirer/source/tuner/configuration/TunerConfiguration.java @@ -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 diff --git a/src/main/java/io/github/dsheirer/source/tuner/configuration/TunerConfigurationManager.java b/src/main/java/io/github/dsheirer/source/tuner/configuration/TunerConfigurationManager.java index 90613a62..170a686a 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/configuration/TunerConfigurationManager.java +++ b/src/main/java/io/github/dsheirer/source/tuner/configuration/TunerConfigurationManager.java @@ -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()); diff --git a/src/main/java/io/github/dsheirer/source/tuner/manager/DiscoveredNetworkTuner.java b/src/main/java/io/github/dsheirer/source/tuner/manager/DiscoveredNetworkTuner.java new file mode 100644 index 00000000..2318e79b --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/manager/DiscoveredNetworkTuner.java @@ -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 + * **************************************************************************** + */ +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(); + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/manager/TunerManager.java b/src/main/java/io/github/dsheirer/source/tuner/manager/TunerManager.java index 1c6e6a0a..7811c3e8 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/manager/TunerManager.java +++ b/src/main/java/io/github/dsheirer/source/tuner/manager/TunerManager.java @@ -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 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 diff --git a/src/main/java/io/github/dsheirer/source/tuner/network/AddNetworkTunerDialog.java b/src/main/java/io/github/dsheirer/source/tuner/network/AddNetworkTunerDialog.java new file mode 100644 index 00000000..4f9584ac --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/network/AddNetworkTunerDialog.java @@ -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 + * **************************************************************************** + */ +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; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTuner.java b/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTuner.java new file mode 100644 index 00000000..3f7a6f76 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTuner.java @@ -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 + * **************************************************************************** + */ +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; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerConfiguration.java b/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerConfiguration.java new file mode 100644 index 00000000..c1e5957c --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerConfiguration.java @@ -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 + * **************************************************************************** + */ +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()); + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerController.java b/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerController.java new file mode 100644 index 00000000..235411f0 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerController.java @@ -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 + * **************************************************************************** + */ +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); + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerDisabledEditor.java b/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerDisabledEditor.java new file mode 100644 index 00000000..fa57a36f --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerDisabledEditor.java @@ -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 + * **************************************************************************** + */ +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 { + 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(); + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerEditor.java b/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerEditor.java new file mode 100644 index 00000000..625ac984 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/network/NetworkTunerEditor.java @@ -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 + * **************************************************************************** + */ +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 { + 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 mSampleRateCombo; + private JCheckBox mGainAutoCheckBox; + private JComboBox 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("

rtl_tcp Network Tuner

"); + sb.append("Host: ").append(getConfiguration().getHost()).append("
"); + sb.append("Port: ").append(getConfiguration().getPort()).append("
"); + sb.append("Tuner Type: rtl_tcp
"); + return sb.toString(); + } + + private JComboBox 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 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(); + } + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/ui/TunerViewPanel.java b/src/main/java/io/github/dsheirer/source/tuner/ui/TunerViewPanel.java index 4b37d28d..513d8e31 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/ui/TunerViewPanel.java +++ b/src/main/java/io/github/dsheirer/source/tuner/ui/TunerViewPanel.java @@ -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 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; } /** diff --git a/src/test/java/io/github/dsheirer/source/tuner/network/NetworkTunerControllerTest.java b/src/test/java/io/github/dsheirer/source/tuner/network/NetworkTunerControllerTest.java new file mode 100644 index 00000000..b85c76ca --- /dev/null +++ b/src/test/java/io/github/dsheirer/source/tuner/network/NetworkTunerControllerTest.java @@ -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 + * **************************************************************************** + */ + +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); + } +}