From 66c5727145784162ce002f5e9b37f76e1069e751 Mon Sep 17 00:00:00 2001 From: denny Date: Thu, 12 Nov 2026 07:02:07 -0500 Subject: [PATCH] Initial Signal Hound tuner support. --- build.gradle | 1 + settings.gradle | 9 + .../dsheirer/source/tuner/TunerClass.java | 1 + .../dsheirer/source/tuner/TunerFactory.java | 29 + .../dsheirer/source/tuner/TunerType.java | 1 + .../configuration/TunerConfiguration.java | 2 + .../source/tuner/manager/TunerManager.java | 58 + .../DiscoveredSignalHoundTuner.java | 114 ++ .../SignalHoundDeviceInformation.java | 34 + .../tuner/signalhound/SignalHoundFamily.java | 180 +++ .../signalhound/SignalHoundSampleRate.java | 86 ++ .../tuner/signalhound/SignalHoundTuner.java | 88 ++ .../SignalHoundTunerConfiguration.java | 131 ++ .../SignalHoundTunerController.java | 1086 +++++++++++++++++ .../signalhound/SignalHoundTunerEditor.java | 534 ++++++++ .../signalhound/SignalHoundTunerTest.java | 499 ++++++++ 16 files changed, 2853 insertions(+) create mode 100644 src/main/java/io/github/dsheirer/source/tuner/signalhound/DiscoveredSignalHoundTuner.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundDeviceInformation.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundFamily.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundSampleRate.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTuner.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerConfiguration.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerController.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerEditor.java create mode 100644 src/test/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerTest.java diff --git a/build.gradle b/build.gradle index fa7d6441..1ac76f4e 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/settings.gradle b/settings.gradle index ea177a14..ae9519fa 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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' 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..733bc901 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/TunerClass.java +++ b/src/main/java/io/github/dsheirer/source/tuner/TunerClass.java @@ -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" ); 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..bc5620e8 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/TunerFactory.java +++ b/src/main/java/io/github/dsheirer/source/tuner/TunerFactory.java @@ -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()) { 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..e60287cc 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/TunerType.java +++ b/src/main/java/io/github/dsheirer/source/tuner/TunerType.java @@ -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"), 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..148b2f00 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 @@ -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 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..ed914c02 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 @@ -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 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 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. */ diff --git a/src/main/java/io/github/dsheirer/source/tuner/signalhound/DiscoveredSignalHoundTuner.java b/src/main/java/io/github/dsheirer/source/tuner/signalhound/DiscoveredSignalHoundTuner.java new file mode 100644 index 00000000..072b34da --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/signalhound/DiscoveredSignalHoundTuner.java @@ -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 + * **************************************************************************** + */ + +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(); + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundDeviceInformation.java b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundDeviceInformation.java new file mode 100644 index 00000000..7f68bddd --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundDeviceInformation.java @@ -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 + * **************************************************************************** + */ + +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) +{ +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundFamily.java b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundFamily.java new file mode 100644 index 00000000..f546f9b8 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundFamily.java @@ -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 + * **************************************************************************** + */ + +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; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundSampleRate.java b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundSampleRate.java new file mode 100644 index 00000000..d4c2a4df --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundSampleRate.java @@ -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 + * **************************************************************************** + */ + +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 getRates(SignalHoundFamily family) + { + List 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 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"; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTuner.java b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTuner.java new file mode 100644 index 00000000..71e54379 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTuner.java @@ -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 + * **************************************************************************** + */ + +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; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerConfiguration.java b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerConfiguration.java new file mode 100644 index 00000000..7a06d78f --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerConfiguration.java @@ -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 + * **************************************************************************** + */ + +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; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerController.java b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerController.java new file mode 100644 index 00000000..9de1ca01 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerController.java @@ -0,0 +1,1086 @@ +/* + * ***************************************************************************** + * 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 + * **************************************************************************** + */ + +package io.github.dsheirer.source.tuner.signalhound; + +import io.github.dsheirer.buffer.FloatNativeBuffer; +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.SignalHound; +import io.github.dsheirer.signalhound.SignalHoundApi; +import io.github.dsheirer.signalhound.SignalHoundDevice; +import io.github.dsheirer.signalhound.SignalHoundException; +import io.github.dsheirer.signalhound.bb.Bb60Device; +import io.github.dsheirer.signalhound.sm.SmDevice; +import io.github.dsheirer.signalhound.sp.SpDevice; +import io.github.dsheirer.signalhound.sp.SpModel; +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; +import io.github.dsheirer.source.tuner.configuration.TunerConfiguration; +import io.github.dsheirer.util.ThreadPool; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Signal Hound tuner controller for the BB60, SM200/SM435 and SP145 families, implemented with the jSignalHound library. + * + * The Signal Hound APIs are polling interfaces: the API buffers about half a second of samples and the application + * reads them in blocks. A dedicated reader thread reads the blocks and dispatches them to the buffer listeners. + * + * The APIs can't change the center frequency, sample rate or front-end settings of a running I/Q stream, so each of + * these changes reconfigures (aborts and restarts) the stream. The reader thread waits while the stream is being + * reconfigured. + * + * Samples are read as 32-bit floats, which the API corrects for amplitude flatness and I/Q imbalance and scales to + * mW (power in dBm = 10 * log10(I*I + Q*Q)). They are scaled so that a signal at the reference level is at full scale + * (1.0), so that the reference level acts as the gain control: lowering it increases the gain. + */ +public class SignalHoundTunerController extends TunerController +{ + private static final Logger mLog = LoggerFactory.getLogger(SignalHoundTunerController.class); + //Widest range of all of the families. The device's own range is applied when it's started. + public static final long MINIMUM_TUNABLE_FREQUENCY_HZ = 9_000L; + public static final long MAXIMUM_TUNABLE_FREQUENCY_HZ = 44_200_000_000L; + public static final int AUTOMATIC = -1; + public static final int REFERENCE_LEVEL_MINIMUM = -60; + public static final int REFERENCE_LEVEL_MAXIMUM = 20; + public static final int REFERENCE_LEVEL_DEFAULT = (int)IqConfig.DEFAULT_REFERENCE_LEVEL; + private static final long FREQUENCY_DEFAULT = 101_100_000L; + //The filter bandwidth requested from the API, relative to the sample rate. The API reduces it to the maximum + //bandwidth for the decimation, which is reported when the stream starts. + private static final double REQUESTED_BANDWIDTH_RATIO = 0.9; + private static final double USABLE_BANDWIDTH_PERCENT_DEFAULT = 0.8; + private static final double USABLE_BANDWIDTH_PERCENT_MINIMUM = 0.5; + //Each read is about 10 ms of samples, as a power of two + private static final double BUFFER_SECONDS = 0.01; + private static final int BUFFER_SAMPLES_MINIMUM = 8192; + private static final int BUFFER_SAMPLES_MAXIMUM = 524288; + private static final long READER_JOIN_TIMEOUT_MS = 2000; + private static final long CONFIGURE_WAIT_MS = 250; + private static final long STATUS_LOG_INTERVAL_NANOSECONDS = TimeUnit.SECONDS.toNanos(10); + + private final DeviceDescriptor mDescriptor; + private final DeviceOpener mDeviceOpener; + private final SignalHoundFamily mFamily; + private SignalHoundDeviceInformation mDeviceInfo; + private List mSampleRates = List.of(); + private SignalHoundSampleRate mSampleRate; + private long mMinimumTunableFrequency = MINIMUM_TUNABLE_FREQUENCY_HZ; + private long mMaximumTunableFrequency = MAXIMUM_TUNABLE_FREQUENCY_HZ; + private long mTunedFrequency = FREQUENCY_DEFAULT; + private int mReferenceLevel = REFERENCE_LEVEL_DEFAULT; + private int mAttenuation = AUTOMATIC; + private int mGain = AUTOMATIC; + private boolean mPreselector = false; + private boolean mPreselectorSupported = false; + //Reader thread and streaming state, guarded by the controller lock (getLock()) + private Thread mReaderThread; + private boolean mStreaming; + private volatile boolean mReading; + + //Lock for device calls and the I/Q stream state, which the reader thread holds while it reads each block. It is + //fair so that a reconfiguration isn't starved by the reader thread, which takes it again after each block. The + //controller lock is always taken before this lock, and the reader thread never takes the controller lock. + private final ReentrantLock mDeviceLock = new ReentrantLock(true); + private final Condition mStreamConfigured = mDeviceLock.newCondition(); + //Guarded by mDeviceLock + private SignalHoundDevice mDevice; + private boolean mIqStreaming; + private int mBufferSamples = BUFFER_SAMPLES_MINIMUM; + private float mSampleScale = 1.0f; + private float mSamplesPerMillisecond; + + /** + * Constructs an instance + * @param descriptor of the device, from the Signal Hound API device list + * @param tunerErrorListener to receive error notifications from this controller + */ + public SignalHoundTunerController(DeviceDescriptor descriptor, ITunerErrorListener tunerErrorListener) + { + this(descriptor, tunerErrorListener, SignalHoundTunerController::openDevice); + } + + /** + * Constructs an instance that opens the device with an opener, for testing. + * @param descriptor of the device + * @param tunerErrorListener to receive error notifications from this controller + * @param deviceOpener that opens the device + */ + SignalHoundTunerController(DeviceDescriptor descriptor, ITunerErrorListener tunerErrorListener, + DeviceOpener deviceOpener) + { + super(tunerErrorListener); + mDescriptor = descriptor; + mDeviceOpener = deviceOpener; + mFamily = SignalHoundFamily.of(descriptor.api()); + setMinimumFrequency(mFamily.getMinimumFrequency()); + setMaximumFrequency(mFamily.getMaximumFrequency()); + setMiddleUnusableHalfBandwidth(0); + setUsableBandwidthPercentage(USABLE_BANDWIDTH_PERCENT_DEFAULT); + } + + @Override + public TunerType getTunerType() + { + return TunerType.SIGNAL_HOUND; + } + + /** + * Device family + */ + public SignalHoundFamily getFamily() + { + return mFamily; + } + + /** + * Descriptor of the device from the Signal Hound API device list. + */ + public DeviceDescriptor getDescriptor() + { + return mDescriptor; + } + + @Override + public int getBufferSampleCount() + { + return getBufferSampleCount(getSampleRate()); + } + + /** + * Number of samples to read in each block: about 10 ms of samples, as a power of two. + * @param sampleRate in Hertz + */ + static int getBufferSampleCount(double sampleRate) + { + int samples = (int)Math.min(Integer.MAX_VALUE, sampleRate * BUFFER_SECONDS); + return Math.clamp(Integer.highestOneBit(Math.max(samples, 1)), BUFFER_SAMPLES_MINIMUM, BUFFER_SAMPLES_MAXIMUM); + } + + /** + * Scale that puts a signal at the reference level at full scale, for samples in units of the square root of mW. + * @param referenceLevel in dBm + */ + static float getSampleScale(double referenceLevel) + { + return (float)Math.pow(10.0, -referenceLevel / 20.0); + } + + /** + * Usable bandwidth percentage from the bandwidth that the API reports for the stream. + */ + static double getUsableBandwidthPercent(IqStreamInfo info) + { + if(info.sampleRate() <= 0 || info.bandwidth() <= 0) + { + return USABLE_BANDWIDTH_PERCENT_DEFAULT; + } + + return Math.clamp(info.bandwidth() / info.sampleRate(), USABLE_BANDWIDTH_PERCENT_MINIMUM, + REQUESTED_BANDWIDTH_RATIO); + } + + @Override + public void start() throws SourceException + { + getLock().lock(); + + try + { + SignalHoundApi api = mDescriptor.api(); + SignalHoundDevice device = mDeviceOpener.open(mDescriptor); + + mDeviceLock.lock(); + + try + { + mDevice = device; + } + finally + { + mDeviceLock.unlock(); + } + + String firmware; + + try + { + firmware = device.firmwareVersion(); + } + catch(SignalHoundException | RuntimeException e) + { + firmware = "unknown"; + mLog.warn("Can't read Signal Hound firmware version - " + e.getMessage()); + } + + mDeviceInfo = new SignalHoundDeviceInformation(device.model(), device.serialNumber(), firmware, + api.libraryName(), getLibraryVersion(api), api.location().orElse("unknown")); + + mPreselectorSupported = device instanceof SmDevice || + (device instanceof SpDevice sp && sp.spModel() == SpModel.SP145B); + + mMinimumTunableFrequency = mFamily.getMinimumFrequency(); + mMaximumTunableFrequency = mFamily.getMaximumFrequency(device.model()); + setFrequencyExtents(mMinimumTunableFrequency, mMaximumTunableFrequency); + + mSampleRates = SignalHoundSampleRate.getRates(mFamily); + mSampleRate = null; + setSampleRate(SignalHoundSampleRate.getClosest(mSampleRates, SignalHoundSampleRate.DEFAULT_RATE)); + setFrequency(FREQUENCY_DEFAULT); + + mLog.info("Signal Hound [" + mDeviceInfo.model() + "] serial [" + mDeviceInfo.serial() + "] firmware [" + + firmware + "] started - " + api.libraryName() + " " + mDeviceInfo.libraryVersion()); + } + catch(SignalHoundException | RuntimeException | LinkageError e) + { + closeDevice(); + throw new SourceException("Can't start Signal Hound tuner - " + e.getMessage(), e); + } + catch(SourceException se) + { + closeDevice(); + throw se; + } + finally + { + getLock().unlock(); + } + } + + /** + * Opens a device with the Signal Hound API. + * @throws SourceException if the API library isn't installed + */ + private static SignalHoundDevice openDevice(DeviceDescriptor descriptor) throws SignalHoundException, SourceException + { + SignalHoundApi api = descriptor.api(); + + if(!api.isAvailable()) + { + throw new SourceException("Signal Hound " + api.libraryName() + " library is not installed"); + } + + return SignalHound.open(descriptor); + } + + /** + * Version of the API library, or unknown when the library isn't loaded. + */ + private static String getLibraryVersion(SignalHoundApi api) + { + try + { + return api.version(); + } + catch(LinkageError e) + { + return "unknown"; + } + } + + /** + * Opens the Signal Hound device. + */ + @FunctionalInterface + interface DeviceOpener + { + SignalHoundDevice open(DeviceDescriptor descriptor) throws SignalHoundException, SourceException; + } + + @Override + public void stop() + { + getLock().lock(); + + try + { + stopStream(); + closeDevice(); + } + finally + { + getLock().unlock(); + } + + mNativeBufferBroadcaster.clear(); + } + + /** + * Closes the device, which also stops any measurement. Call while holding the controller lock, after the reader + * thread has stopped. + */ + private void closeDevice() + { + SignalHoundDevice device; + + mDeviceLock.lock(); + + try + { + device = mDevice; + mDevice = null; + mIqStreaming = false; + } + finally + { + mDeviceLock.unlock(); + } + + if(device != null) + { + try + { + device.close(); + } + catch(RuntimeException e) + { + mLog.error("Error closing Signal Hound device", e); + } + } + } + + @Override + public void apply(TunerConfiguration tunerConfiguration) throws SourceException + { + if(!(tunerConfiguration instanceof SignalHoundTunerConfiguration config)) + { + throw new IllegalArgumentException("Invalid tuner config:" + tunerConfiguration.getClass()); + } + + //Saved limits can be wider than this device, since the defaults cover all of the families + config.setMinimumFrequency(Math.max(config.getMinimumFrequency(), mMinimumTunableFrequency)); + config.setMaximumFrequency(Math.min(config.getMaximumFrequency() > 0 ? config.getMaximumFrequency() : + mMaximumTunableFrequency, mMaximumTunableFrequency)); + + SignalHoundSampleRate rate = SignalHoundSampleRate.getClosest(mSampleRates, config.getSampleRate()); + setSampleRate(rate); + config.setSampleRate(rate.rate()); + + //Invoke super for frequency, frequency correction and autoPPM + super.apply(config); + + int referenceLevel = Math.clamp(config.getReferenceLevel(), REFERENCE_LEVEL_MINIMUM, REFERENCE_LEVEL_MAXIMUM); + setReferenceLevel(referenceLevel); + config.setReferenceLevel(referenceLevel); + + int attenuation = getValidSetting(config.getAttenuation(), mFamily.getMaximumAttenuation()); + setAttenuation(attenuation); + config.setAttenuation(attenuation); + + int gain = mFamily.hasGain() ? getValidSetting(config.getGain(), mFamily.getMaximumGain()) : AUTOMATIC; + setGain(gain); + config.setGain(gain); + + boolean preselector = mPreselectorSupported && config.isPreselector(); + setPreselector(preselector); + config.setPreselector(preselector); + } + + /** + * Setting value when it's automatic or within 0 and the maximum, otherwise automatic. + */ + static int getValidSetting(int value, int maximum) + { + return value >= 0 && value <= maximum ? value : AUTOMATIC; + } + + @Override + public long getTunedFrequency() throws SourceException + { + return mTunedFrequency; + } + + @Override + public void setTunedFrequency(long frequency) throws SourceException + { + getLock().lock(); + + try + { + long previous = mTunedFrequency; + mTunedFrequency = frequency; + + if(frequency != previous) + { + reconfigure("tune to [" + frequency + "]"); + } + } + finally + { + getLock().unlock(); + } + } + + @Override + public double getCurrentSampleRate() + { + return mSampleRate != null ? mSampleRate.rate() : 0; + } + + /** + * Sample rates of the device family. + */ + public List getSampleRates() + { + return mSampleRates; + } + + /** + * Current sample rate, or null before the tuner is started. + */ + public SignalHoundSampleRate getSignalHoundSampleRate() + { + return mSampleRate; + } + + /** + * Sets the sample rate. + * @param rate to set + * @throws SourceException if the sample rate is locked or can't be set + */ + public void setSampleRate(SignalHoundSampleRate rate) throws SourceException + { + getLock().lock(); + + try + { + if(!rate.equals(mSampleRate)) + { + if(isLockedSampleRate()) + { + throw new SourceException("Can't change Signal Hound sample rate while channels are allocated"); + } + + mSampleRate = rate; + reconfigure("set sample rate to [" + rate + "]"); + mFrequencyController.setSampleRate(rate.rate()); + } + } + finally + { + getLock().unlock(); + } + } + + /** + * Lowest frequency that the device can tune. + */ + public long getMinimumTunableFrequency() + { + return mMinimumTunableFrequency; + } + + /** + * Highest frequency that the device can tune. + */ + public long getMaximumTunableFrequency() + { + return mMaximumTunableFrequency; + } + + /** + * Reference level in dBm. + */ + public int getReferenceLevel() + { + return mReferenceLevel; + } + + /** + * Sets the reference level, which is the strongest expected input level. When the attenuation (and the gain for + * the BB60) is automatic, the API chooses them from the reference level. + * @param referenceLevel in dBm, -60 to 20 + * @throws SourceException if the stream can't be reconfigured + */ + public void setReferenceLevel(int referenceLevel) throws SourceException + { + if(referenceLevel < REFERENCE_LEVEL_MINIMUM || referenceLevel > REFERENCE_LEVEL_MAXIMUM) + { + throw new IllegalArgumentException("Reference level must be " + REFERENCE_LEVEL_MINIMUM + " to " + + REFERENCE_LEVEL_MAXIMUM + " dBm: " + referenceLevel); + } + + getLock().lock(); + + try + { + if(referenceLevel != mReferenceLevel) + { + mReferenceLevel = referenceLevel; + reconfigure("set reference level to [" + referenceLevel + " dBm]"); + } + } + finally + { + getLock().unlock(); + } + } + + /** + * Attenuator setting, or {@link #AUTOMATIC}. + */ + public int getAttenuation() + { + return mAttenuation; + } + + /** + * Sets the attenuator. + * @param attenuation 0 to the family's maximum, in steps of the family's attenuation step, or {@link #AUTOMATIC} + * @throws SourceException if the stream can't be reconfigured + */ + public void setAttenuation(int attenuation) throws SourceException + { + if(getValidSetting(attenuation, mFamily.getMaximumAttenuation()) != attenuation) + { + throw new IllegalArgumentException("Invalid " + mFamily + " attenuation setting: " + attenuation); + } + + getLock().lock(); + + try + { + if(attenuation != mAttenuation) + { + mAttenuation = attenuation; + reconfigure("set attenuation to [" + attenuation + "]"); + } + } + finally + { + getLock().unlock(); + } + } + + /** + * Gain setting (BB60 only), or {@link #AUTOMATIC}. + */ + public int getGain() + { + return mGain; + } + + /** + * Sets the gain of a BB60. The BB60 uses the manual gain and attenuation only when both are set, otherwise it + * chooses both from the reference level. + * @param gain 0 to 3, or {@link #AUTOMATIC} + * @throws SourceException if the stream can't be reconfigured + */ + public void setGain(int gain) throws SourceException + { + if(gain != AUTOMATIC && (!mFamily.hasGain() || getValidSetting(gain, mFamily.getMaximumGain()) != gain)) + { + throw new IllegalArgumentException("Invalid " + mFamily + " gain setting: " + gain); + } + + getLock().lock(); + + try + { + if(gain != mGain) + { + mGain = gain; + reconfigure("set gain to [" + gain + "]"); + } + } + finally + { + getLock().unlock(); + } + } + + /** + * Indicates if the device has an RF preselector: the SM200/SM435, and the SP145B. + */ + public boolean isPreselectorSupported() + { + return mPreselectorSupported; + } + + /** + * Indicates if the RF preselector is enabled. + */ + public boolean isPreselector() + { + return mPreselector; + } + + /** + * Enables or disables the RF preselector, which filters the input up to 645 MHz (SM200/SM435) or 520 MHz (SP145B). + * @param enabled true to enable + * @throws SourceException if the device doesn't have a preselector or the stream can't be reconfigured + */ + public void setPreselector(boolean enabled) throws SourceException + { + getLock().lock(); + + try + { + if(enabled != mPreselector) + { + if(enabled && !mPreselectorSupported) + { + throw new SourceException("This Signal Hound device doesn't have an RF preselector"); + } + + mPreselector = enabled; + reconfigure((enabled ? "enable" : "disable") + " preselector"); + } + } + finally + { + getLock().unlock(); + } + } + + /** + * Device information, available once the tuner has been started. + */ + public SignalHoundDeviceInformation getDeviceInfo() + { + return mDeviceInfo; + } + + /** + * Reads the device's temperature, voltage and current sensors, for display. + * @return readings, or null if they can't be read + */ + public Diagnostics getDiagnostics() + { + mDeviceLock.lock(); + + try + { + return mDevice != null ? mDevice.diagnostics() : null; + } + catch(SignalHoundException | RuntimeException e) + { + mLog.warn("Can't read Signal Hound diagnostics - " + e.getMessage()); + return null; + } + finally + { + mDeviceLock.unlock(); + } + } + + /** + * Adds the IQ buffer listener and automatically starts streaming, if not already started. + */ + @Override + public void addBufferListener(Listener listener) + { + getLock().lock(); + + try + { + if(hasDevice()) + { + super.addBufferListener(listener); + + if(!mStreaming) + { + startStream(); + } + } + } + catch(SourceException se) + { + mLog.error("Error starting streaming on Signal Hound tuner", se); + } + finally + { + getLock().unlock(); + } + } + + /** + * Removes the IQ buffer listener and stops streaming if there are no more listeners. + */ + @Override + public void removeBufferListener(Listener listener) + { + getLock().lock(); + + try + { + super.removeBufferListener(listener); + + if(!hasBufferListeners()) + { + stopStream(); + } + } + finally + { + getLock().unlock(); + } + } + + /** + * Indicates if the device is open. + */ + private boolean hasDevice() + { + mDeviceLock.lock(); + + try + { + return mDevice != null; + } + finally + { + mDeviceLock.unlock(); + } + } + + /** + * Configures and starts the I/Q stream and the reader thread. Call while holding the controller lock. + */ + private void startStream() throws SourceException + { + configureStream(); + mStreaming = true; + mReading = true; + mReaderThread = new Thread(this::read, "sdrtrunk signal hound " + mDescriptor.serialNumber()); + mReaderThread.setDaemon(true); + mReaderThread.start(); + } + + /** + * Stops the reader thread and the I/Q stream. Call while holding the controller lock. + */ + private void stopStream() + { + mStreaming = false; + mReading = false; + Thread reader = mReaderThread; + mReaderThread = null; + + mDeviceLock.lock(); + + try + { + mStreamConfigured.signalAll(); + } + finally + { + mDeviceLock.unlock(); + } + + if(reader != null && reader != Thread.currentThread()) + { + try + { + //The reader finishes the block that it's reading, about 10 ms of samples + reader.join(READER_JOIN_TIMEOUT_MS); + + if(reader.isAlive()) + { + mLog.warn("Signal Hound reader thread didn't stop within " + READER_JOIN_TIMEOUT_MS + " ms"); + } + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + } + } + + mDeviceLock.lock(); + + try + { + if(mDevice != null && mIqStreaming) + { + mIqStreaming = false; + mDevice.stop(); + } + } + catch(SignalHoundException | RuntimeException e) + { + mLog.error("Error stopping Signal Hound I/Q stream - " + e.getMessage()); + } + finally + { + mDeviceLock.unlock(); + } + } + + /** + * Reconfigures the I/Q stream after a settings change, when it's running. Otherwise the settings are applied when + * the stream starts. Call while holding the controller lock. + * @param change description for the error message + * @throws SourceException if the stream can't be reconfigured, which leaves it stopped + */ + private void reconfigure(String change) throws SourceException + { + if(mStreaming) + { + try + { + configureStream(); + } + catch(SourceException se) + { + throw new SourceException("Can't " + change + " - " + se.getMessage(), se); + } + } + } + + /** + * Configures and starts the I/Q stream with the current settings, stopping it first when it's running. Call while + * holding the controller lock. + * @throws SourceException if the device isn't open or rejects the settings, which leaves the stream stopped + */ + private void configureStream() throws SourceException + { + if(mSampleRate == null) + { + throw new SourceException("Signal Hound tuner isn't started"); + } + + IqConfig config = new IqConfig(mTunedFrequency, mSampleRate.decimation(), + mSampleRate.rate() * REQUESTED_BANDWIDTH_RATIO, IqDataType.FLOAT32, mReferenceLevel); + IqStreamInfo info; + + mDeviceLock.lock(); + + try + { + if(mDevice == null) + { + throw new SourceException("Signal Hound device isn't open"); + } + + mIqStreaming = false; + applyFrontEnd(mDevice); + info = mDevice.startIq(config); + mSampleScale = getSampleScale(mReferenceLevel); + mBufferSamples = getBufferSampleCount(info.sampleRate()); + mSamplesPerMillisecond = (float)(info.sampleRate() / 1000.0); + mIqStreaming = true; + mStreamConfigured.signalAll(); + } + catch(SignalHoundException | RuntimeException e) + { + throw new SourceException("Signal Hound rejected I/Q stream settings " + config + " - " + e.getMessage(), e); + } + finally + { + mDeviceLock.unlock(); + } + + if(Math.abs(info.sampleRate() - mSampleRate.rate()) > 1.0) + { + mLog.warn("Signal Hound I/Q sample rate is [" + info.sampleRate() + "] instead of [" + + mSampleRate.rate() + "]"); + } + + setUsableBandwidthPercentage(getUsableBandwidthPercent(info)); + } + + /** + * Applies the attenuation, gain and preselector settings, which the device uses when the stream is configured. + * Call while holding the device lock. + */ + private void applyFrontEnd(SignalHoundDevice device) throws SignalHoundException + { + switch(device) + { + case Bb60Device bb -> bb.setGainAttenuation(mGain, mAttenuation); + case SmDevice sm -> + { + sm.setAttenuation(mAttenuation); + sm.setPreselector(mPreselector); + } + case SpDevice sp -> + { + sp.setAttenuation(mAttenuation); + + if(mPreselectorSupported) + { + try + { + sp.setPreselector(mPreselector); + } + catch(UnsupportedOperationException e) + { + //The Linux x86-64 and macOS builds of sp_api don't have the preselector functions + mLog.warn("Signal Hound sp_api library doesn't support the SP145B preselector on this platform"); + mPreselectorSupported = false; + mPreselector = false; + } + } + } + default -> {} + } + } + + /** + * Reads blocks of samples on the reader thread and dispatches them to the buffer listeners, until the stream is + * stopped or a read fails. + */ + private void read() + { + IqBuffer buffer = null; + String failure = null; + long lostBlocks = 0; + long warnings = 0; + String lastWarning = null; + long lastReport = System.nanoTime(); + + try + { + while(mReading) + { + float[] samples; + float scale; + float samplesPerMillisecond; + IqBlock block; + + mDeviceLock.lock(); + + try + { + while(mReading && (!mIqStreaming || mDevice == null)) + { + mStreamConfigured.await(CONFIGURE_WAIT_MS, TimeUnit.MILLISECONDS); + } + + if(!mReading) + { + break; + } + + if(buffer == null || buffer.capacity() != mBufferSamples) + { + if(buffer != null) + { + buffer.close(); + } + + buffer = IqBuffer.allocate(IqDataType.FLOAT32, mBufferSamples); + } + + block = mDevice.readIq(buffer, false); + samples = new float[buffer.capacity() * 2]; + buffer.copyFloats(samples); + scale = mSampleScale; + samplesPerMillisecond = mSamplesPerMillisecond; + } + finally + { + mDeviceLock.unlock(); + } + + scale(samples, scale); + + if(block.sampleLoss()) + { + lostBlocks++; + } + + if(block.warning() != null) + { + warnings++; + lastWarning = block.warning(); + } + + long now = System.nanoTime(); + + if(now - lastReport > STATUS_LOG_INTERVAL_NANOSECONDS) + { + if(lostBlocks > 0) + { + mLog.warn("Signal Hound dropped samples before [" + lostBlocks + "] sample blocks - the " + + "computer isn't keeping up with the sample rate"); + } + + if(warnings > 0) + { + mLog.warn("Signal Hound reported [" + warnings + "] warnings, most recently [" + lastWarning + + "] - an ADC overflow means that the reference level is too low for the input signals"); + } + + lostBlocks = 0; + warnings = 0; + lastReport = now; + } + + long timestamp = block.timestampNanos() > 0 ? block.timestampNanos() / 1_000_000L : + System.currentTimeMillis(); + broadcast(new FloatNativeBuffer(samples, timestamp, samplesPerMillisecond)); + } + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + } + catch(SignalHoundException | RuntimeException e) + { + //A read that fails because the stream is being stopped isn't an error + if(mReading) + { + failure = e.getMessage(); + } + } + finally + { + if(buffer != null) + { + buffer.close(); + } + } + + if(failure != null) + { + mReading = false; + String message = "Signal Hound sample stream stopped - " + failure; + mLog.error(message); + //Stopping the tuner joins this thread, so it's done on another thread + ThreadPool.CACHED.execute(() -> setErrorMessage(message)); + } + } + + /** + * Scales the samples in place. + * @param samples interleaved complex samples + * @param scale to apply + */ + static void scale(float[] samples, float scale) + { + for(int x = 0; x < samples.length; x++) + { + samples[x] *= scale; + } + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerEditor.java b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerEditor.java new file mode 100644 index 00000000..481cf586 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerEditor.java @@ -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 + * **************************************************************************** + */ + +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 +{ + @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 mSampleRateCombo; + private JSlider mReferenceLevelSlider; + private JLabel mReferenceLevelValueLabel; + private JComboBox mAttenuationCombo; + private JComboBox 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 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 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 getAttenuationCombo() + { + if(mAttenuationCombo == null) + { + List 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 getGainCombo() + { + if(mGainCombo == null) + { + List 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("

Signal Hound Tuner

"); + sb.append("Model: ").append(info.model()).append("
"); + sb.append("Serial: ").append(info.serial()).append("
"); + sb.append("Firmware: ").append(info.firmware()).append("
"); + sb.append("Library: ").append(info.library()).append(" ").append(info.libraryVersion()).append("
"); + sb.append("Location: ").append(info.libraryLocation()).append("
"); + + Diagnostics diagnostics = getTuner().getController().getDiagnostics(); + + if(diagnostics != null) + { + sb.append("Temperature: ").append(String.format(Locale.ROOT, "%.1f C", + diagnostics.temperature())).append("
"); + sb.append("Voltage: ").append(String.format(Locale.ROOT, "%.2f V", + diagnostics.voltage())).append("
"); + sb.append("Current: ").append(String.format(Locale.ROOT, "%.3f A", + diagnostics.current())).append("
"); + } + + 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; + } + } +} diff --git a/src/test/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerTest.java b/src/test/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerTest.java new file mode 100644 index 00000000..1b502431 --- /dev/null +++ b/src/test/java/io/github/dsheirer/source/tuner/signalhound/SignalHoundTunerTest.java @@ -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 + * **************************************************************************** + */ + +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 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 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 buffers = new ArrayBlockingQueue<>(16); + Listener 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 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 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 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 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 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 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; + } + } +}