Compare commits

..

2 Commits

Author SHA1 Message Date
denny
66c5727145 Initial Signal Hound tuner support. 2026-11-12 07:02:07 -05:00
Denny Sheirer
a53f6e9d99 #2628 Remove ThumbDV code support. (#2629)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-12 05:18:52 -05:00
16 changed files with 2853 additions and 0 deletions

View File

@@ -132,6 +132,7 @@ dependencies {
implementation 'io.github.dsheirer:jlimesdr:25.1.0.2' //LimeSDR tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jrtl:1.4.0.3' //RTL-SDR tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jsignalhound:2026.10.05' //Signal Hound tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:radio-reference-api:18.0.0'
implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib
implementation 'org.apache.commons:commons-lang3:3.21.0'

View File

@@ -75,6 +75,15 @@ dependencyResolutionManagement {
content { includeModule 'io.github.dsheirer', 'jsdrplay' }
}
//jSignalHound is published as a GitHub release. The jar does not contain the Signal Hound API native libraries,
//which users install separately from the Signal Hound SDK
ivy {
url = 'https://github.com/DSheirer/jSignalHound/releases/download'
patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' }
metadataSources { artifact() }
content { includeModule 'io.github.dsheirer', 'jsignalhound' }
}
//jBladeRF is published as a GitHub release, with the native libraries for all platforms in the jar
ivy {
url = 'https://github.com/DSheirer/jBladeRF/releases/download'

View File

@@ -35,6 +35,7 @@ public enum TunerClass
LIMESDR("LimeSDR"),
RTL2832("RTL-2832"),
RSP("RSP"),
SIGNAL_HOUND("Signal Hound"),
TEST_TUNER("Test"),
RECORDING_TUNER("Recording"),
UNKNOWN("Unknown" );

View File

@@ -24,6 +24,7 @@ import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.source.ChannelizerType;
import io.github.dsheirer.sdrplay.DeviceInfo;
import io.github.dsheirer.sdrplay.RspModel;
import io.github.dsheirer.signalhound.DeviceDescriptor;
import io.github.dsheirer.source.SourceException;
import io.github.dsheirer.source.mixer.MixerManager;
import io.github.dsheirer.source.tuner.airspy.AirspyTuner;
@@ -121,6 +122,11 @@ import io.github.dsheirer.source.tuner.sdrplay.rspDx.IControlRspDx;
import io.github.dsheirer.source.tuner.sdrplay.rspDx.RspDxTunerConfiguration;
import io.github.dsheirer.source.tuner.sdrplay.rspDx.RspDxTunerController;
import io.github.dsheirer.source.tuner.sdrplay.rspDx.RspDxTunerEditor;
import io.github.dsheirer.source.tuner.signalhound.DiscoveredSignalHoundTuner;
import io.github.dsheirer.source.tuner.signalhound.SignalHoundTuner;
import io.github.dsheirer.source.tuner.signalhound.SignalHoundTunerConfiguration;
import io.github.dsheirer.source.tuner.signalhound.SignalHoundTunerController;
import io.github.dsheirer.source.tuner.signalhound.SignalHoundTunerEditor;
import io.github.dsheirer.source.tuner.ui.TunerEditor;
import java.util.ArrayList;
import java.util.List;
@@ -276,6 +282,20 @@ public class TunerFactory
}
}
/**
* Creates a Signal Hound tuner
* @param descriptor of the device from the Signal Hound API device list
* @param channelizerType for the tuner
* @param tunerErrorListener to listen for errors from the tuner
* @return tuner
*/
public static SignalHoundTuner getSignalHoundTuner(DeviceDescriptor descriptor, ChannelizerType channelizerType,
ITunerErrorListener tunerErrorListener)
{
return new SignalHoundTuner(new SignalHoundTunerController(descriptor, tunerErrorListener), tunerErrorListener,
channelizerType);
}
/**
* Create a USB tuner
* @param tunerClass to instantiate
@@ -380,6 +400,8 @@ public class TunerFactory
return new RspDuoTuner2Configuration(uniqueID);
case RSP_DX:
return new RspDxTunerConfiguration(uniqueID);
case SIGNAL_HOUND:
return new SignalHoundTunerConfiguration(uniqueID);
default:
throw new IllegalArgumentException("Unrecognized tuner type [" + type.name() + "]");
}
@@ -448,6 +470,13 @@ public class TunerFactory
discoveredTuner.getClass());
case RECORDING_TUNER:
return new RecordingTunerEditor(userPreferences, tunerManager, discoveredTuner);
case SIGNAL_HOUND:
if(discoveredTuner instanceof DiscoveredSignalHoundTuner discoveredSignalHoundTuner)
{
return new SignalHoundTunerEditor(userPreferences, tunerManager, discoveredSignalHoundTuner);
}
throw new IllegalArgumentException("Unrecognized discovered Signal Hound tuner class: " +
discoveredTuner.getClass());
case RTL2832:
if(discoveredTuner.hasTuner())
{

View File

@@ -49,6 +49,7 @@ public enum TunerType
RSP_DUO_1("RSPduo Tuner 1"),
RSP_DUO_2("RSPduo Tuner 2"),
RSP_DX("RSPdx"),
SIGNAL_HOUND("Signal Hound"),
TEST("Test"),
RECORDING("Recording"),

View File

@@ -40,6 +40,7 @@ import io.github.dsheirer.source.tuner.rtl.fc2580.FC2580TunerConfiguration;
import io.github.dsheirer.source.tuner.rtl.r8x.r820t.R820TTunerConfiguration;
import io.github.dsheirer.source.tuner.rtl.r8x.r828d.R828DTunerConfiguration;
import io.github.dsheirer.source.tuner.sdrplay.RspTunerConfiguration;
import io.github.dsheirer.source.tuner.signalhound.SignalHoundTunerConfiguration;
/**
* Abstract class to hold a configuration for a specific type of tuner
@@ -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 = SignalHoundTunerConfiguration.class, name = "signalHoundTunerConfiguration"),
})
@JacksonXmlRootElement(localName = "tuner_configuration")
public abstract class TunerConfiguration

View File

@@ -32,6 +32,10 @@ import io.github.dsheirer.preference.source.ChannelizerType;
import io.github.dsheirer.sdrplay.DeviceInfo;
import io.github.dsheirer.sdrplay.SDRplay;
import io.github.dsheirer.sdrplay.SDRplayException;
import io.github.dsheirer.signalhound.DeviceDescriptor;
import io.github.dsheirer.signalhound.SignalHound;
import io.github.dsheirer.signalhound.SignalHoundApi;
import io.github.dsheirer.signalhound.SignalHoundException;
import io.github.dsheirer.source.Source;
import io.github.dsheirer.source.SourceException;
import io.github.dsheirer.source.config.SourceConfigTuner;
@@ -50,6 +54,7 @@ import io.github.dsheirer.source.tuner.configuration.TunerConfigurationManager;
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;
import io.github.dsheirer.source.tuner.signalhound.DiscoveredSignalHoundTuner;
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
import io.github.dsheirer.util.ThreadPool;
import java.util.ArrayList;
@@ -136,6 +141,8 @@ public class TunerManager implements IDiscoveredTunerStatusListener
discoverSdrPlayTuners();
}
//The Signal Hound API libraries link their own libusb (and FTDI library for the BB60), so they don't need jlibusb
discoverSignalHoundTuners();
discoverRecordingTuners();
}
@@ -390,6 +397,57 @@ public class TunerManager implements IDiscoveredTunerStatusListener
}
}
/**
* Discover Signal Hound tuners (BB60, SM200/SM435 and SP145) with each of the Signal Hound API libraries that is
* installed. The libraries are from the Signal Hound SDK and are installed separately. Networked (10GbE) SM
* devices aren't discovered.
*/
private void discoverSignalHoundTuners()
{
ChannelizerType channelizerType = mUserPreferences.getTunerPreference().getChannelizerType();
List<String> notInstalled = new ArrayList<>();
for(SignalHoundApi api : SignalHoundApi.values())
{
try
{
if(!api.isAvailable())
{
notInstalled.add(api.libraryName());
mLog.debug("Signal Hound " + api.libraryName() + " library not loaded - " +
api.loadFailure().orElse("unknown reason"));
continue;
}
mLog.info("Signal Hound " + api.libraryName() + " version [" + api.version() + "] loaded from [" +
api.location().orElse("unknown") + "]");
List<DeviceDescriptor> devices = SignalHound.devices(api);
mLog.info("Discovered [" + devices.size() + "] Signal Hound " + api.description() + " devices");
for(DeviceDescriptor device : devices)
{
DiscoveredSignalHoundTuner tuner = new DiscoveredSignalHoundTuner(device, channelizerType);
if(mDiscoveredTunerModel.getDiscoveredTuner(tuner.getId()) == null)
{
startAndConfigureTuner(tuner);
}
}
}
catch(SignalHoundException | RuntimeException | LinkageError e)
{
mLog.error("Unable to discover Signal Hound " + api.description() + " devices - " + e.getMessage());
}
}
if(!notInstalled.isEmpty())
{
mLog.info("Signal Hound API libraries not installed " + notInstalled + " - Signal Hound tuners for these " +
"libraries are not available");
}
}
/**
* Closes the SDRplay API discovery session.
*/

View File

@@ -0,0 +1,114 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* 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.signalhound;
import io.github.dsheirer.preference.source.ChannelizerType;
import io.github.dsheirer.signalhound.DeviceDescriptor;
import io.github.dsheirer.source.tuner.TunerClass;
import io.github.dsheirer.source.tuner.TunerFactory;
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
/**
* Signal Hound tuner discovered from the device list of a Signal Hound API.
*/
public class DiscoveredSignalHoundTuner extends DiscoveredTuner
{
private final DeviceDescriptor mDescriptor;
private final ChannelizerType mChannelizerType;
/**
* Constructs an instance
* @param descriptor of the device, from the Signal Hound API device list
* @param channelizerType to use for the tuner
*/
public DiscoveredSignalHoundTuner(DeviceDescriptor descriptor, ChannelizerType channelizerType)
{
mDescriptor = descriptor;
mChannelizerType = channelizerType;
}
/**
* Descriptor of the device
*/
public DeviceDescriptor getDescriptor()
{
return mDescriptor;
}
/**
* Device family
*/
public SignalHoundFamily getFamily()
{
return SignalHoundFamily.of(mDescriptor.api());
}
@Override
public TunerClass getTunerClass()
{
return TunerClass.SIGNAL_HOUND;
}
/**
* Constructs and starts the tuner
*/
@Override
public void start()
{
if(isAvailable() && !hasTuner())
{
mTuner = TunerFactory.getSignalHoundTuner(mDescriptor, mChannelizerType, this);
try
{
mTuner.start();
}
catch(Exception se)
{
setErrorMessage("Error starting tuner [" + getId() + "] - " + se.getMessage());
mTuner = null;
}
}
}
/**
* Tuner ID, which identifies the tuner configuration. The family is used instead of the model, since the APIs
* don't always report the model before the device is opened, and the ID must be the same each time.
*/
@Override
public String getId()
{
return getId(mDescriptor);
}
/**
* Tuner ID of a device.
*/
public static String getId(DeviceDescriptor descriptor)
{
return "Signal Hound " + SignalHoundFamily.of(descriptor.api()) + " SER#" + descriptor.serialNumber();
}
@Override
public String toString()
{
return getId();
}
}

View File

@@ -0,0 +1,34 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* 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.signalhound;
/**
* Signal Hound device details for display.
* @param model such as BB60D, SM200B or SP145
* @param serial number
* @param firmware version
* @param library name of the Signal Hound API library, such as bb_api
* @param libraryVersion of the Signal Hound API library
* @param libraryLocation where the Signal Hound API library was loaded from
*/
public record SignalHoundDeviceInformation(String model, int serial, String firmware, String library,
String libraryVersion, String libraryLocation)
{
}

View File

@@ -0,0 +1,180 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* 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.signalhound;
import io.github.dsheirer.signalhound.SignalHoundApi;
import java.util.Locale;
/**
* Signal Hound device families, one for each Signal Hound API, with the limits and front-end controls of each family
* from the SDK headers (bb_api.h, sm_api.h and sp_api.h).
*
* The family names are used in tuner IDs, which persist the tuner configurations, so they must not change.
*/
public enum SignalHoundFamily
{
/**
* BB60A, BB60C and BB60D. Gain (0-3) and attenuation (0-30 dB in 10 dB steps) are automatic unless both are set.
*/
BB60(SignalHoundApi.BB, "BB60", 40_000_000, 9_000L, 6_400_000_000L, 3, 10, true, false),
/**
* SM200A/B/C and SM435B/C. Attenuation 0-30 dB in 5 dB steps, and an RF preselector up to 645 MHz.
*/
SM(SignalHoundApi.SM, "SM", 50_000_000, 100_000L, 20_600_000_000L, 6, 5, false, true),
/**
* SP145A and SP145B. Attenuation 0-30 dB in 5 dB steps, and an RF preselector on the SP145B up to 520 MHz.
*/
SP145(SignalHoundApi.SP, "SP145", 61_440_000, 9_000L, 15_000_000_000L, 6, 5, false, true);
//SM435 models tune higher than the SM200
private static final long SM435_MAXIMUM_FREQUENCY = 44_200_000_000L;
private final SignalHoundApi mApi;
private final String mLabel;
private final int mBaseSampleRate;
private final long mMinimumFrequency;
private final long mMaximumFrequency;
private final int mMaximumAttenuation;
private final int mAttenuationStepDb;
private final boolean mHasGain;
private final boolean mHasPreselector;
SignalHoundFamily(SignalHoundApi api, String label, int baseSampleRate, long minimumFrequency,
long maximumFrequency, int maximumAttenuation, int attenuationStepDb, boolean hasGain,
boolean hasPreselector)
{
mApi = api;
mLabel = label;
mBaseSampleRate = baseSampleRate;
mMinimumFrequency = minimumFrequency;
mMaximumFrequency = maximumFrequency;
mMaximumAttenuation = maximumAttenuation;
mAttenuationStepDb = attenuationStepDb;
mHasGain = hasGain;
mHasPreselector = hasPreselector;
}
/**
* Family of the devices that an API drives.
*/
public static SignalHoundFamily of(SignalHoundApi api)
{
return switch(api)
{
case BB -> BB60;
case SM -> SM;
case SP -> SP145;
};
}
/**
* API that drives the family.
*/
public SignalHoundApi getApi()
{
return mApi;
}
/**
* I/Q sample rate before decimation, in Hertz.
*/
public int getBaseSampleRate()
{
return mBaseSampleRate;
}
/**
* Lowest tunable frequency, in Hertz.
*/
public long getMinimumFrequency()
{
return mMinimumFrequency;
}
/**
* Highest tunable frequency of the family's default model, in Hertz.
*/
public long getMaximumFrequency()
{
return mMaximumFrequency;
}
/**
* Highest tunable frequency of a model, in Hertz.
* @param model as reported by the device, such as SM435B
*/
public long getMaximumFrequency(String model)
{
if(this == SM && model != null && model.toUpperCase(Locale.ROOT).startsWith("SM435"))
{
return SM435_MAXIMUM_FREQUENCY;
}
return mMaximumFrequency;
}
/**
* Highest attenuator setting, where each setting is {@link #getAttenuationStepDb()} dB.
*/
public int getMaximumAttenuation()
{
return mMaximumAttenuation;
}
/**
* Attenuation in dB of each attenuator setting.
*/
public int getAttenuationStepDb()
{
return mAttenuationStepDb;
}
/**
* Indicates if the family has a manual gain setting, in addition to the attenuator.
*/
public boolean hasGain()
{
return mHasGain;
}
/**
* Highest gain setting, for families that have one.
*/
public int getMaximumGain()
{
return mHasGain ? 3 : 0;
}
/**
* Indicates if devices of the family can have an RF preselector. For the SP145, only the SP145B has one.
*/
public boolean hasPreselector()
{
return mHasPreselector;
}
@Override
public String toString()
{
return mLabel;
}
}

View File

@@ -0,0 +1,86 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* 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.signalhound;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
/**
* Signal Hound I/Q sample rate: the family's base sample rate divided by a power of two decimation.
* @param decimation power of two
* @param rate in Hertz
*/
public record SignalHoundSampleRate(int decimation, int rate)
{
//Decimations offered to the user. Higher decimations are supported by the APIs, but the sample rates are below
//what is useful for channelizing (600-780 kHz at the highest decimation offered here).
public static final int MAXIMUM_DECIMATION = 64;
public static final int DEFAULT_RATE = 10_000_000;
private static final DecimalFormat FORMAT = new DecimalFormat("0.00###");
/**
* Sample rates of a family, highest rate first. Every rate is an integer number of Hertz.
*/
public static List<SignalHoundSampleRate> getRates(SignalHoundFamily family)
{
List<SignalHoundSampleRate> rates = new ArrayList<>();
for(int decimation = 1; decimation <= MAXIMUM_DECIMATION; decimation *= 2)
{
rates.add(new SignalHoundSampleRate(decimation, family.getBaseSampleRate() / decimation));
}
return rates;
}
/**
* Finds the highest rate that doesn't exceed the requested rate.
* @param rates to search
* @param requested rate in Hertz
* @return closest rate, or the lowest rate when the requested rate is below all of them
*/
public static SignalHoundSampleRate getClosest(List<SignalHoundSampleRate> rates, int requested)
{
SignalHoundSampleRate closest = null;
SignalHoundSampleRate lowest = null;
for(SignalHoundSampleRate rate : rates)
{
if(rate.rate() <= requested && (closest == null || rate.rate() > closest.rate()))
{
closest = rate;
}
if(lowest == null || rate.rate() < lowest.rate())
{
lowest = rate;
}
}
return closest != null ? closest : lowest;
}
@Override
public String toString()
{
return FORMAT.format(rate / 1E6) + " MHz";
}
}

View File

@@ -0,0 +1,88 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* 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.signalhound;
import io.github.dsheirer.preference.source.ChannelizerType;
import io.github.dsheirer.source.tuner.ITunerErrorListener;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.TunerClass;
/**
* Signal Hound tuner: BB60, SM200/SM435 or SP145
*/
public class SignalHoundTuner extends Tuner
{
//The BB60 and SM200 have 14-bit ADCs. The samples are floats after the API's corrections and filtering.
private static final double SAMPLE_SIZE_14_BIT = 14.0;
//The API transfers 16-bit I and Q samples from the device (32 bits per complex sample)
private static final int LINK_BITS_PER_SAMPLE = 32;
/**
* Constructs an instance
* @param controller for the Signal Hound device
* @param tunerErrorListener to listen for errors from this tuner
* @param channelizerType for the channelizer
*/
public SignalHoundTuner(SignalHoundTunerController controller, ITunerErrorListener tunerErrorListener,
ChannelizerType channelizerType)
{
super(controller, tunerErrorListener, channelizerType);
}
/**
* Signal Hound tuner controller
*/
public SignalHoundTunerController getController()
{
return (SignalHoundTunerController)getTunerController();
}
@Override
public String getPreferredName()
{
SignalHoundDeviceInformation info = getController().getDeviceInfo();
String model = info != null ? info.model() : getController().getFamily().toString();
return "Signal Hound " + model + " " + getUniqueID();
}
@Override
public String getUniqueID()
{
return Integer.toString(getController().getDescriptor().serialNumber());
}
@Override
public TunerClass getTunerClass()
{
return TunerClass.SIGNAL_HOUND;
}
@Override
public double getSampleSize()
{
return SAMPLE_SIZE_14_BIT;
}
@Override
public int getMaximumUSBBitsPerSecond()
{
return getController().getFamily().getBaseSampleRate() * LINK_BITS_PER_SAMPLE;
}
}

View File

@@ -0,0 +1,131 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* 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.signalhound;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import io.github.dsheirer.source.tuner.TunerType;
import io.github.dsheirer.source.tuner.configuration.TunerConfiguration;
/**
* Signal Hound tuner configuration, for devices of all Signal Hound families.
*/
public class SignalHoundTunerConfiguration extends TunerConfiguration
{
private int mSampleRate = SignalHoundSampleRate.DEFAULT_RATE;
private int mReferenceLevel = SignalHoundTunerController.REFERENCE_LEVEL_DEFAULT;
private int mAttenuation = SignalHoundTunerController.AUTOMATIC;
private int mGain = SignalHoundTunerController.AUTOMATIC;
private boolean mPreselector = false;
/**
* Default constructor for Jackson
*/
public SignalHoundTunerConfiguration()
{
super(SignalHoundTunerController.MINIMUM_TUNABLE_FREQUENCY_HZ,
SignalHoundTunerController.MAXIMUM_TUNABLE_FREQUENCY_HZ);
}
/**
* Constructs an instance
* @param uniqueID for the tuner
*/
public SignalHoundTunerConfiguration(String uniqueID)
{
super(uniqueID);
}
@Override
@JacksonXmlProperty(isAttribute = true, localName = "type", namespace = "http://www.w3.org/2001/XMLSchema-instance")
public TunerType getTunerType()
{
return TunerType.SIGNAL_HOUND;
}
/**
* Sample rate in Hertz
*/
@JacksonXmlProperty(isAttribute = true, localName = "sample_rate")
public int getSampleRate()
{
return mSampleRate;
}
public void setSampleRate(int sampleRate)
{
mSampleRate = sampleRate;
}
/**
* Reference level in dBm: the strongest expected input level, which sets the gain and attenuation automatically.
*/
@JacksonXmlProperty(isAttribute = true, localName = "reference_level")
public int getReferenceLevel()
{
return mReferenceLevel;
}
public void setReferenceLevel(int referenceLevel)
{
mReferenceLevel = referenceLevel;
}
/**
* Attenuator setting, or -1 for automatic. The step size depends on the device family.
*/
@JacksonXmlProperty(isAttribute = true, localName = "attenuation")
public int getAttenuation()
{
return mAttenuation;
}
public void setAttenuation(int attenuation)
{
mAttenuation = attenuation;
}
/**
* Gain setting (BB60 only), or -1 for automatic.
*/
@JacksonXmlProperty(isAttribute = true, localName = "gain")
public int getGain()
{
return mGain;
}
public void setGain(int gain)
{
mGain = gain;
}
/**
* RF preselector (SM200/SM435 and SP145B) enabled.
*/
@JacksonXmlProperty(isAttribute = true, localName = "preselector")
public boolean isPreselector()
{
return mPreselector;
}
public void setPreselector(boolean preselector)
{
mPreselector = preselector;
}
}

View File

@@ -0,0 +1,534 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* 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.signalhound;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.signalhound.Diagnostics;
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.ui.TunerEditor;
import java.io.Serial;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import net.miginfocom.swing.MigLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.DefaultComboBoxModel;
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.JSlider;
import javax.swing.SpinnerNumberModel;
/**
* Signal Hound tuner editor
*/
public class SignalHoundTunerEditor extends TunerEditor<SignalHoundTuner, SignalHoundTunerConfiguration>
{
@Serial
private static final long serialVersionUID = 1L;
private static final Logger mLog = LoggerFactory.getLogger(SignalHoundTunerEditor.class);
private final SignalHoundFamily mFamily;
private JButton mTunerInfoButton;
private JComboBox<SignalHoundSampleRate> mSampleRateCombo;
private JSlider mReferenceLevelSlider;
private JLabel mReferenceLevelValueLabel;
private JComboBox<Setting> mAttenuationCombo;
private JComboBox<Setting> mGainCombo;
private JCheckBox mPreselectorCheckBox;
/**
* Constructs an instance
* @param userPreferences for wide-band recordings
* @param tunerManager for saving tuner configuration
* @param discoveredTuner for the optionally usable Signal Hound tuner and controller
*/
public SignalHoundTunerEditor(UserPreferences userPreferences, TunerManager tunerManager,
DiscoveredSignalHoundTuner discoveredTuner)
{
super(userPreferences, tunerManager, discoveredTuner);
mFamily = discoveredTuner.getFamily();
init();
tunerStatusUpdated();
}
@Override
public long getMinimumTunableFrequency()
{
return hasTuner() ? getTuner().getController().getMinimumTunableFrequency() :
SignalHoundTunerController.MINIMUM_TUNABLE_FREQUENCY_HZ;
}
@Override
public long getMaximumTunableFrequency()
{
return hasTuner() ? getTuner().getController().getMaximumTunableFrequency() :
SignalHoundTunerController.MAXIMUM_TUNABLE_FREQUENCY_HZ;
}
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(), "wrap");
add(getButtonPanel(), "span,align left");
add(new JSeparator(), "span,growx");
add(new JLabel("Frequency (MHz):"));
add(getFrequencyPanel(), "wrap");
add(new JLabel("Sample Rate:"));
add(getSampleRateCombo(), "wrap");
add(new JSeparator(), "span,growx");
add(new JLabel("Reference Level:"));
add(getReferenceLevelSlider());
add(getReferenceLevelValueLabel());
add(new JLabel("Attenuation:"));
add(getAttenuationCombo(), "wrap");
if(mFamily.hasGain())
{
add(new JLabel("Gain:"));
add(getGainCombo(), "wrap");
}
if(mFamily.hasPreselector())
{
add(new JLabel(""));
add(getPreselectorCheckBox(), "wrap");
}
//Empty row that takes all extra vertical space so that the controls stay stacked at the top of the editor.
add(new JLabel(), "span,pushy");
}
@Override
protected void tunerStatusUpdated()
{
setLoading(true);
boolean started = hasStartedTuner();
getTunerIdLabel().setText(started ? getTuner().getPreferredName() : getDiscoveredTuner().getId());
String status = getDiscoveredTuner().getTunerStatus().toString();
if(getDiscoveredTuner().hasErrorMessage())
{
status += " - " + getDiscoveredTuner().getErrorMessage();
}
getTunerStatusLabel().setText(status);
getButtonPanel().updateControls();
getFrequencyPanel().updateControls();
getTunerInfoButton().setEnabled(started);
if(started)
{
SignalHoundTunerController controller = getTuner().getController();
List<SignalHoundSampleRate> rates = controller.getSampleRates();
getSampleRateCombo().setModel(new DefaultComboBoxModel<>(rates.toArray(new SignalHoundSampleRate[0])));
getSampleRateCombo().setSelectedItem(controller.getSignalHoundSampleRate());
getSampleRateCombo().setEnabled(!controller.isLockedSampleRate());
getReferenceLevelSlider().setValue(controller.getReferenceLevel());
getReferenceLevelSlider().setEnabled(true);
getReferenceLevelValueLabel().setEnabled(true);
getAttenuationCombo().setSelectedItem(new Setting(controller.getAttenuation(), null));
getAttenuationCombo().setEnabled(true);
if(mFamily.hasGain())
{
getGainCombo().setSelectedItem(new Setting(controller.getGain(), null));
getGainCombo().setEnabled(true);
}
if(mFamily.hasPreselector())
{
getPreselectorCheckBox().setSelected(controller.isPreselector());
getPreselectorCheckBox().setEnabled(controller.isPreselectorSupported());
}
}
else
{
getSampleRateCombo().setModel(new DefaultComboBoxModel<>());
getSampleRateCombo().setEnabled(false);
getReferenceLevelSlider().setEnabled(false);
getReferenceLevelValueLabel().setEnabled(false);
getAttenuationCombo().setEnabled(false);
if(mFamily.hasGain())
{
getGainCombo().setEnabled(false);
}
if(mFamily.hasPreselector())
{
getPreselectorCheckBox().setEnabled(false);
}
}
updateReferenceLevelLabel();
setLoading(false);
}
/**
* Indicates if the tuner has finished starting, since a status update can arrive while it's starting or restarting.
*/
private boolean hasStartedTuner()
{
return hasTuner() && getTuner().getController().getSignalHoundSampleRate() != null;
}
private void updateReferenceLevelLabel()
{
getReferenceLevelValueLabel().setText(getReferenceLevelSlider().getValue() + " dBm");
}
/**
* Button that provides tuner information
*/
private JButton getTunerInfoButton()
{
if(mTunerInfoButton == null)
{
mTunerInfoButton = new JButton("Info");
mTunerInfoButton.setEnabled(false);
mTunerInfoButton.addActionListener(_ -> JOptionPane.showMessageDialog(SignalHoundTunerEditor.this,
getTunerInfo(), "Tuner Info", JOptionPane.INFORMATION_MESSAGE));
}
return mTunerInfoButton;
}
private JComboBox<SignalHoundSampleRate> getSampleRateCombo()
{
if(mSampleRateCombo == null)
{
mSampleRateCombo = new JComboBox<>();
mSampleRateCombo.setEnabled(false);
mSampleRateCombo.addActionListener(_ ->
{
if(hasTuner() && !isLoading())
{
SignalHoundSampleRate rate = (SignalHoundSampleRate)mSampleRateCombo.getSelectedItem();
try
{
getTuner().getController().setSampleRate(rate);
//Adjust the min/max values for the sample rate.
adjustForSampleRate(rate.rate());
save();
}
catch(Exception e1)
{
mLog.error("Error setting Signal Hound sample rate", e1);
JOptionPane.showMessageDialog(SignalHoundTunerEditor.this, "Couldn't set sample rate to " +
rate + " - " + e1.getMessage());
}
}
});
}
return mSampleRateCombo;
}
private JSlider getReferenceLevelSlider()
{
if(mReferenceLevelSlider == null)
{
mReferenceLevelSlider = new JSlider(JSlider.HORIZONTAL, SignalHoundTunerController.REFERENCE_LEVEL_MINIMUM,
SignalHoundTunerController.REFERENCE_LEVEL_MAXIMUM,
SignalHoundTunerController.REFERENCE_LEVEL_DEFAULT);
mReferenceLevelSlider.setEnabled(false);
mReferenceLevelSlider.setMajorTickSpacing(10);
mReferenceLevelSlider.setPaintTicks(true);
mReferenceLevelSlider.setToolTipText("Strongest expected input level. Lower values increase the gain. " +
"Raise it when the log reports ADC overflow warnings.");
mReferenceLevelSlider.addChangeListener(_ ->
{
updateReferenceLevelLabel();
//Reconfigure the stream once the user releases the slider, since each change restarts the stream
if(hasTuner() && !isLoading() && !mReferenceLevelSlider.getValueIsAdjusting())
{
int referenceLevel = mReferenceLevelSlider.getValue();
try
{
getTuner().getController().setReferenceLevel(referenceLevel);
save();
}
catch(Exception e)
{
mLog.error("Couldn't set Signal Hound reference level to:" + referenceLevel, e);
JOptionPane.showMessageDialog(mReferenceLevelSlider, "Couldn't set reference level to " +
referenceLevel + " dBm");
}
}
});
}
return mReferenceLevelSlider;
}
private JLabel getReferenceLevelValueLabel()
{
if(mReferenceLevelValueLabel == null)
{
mReferenceLevelValueLabel = new JLabel("0 dBm");
mReferenceLevelValueLabel.setEnabled(false);
}
return mReferenceLevelValueLabel;
}
private JComboBox<Setting> getAttenuationCombo()
{
if(mAttenuationCombo == null)
{
List<Setting> settings = new ArrayList<>();
settings.add(new Setting(SignalHoundTunerController.AUTOMATIC, "Automatic (Reference Level)"));
for(int x = 0; x <= mFamily.getMaximumAttenuation(); x++)
{
settings.add(new Setting(x, (x * mFamily.getAttenuationStepDb()) + " dB"));
}
mAttenuationCombo = new JComboBox<>(settings.toArray(new Setting[0]));
mAttenuationCombo.setEnabled(false);
mAttenuationCombo.setToolTipText(mFamily.hasGain() ? "The BB60 uses manual attenuation only when the " +
"gain is also manual" : "Input attenuation, or automatic to set it from the reference level");
mAttenuationCombo.addActionListener(_ ->
{
if(hasTuner() && !isLoading() && mAttenuationCombo.getSelectedItem() instanceof Setting setting)
{
try
{
getTuner().getController().setAttenuation(setting.value());
save();
}
catch(Exception e)
{
mLog.error("Couldn't set Signal Hound attenuation to:" + setting, e);
JOptionPane.showMessageDialog(mAttenuationCombo, "Couldn't set attenuation to " + setting);
}
}
});
}
return mAttenuationCombo;
}
private JComboBox<Setting> getGainCombo()
{
if(mGainCombo == null)
{
List<Setting> settings = new ArrayList<>();
settings.add(new Setting(SignalHoundTunerController.AUTOMATIC, "Automatic (Reference Level)"));
for(int x = 0; x <= mFamily.getMaximumGain(); x++)
{
settings.add(new Setting(x, Integer.toString(x)));
}
mGainCombo = new JComboBox<>(settings.toArray(new Setting[0]));
mGainCombo.setEnabled(false);
mGainCombo.setToolTipText("The BB60 uses manual gain only when the attenuation is also manual");
mGainCombo.addActionListener(_ ->
{
if(hasTuner() && !isLoading() && mGainCombo.getSelectedItem() instanceof Setting setting)
{
try
{
getTuner().getController().setGain(setting.value());
save();
}
catch(Exception e)
{
mLog.error("Couldn't set Signal Hound gain to:" + setting, e);
JOptionPane.showMessageDialog(mGainCombo, "Couldn't set gain to " + setting);
}
}
});
}
return mGainCombo;
}
private JCheckBox getPreselectorCheckBox()
{
if(mPreselectorCheckBox == null)
{
mPreselectorCheckBox = new JCheckBox("RF Preselector");
mPreselectorCheckBox.setEnabled(false);
mPreselectorCheckBox.setToolTipText(mFamily == SignalHoundFamily.SM ?
"Filters the input to reject strong out-of-band signals, up to 645 MHz" :
"SP145B only: filters the input to reject strong out-of-band signals, up to 520 MHz");
mPreselectorCheckBox.addActionListener(_ ->
{
if(hasTuner() && !isLoading())
{
boolean enabled = mPreselectorCheckBox.isSelected();
try
{
getTuner().getController().setPreselector(enabled);
save();
}
catch(Exception e)
{
mLog.error("Couldn't " + (enabled ? "enable" : "disable") + " Signal Hound preselector", e);
JOptionPane.showMessageDialog(mPreselectorCheckBox, "Couldn't change preselector - " +
e.getMessage());
}
}
});
}
return mPreselectorCheckBox;
}
@Override
public void save()
{
if(hasConfiguration() && !isLoading())
{
getConfiguration().setFrequency(getFrequencyControl().getFrequency());
getConfiguration().setMinimumFrequency(getMinimumFrequencyTextField().getFrequency());
getConfiguration().setMaximumFrequency(getMaximumFrequencyTextField().getFrequency());
double value = ((SpinnerNumberModel)getFrequencyCorrectionSpinner().getModel()).getNumber().doubleValue();
getConfiguration().setFrequencyCorrection(value);
getConfiguration().setAutoPPMCorrectionEnabled(getAutoPPMCheckBox().isSelected());
if(getSampleRateCombo().getSelectedItem() instanceof SignalHoundSampleRate rate)
{
getConfiguration().setSampleRate(rate.rate());
}
getConfiguration().setReferenceLevel(getReferenceLevelSlider().getValue());
if(getAttenuationCombo().getSelectedItem() instanceof Setting setting)
{
getConfiguration().setAttenuation(setting.value());
}
if(mFamily.hasGain() && getGainCombo().getSelectedItem() instanceof Setting setting)
{
getConfiguration().setGain(setting.value());
}
if(mFamily.hasPreselector())
{
getConfiguration().setPreselector(getPreselectorCheckBox().isSelected());
}
saveConfiguration();
}
}
@Override
public void setTunerLockState(boolean locked)
{
getFrequencyPanel().updateControls();
getSampleRateCombo().setEnabled(!locked);
getSampleRateCombo().setToolTipText(locked ? "Sample Rate is locked. Disable decoding channels to unlock." :
"Select a sample rate for the tuner");
}
private String getTunerInfo()
{
if(getDiscoveredTuner().getTunerStatus() == TunerStatus.ERROR)
{
return getDiscoveredTuner().getErrorMessage();
}
if(hasTuner())
{
SignalHoundDeviceInformation info = getTuner().getController().getDeviceInfo();
StringBuilder sb = new StringBuilder();
sb.append("<html><h3>Signal Hound Tuner</h3>");
sb.append("<b>Model: </b>").append(info.model()).append("<br>");
sb.append("<b>Serial: </b>").append(info.serial()).append("<br>");
sb.append("<b>Firmware: </b>").append(info.firmware()).append("<br>");
sb.append("<b>Library: </b>").append(info.library()).append(" ").append(info.libraryVersion()).append("<br>");
sb.append("<b>Location: </b>").append(info.libraryLocation()).append("<br>");
Diagnostics diagnostics = getTuner().getController().getDiagnostics();
if(diagnostics != null)
{
sb.append("<b>Temperature: </b>").append(String.format(Locale.ROOT, "%.1f C",
diagnostics.temperature())).append("<br>");
sb.append("<b>Voltage: </b>").append(String.format(Locale.ROOT, "%.2f V",
diagnostics.voltage())).append("<br>");
sb.append("<b>Current: </b>").append(String.format(Locale.ROOT, "%.3f A",
diagnostics.current())).append("<br>");
}
return sb.toString();
}
return null;
}
/**
* Attenuation or gain combo box entry. Entries are equal when their values are equal, so that the entry for a
* value can be selected with a new instance.
* @param value of the setting, or -1 for automatic
* @param label to display
*/
record Setting(int value, String label)
{
@Override
public boolean equals(Object o)
{
return o instanceof Setting other && other.value == value;
}
@Override
public int hashCode()
{
return Integer.hashCode(value);
}
@Override
public String toString()
{
return label;
}
}
}

View File

@@ -0,0 +1,499 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* 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.signalhound;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.dsheirer.buffer.INativeBuffer;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.signalhound.DeviceDescriptor;
import io.github.dsheirer.signalhound.Diagnostics;
import io.github.dsheirer.signalhound.IqBlock;
import io.github.dsheirer.signalhound.IqBuffer;
import io.github.dsheirer.signalhound.IqConfig;
import io.github.dsheirer.signalhound.IqDataType;
import io.github.dsheirer.signalhound.IqStreamInfo;
import io.github.dsheirer.signalhound.SignalHoundApi;
import io.github.dsheirer.signalhound.SignalHoundDevice;
import io.github.dsheirer.signalhound.SignalHoundException;
import io.github.dsheirer.signalhound.Sweep;
import io.github.dsheirer.signalhound.SweepConfig;
import io.github.dsheirer.signalhound.SweepInfo;
import io.github.dsheirer.source.SourceException;
import io.github.dsheirer.source.tuner.ITunerErrorListener;
import io.github.dsheirer.source.tuner.TunerFactory;
import io.github.dsheirer.source.tuner.TunerType;
import io.github.dsheirer.source.tuner.configuration.TunerConfiguration;
import java.lang.foreign.ValueLayout;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
/**
* Tests for the Signal Hound tuner, using a simulated device since the tests can't assume Signal Hound hardware.
*/
class SignalHoundTunerTest
{
private static final DeviceDescriptor SM200 = new DeviceDescriptor(SignalHoundApi.SM, 1234, "SM200B");
@Test
void sampleRatesAreTheBaseRateDecimatedByPowersOfTwo()
{
List<SignalHoundSampleRate> bb60 = SignalHoundSampleRate.getRates(SignalHoundFamily.BB60);
assertEquals(new SignalHoundSampleRate(1, 40_000_000), bb60.getFirst());
assertEquals(new SignalHoundSampleRate(64, 625_000), bb60.getLast());
assertEquals(new SignalHoundSampleRate(64, 781_250), SignalHoundSampleRate.getRates(SignalHoundFamily.SM).getLast());
assertEquals(new SignalHoundSampleRate(64, 960_000), SignalHoundSampleRate.getRates(SignalHoundFamily.SP145).getLast());
for(SignalHoundFamily family : SignalHoundFamily.values())
{
for(SignalHoundSampleRate rate : SignalHoundSampleRate.getRates(family))
{
assertEquals(family.getBaseSampleRate(), (long)rate.rate() * rate.decimation(), "Rate " + rate);
}
}
}
@Test
void closestRateDoesNotExceedTheRequestedRate()
{
List<SignalHoundSampleRate> bb60 = SignalHoundSampleRate.getRates(SignalHoundFamily.BB60);
assertEquals(10_000_000, SignalHoundSampleRate.getClosest(bb60, 10_000_000).rate());
assertEquals(6_250_000, SignalHoundSampleRate.getClosest(SignalHoundSampleRate.getRates(SignalHoundFamily.SM),
10_000_000).rate());
assertEquals(7_680_000, SignalHoundSampleRate.getClosest(
SignalHoundSampleRate.getRates(SignalHoundFamily.SP145), 10_000_000).rate());
assertEquals(625_000, SignalHoundSampleRate.getClosest(bb60, 1).rate());
assertEquals("1.5625 MHz", new SignalHoundSampleRate(32, 1_562_500).toString());
assertEquals("10.00 MHz", new SignalHoundSampleRate(4, 10_000_000).toString());
}
@Test
void familyLimits()
{
assertEquals(SignalHoundFamily.SM, SignalHoundFamily.of(SignalHoundApi.SM));
assertEquals(20_600_000_000L, SignalHoundFamily.SM.getMaximumFrequency("SM200B"));
assertEquals(44_200_000_000L, SignalHoundFamily.SM.getMaximumFrequency("SM435C"));
assertEquals(6_400_000_000L, SignalHoundFamily.BB60.getMaximumFrequency("BB60D"));
assertTrue(SignalHoundFamily.BB60.hasGain());
assertFalse(SignalHoundFamily.SM.hasGain());
assertEquals(SignalHoundTunerController.MAXIMUM_TUNABLE_FREQUENCY_HZ,
SignalHoundFamily.SM.getMaximumFrequency("SM435B"));
}
@Test
void readsAreAbout10MillisecondsAsAPowerOfTwo()
{
assertEquals(65536, SignalHoundTunerController.getBufferSampleCount(10_000_000));
assertEquals(8192, SignalHoundTunerController.getBufferSampleCount(625_000));
assertEquals(524288, SignalHoundTunerController.getBufferSampleCount(61_440_000));
assertEquals(8192, SignalHoundTunerController.getBufferSampleCount(0));
}
@Test
void referenceLevelIsFullScale()
{
//A -20 dBm signal has an amplitude of sqrt(0.01 mW) = 0.1
assertEquals(10.0f, SignalHoundTunerController.getSampleScale(-20), 1e-5f);
assertEquals(1.0f, SignalHoundTunerController.getSampleScale(0), 1e-6f);
assertEquals(0.1f, SignalHoundTunerController.getSampleScale(20), 1e-6f);
}
@Test
void usableBandwidthComesFromTheStreamBandwidth()
{
assertEquals(0.75, SignalHoundTunerController.getUsableBandwidthPercent(
new IqStreamInfo(40e6, 30e6, IqDataType.FLOAT32)), 1e-9);
assertEquals(0.9, SignalHoundTunerController.getUsableBandwidthPercent(
new IqStreamInfo(10e6, 9.9e6, IqDataType.FLOAT32)), 1e-9);
assertEquals(0.5, SignalHoundTunerController.getUsableBandwidthPercent(
new IqStreamInfo(10e6, 1e6, IqDataType.FLOAT32)), 1e-9);
}
@Test
void invalidSettingsAreAutomatic()
{
assertEquals(3, SignalHoundTunerController.getValidSetting(3, 3));
assertEquals(SignalHoundTunerController.AUTOMATIC, SignalHoundTunerController.getValidSetting(4, 3));
assertEquals(SignalHoundTunerController.AUTOMATIC, SignalHoundTunerController.getValidSetting(-5, 3));
}
@Test
void idUsesTheFamilyAndSerialNumber()
{
assertEquals("Signal Hound SM SER#1234", DiscoveredSignalHoundTuner.getId(SM200));
assertEquals("Signal Hound SP145 SER#99", DiscoveredSignalHoundTuner.getId(
new DeviceDescriptor(SignalHoundApi.SP, 99, "SP145")));
}
@Test
void configurationRoundTrips() throws Exception
{
SignalHoundTunerConfiguration configuration = new SignalHoundTunerConfiguration("test");
configuration.setSampleRate(5_000_000);
configuration.setReferenceLevel(-40);
configuration.setAttenuation(2);
configuration.setGain(1);
configuration.setPreselector(true);
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
TunerConfiguration restored = mapper.readValue(mapper.writeValueAsString(configuration),
TunerConfiguration.class);
SignalHoundTunerConfiguration signalHound = assertInstanceOf(SignalHoundTunerConfiguration.class, restored);
assertEquals(TunerType.SIGNAL_HOUND, signalHound.getTunerType());
assertEquals(5_000_000, signalHound.getSampleRate());
assertEquals(-40, signalHound.getReferenceLevel());
assertEquals(2, signalHound.getAttenuation());
assertEquals(1, signalHound.getGain());
assertTrue(signalHound.isPreselector());
assertEquals("test", signalHound.getUniqueID());
assertInstanceOf(SignalHoundTunerConfiguration.class,
TunerFactory.getTunerConfiguration(TunerType.SIGNAL_HOUND, "id"));
}
@Test
void streamsScaledSamplesAndReconfiguresForChanges() throws Exception
{
FakeDevice device = new FakeDevice(0.01f);
ErrorListener errors = new ErrorListener();
SignalHoundTunerController controller = new SignalHoundTunerController(SM200, errors, _ -> device);
controller.start();
assertEquals("SM200B", controller.getDeviceInfo().model());
assertEquals(20_600_000_000L, controller.getMaximumTunableFrequency());
assertEquals(6_250_000, controller.getSignalHoundSampleRate().rate());
assertFalse(controller.isPreselectorSupported(), "Only SmDevice and SP145B have a preselector");
assertTrue(device.configs.isEmpty(), "The stream isn't configured until there's a listener");
//Small, since each buffer is up to 512 KB. Buffers are dropped while it is full.
BlockingQueue<INativeBuffer> buffers = new ArrayBlockingQueue<>(16);
Listener<INativeBuffer> listener = buffers::offer;
controller.addBufferListener(listener);
//0.01 sqrt(mW) at the default -20 dBm reference level is 0.1 of full scale
assertEquals(0.1f, firstSample(buffers), 1e-6f);
IqConfig first = device.configs.getLast();
assertEquals(8, first.decimation());
assertEquals(-20.0, first.referenceLevel());
assertEquals(IqDataType.FLOAT32, first.dataType());
controller.setFrequency(460_000_000L);
assertEquals(460_000_000.0, device.configs.getLast().centerFrequency());
controller.setReferenceLevel(-40);
assertEquals(-40.0, device.configs.getLast().referenceLevel());
//A block read before the change can still be in flight to the listeners
awaitSample(buffers, 1.0f);
controller.setSampleRate(controller.getSampleRates().get(2));
assertEquals(4, device.configs.getLast().decimation());
assertEquals(12_500_000.0, controller.getSampleRate());
awaitSampleCount(buffers, 65536);
controller.removeBufferListener(listener);
assertFalse(device.streaming, "The stream stops without listeners");
assertTrue(device.stops > 0);
controller.stop();
assertTrue(device.closed);
assertTrue(errors.messages.isEmpty(), "Errors: " + errors.messages);
}
@Test
void settingsAreAppliedWhenTheStreamStarts() throws Exception
{
FakeDevice device = new FakeDevice(0.01f);
SignalHoundTunerController controller = new SignalHoundTunerController(SM200, new ErrorListener(),
_ -> device);
controller.start();
SignalHoundTunerConfiguration configuration = new SignalHoundTunerConfiguration("id");
configuration.setFrequency(851_000_000L);
configuration.setSampleRate(25_000_000);
configuration.setReferenceLevel(-100);
configuration.setAttenuation(9);
configuration.setGain(2);
configuration.setPreselector(true);
controller.apply(configuration);
//Settings that the device doesn't support are corrected in the configuration
assertEquals(SignalHoundTunerController.REFERENCE_LEVEL_MINIMUM, configuration.getReferenceLevel());
assertEquals(SignalHoundTunerController.AUTOMATIC, configuration.getAttenuation());
assertEquals(SignalHoundTunerController.AUTOMATIC, configuration.getGain());
assertFalse(configuration.isPreselector());
assertTrue(device.configs.isEmpty());
Listener<INativeBuffer> listener = _ -> {};
controller.addBufferListener(listener);
IqConfig config = device.configs.getLast();
assertEquals(851_000_000.0, config.centerFrequency());
assertEquals(2, config.decimation());
assertEquals(-60.0, config.referenceLevel());
assertEquals(1, device.configs.size(), "Configured once when the stream starts");
assertThrows(IllegalArgumentException.class, () -> controller.setGain(1), "The SM doesn't have gain");
assertThrows(SourceException.class, () -> controller.setPreselector(true));
controller.stop();
}
@Test
void readFailureStopsTheTunerWithAnError() throws Exception
{
FakeDevice device = new FakeDevice(0.01f);
ErrorListener errors = new ErrorListener();
SignalHoundTunerController controller = new SignalHoundTunerController(SM200, errors, _ -> device);
controller.start();
controller.addBufferListener(_ -> {});
device.readFailure = new SignalHoundException(SignalHoundApi.SM, "smGetIQ", -11, "Device disconnected");
String message = errors.messages.poll(5, TimeUnit.SECONDS);
assertNotNull(message);
assertTrue(message.contains("Device disconnected"), message);
controller.stop();
assertTrue(device.closed);
}
@Test
void startFailureClosesTheDevice()
{
SignalHoundTunerController controller = new SignalHoundTunerController(SM200, new ErrorListener(), _ -> {
throw new SignalHoundException(SignalHoundApi.SM, "smOpenDeviceBySerial", -1, "Device not found");
});
SourceException exception = assertThrows(SourceException.class, controller::start);
assertTrue(exception.getMessage().contains("Device not found"), exception.getMessage());
}
/**
* Waits for a buffer and returns its first I sample.
*/
private static float firstSample(BlockingQueue<INativeBuffer> buffers) throws InterruptedException
{
INativeBuffer buffer = buffers.poll(5, TimeUnit.SECONDS);
assertNotNull(buffer, "No samples received");
return buffer.iteratorInterleaved().next().samples()[0];
}
/**
* Waits for a buffer whose first I sample is the expected value.
*/
private static void awaitSample(BlockingQueue<INativeBuffer> buffers, float expected) throws InterruptedException
{
long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
float sample = Float.NaN;
while(System.nanoTime() < end)
{
sample = firstSample(buffers);
if(Math.abs(sample - expected) < 1e-5f)
{
return;
}
}
assertEquals(expected, sample, 1e-5f);
}
/**
* Waits for a buffer with the expected sample count.
*/
private static void awaitSampleCount(BlockingQueue<INativeBuffer> buffers, int expected) throws InterruptedException
{
long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
int count = 0;
while(System.nanoTime() < end)
{
INativeBuffer buffer = buffers.poll(5, TimeUnit.SECONDS);
assertNotNull(buffer, "No samples received");
count = buffer.sampleCount();
if(count == expected)
{
return;
}
}
assertEquals(expected, count);
}
private static class ErrorListener implements ITunerErrorListener
{
private final BlockingQueue<String> messages = new ArrayBlockingQueue<>(10);
@Override
public void setErrorMessage(String errorMessage)
{
messages.offer(errorMessage);
}
@Override
public void tunerRemoved()
{
}
}
/**
* Simulated SM200 that streams a constant sample value, reading as fast as the test reads.
*/
private static class FakeDevice implements SignalHoundDevice
{
private final float mValue;
private final List<IqConfig> configs = new CopyOnWriteArrayList<>();
private volatile boolean streaming;
private volatile boolean closed;
private volatile int stops;
private volatile SignalHoundException readFailure;
private volatile RuntimeException firmwareFailure;
FakeDevice(float value)
{
mValue = value;
}
@Override
public SignalHoundApi api()
{
return SignalHoundApi.SM;
}
@Override
public int serialNumber()
{
return 1234;
}
@Override
public String model()
{
return "SM200B";
}
@Override
public String firmwareVersion()
{
if(firmwareFailure != null)
{
throw firmwareFailure;
}
return "8.1.0";
}
@Override
public Diagnostics diagnostics()
{
return new Diagnostics(40.0f, 5.0f, 1.2f);
}
@Override
public synchronized IqStreamInfo startIq(IqConfig config)
{
configs.add(config);
streaming = true;
double rate = SignalHoundFamily.SM.getBaseSampleRate() / (double)config.decimation();
return new IqStreamInfo(rate, Math.min(config.bandwidth(), rate * 0.8), config.dataType());
}
@Override
public synchronized IqBlock readIq(IqBuffer buffer, boolean purge) throws SignalHoundException
{
if(!streaming)
{
throw new IllegalStateException("Not streaming");
}
if(readFailure != null)
{
throw readFailure;
}
buffer.segment().fill((byte)0);
for(int x = 0; x < buffer.capacity() * 2; x++)
{
buffer.segment().setAtIndex(ValueLayout.JAVA_FLOAT, x, mValue);
}
try
{
Thread.sleep(1);
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
return new IqBlock(buffer.capacity(), System.currentTimeMillis() * 1_000_000L, false, 0, null);
}
@Override
public float iqCorrection()
{
return 1.0f;
}
@Override
public SweepInfo startSweep(SweepConfig config)
{
throw new UnsupportedOperationException();
}
@Override
public Sweep readSweep()
{
throw new UnsupportedOperationException();
}
@Override
public synchronized void stop()
{
streaming = false;
stops++;
}
@Override
public synchronized boolean isClosed()
{
return closed;
}
@Override
public synchronized void close()
{
streaming = false;
closed = true;
}
}
}