From 657cbf5c55834814ab28ecc998173cfa32a8a899 Mon Sep 17 00:00:00 2001 From: Denny Sheirer Date: Sun, 8 Nov 2026 06:03:13 -0500 Subject: [PATCH] #2600 LimeSDR support (#2601) Co-authored-by: denny --- build.gradle | 1 + settings.gradle | 8 + .../dsheirer/source/tuner/TunerClass.java | 6 +- .../dsheirer/source/tuner/TunerFactory.java | 10 + .../dsheirer/source/tuner/TunerType.java | 3 +- .../configuration/TunerConfiguration.java | 2 + .../limesdr/LimeSdrDeviceInformation.java | 35 + .../tuner/limesdr/LimeSdrSampleRate.java | 112 ++ .../source/tuner/limesdr/LimeSdrTuner.java | 86 ++ .../limesdr/LimeSdrTunerConfiguration.java | 97 ++ .../tuner/limesdr/LimeSdrTunerController.java | 1074 +++++++++++++++++ .../tuner/limesdr/LimeSdrTunerEditor.java | 363 ++++++ src/main/resources/65-limesdr.rules | 7 + .../tuner/limesdr/LimeSdrTunerTest.java | 204 ++++ 14 files changed, 2006 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrDeviceInformation.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrSampleRate.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTuner.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerConfiguration.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerController.java create mode 100644 src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerEditor.java create mode 100644 src/main/resources/65-limesdr.rules create mode 100644 src/test/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerTest.java diff --git a/build.gradle b/build.gradle index 92b6dd3b..ea4e9205 100644 --- a/build.gradle +++ b/build.gradle @@ -129,6 +129,7 @@ dependencies { implementation 'io.github.dsheirer:jbladerf:2.6.0' //bladeRF tuners - GitHub release, see settings.gradle implementation 'io.github.dsheirer:jhackrf:2026.01.3' //HackRF tuners - GitHub release, see settings.gradle implementation 'io.github.dsheirer:jhydrasdr:1.1.4' //HydraSDR tuners - GitHub release, see settings.gradle + implementation 'io.github.dsheirer:jlimesdr:25.1.0.1' //LimeSDR tuners - GitHub release, see settings.gradle implementation 'io.github.dsheirer:jrtl:1.4.0.1' //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:radio-reference-api:18.0.0' diff --git a/settings.gradle b/settings.gradle index 175225ff..5286abe2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -49,6 +49,14 @@ dependencyResolutionManagement { content { includeModule 'io.github.dsheirer', 'jhydrasdr' } } + //jLimeSDR is published as a GitHub release, with the native libraries for all platforms in the jar + ivy { + url = 'https://github.com/DSheirer/jLimeSDR/releases/download' + patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' } + metadataSources { artifact() } + content { includeModule 'io.github.dsheirer', 'jlimesdr' } + } + //jsdrplay is published as a GitHub release. The jar does not contain the SDRplay API native library, which //users install separately from sdrplay.com ivy { 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 68220270..ee760585 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/TunerClass.java +++ b/src/main/java/io/github/dsheirer/source/tuner/TunerClass.java @@ -32,6 +32,7 @@ public enum TunerClass FUNCUBE_DONGLE_PRO_PLUS("Funcube Dongle Pro+" ), HACKRF("HackRF" ), HYDRASDR("HydraSDR"), + LIMESDR("LimeSDR"), RTL2832("RTL-2832"), RSP("RSP"), TEST_TUNER("Test"), @@ -56,7 +57,7 @@ public enum TunerClass } public static final EnumSet SUPPORTED_USB_TUNERS = EnumSet.of(AIRSPY, AIRSPY_HF, BLADERF, HACKRF, - HYDRASDR, RTL2832, FUNCUBE_DONGLE_PRO, FUNCUBE_DONGLE_PRO_PLUS); + HYDRASDR, LIMESDR, RTL2832, FUNCUBE_DONGLE_PRO, FUNCUBE_DONGLE_PRO_PLUS); public static final EnumSet FUNCUBE_TUNERS = EnumSet.of(FUNCUBE_DONGLE_PRO, FUNCUBE_DONGLE_PRO_PLUS); @@ -146,6 +147,9 @@ public enum TunerClass return BLADERF; case 0x38AF0001: //HYDRASDR RFONE (official VID 38af, PID 0001) return HYDRASDR; + case 0x1D506108: //LIMESDR-USB + case 0x0403601F: //LIMESDR MINI (FTDI FT601) + return LIMESDR; case 0x1D5060A1: return AIRSPY; case 0x03EB800C: 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 1d297aad..7be34d3f 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/TunerFactory.java +++ b/src/main/java/io/github/dsheirer/source/tuner/TunerFactory.java @@ -54,6 +54,10 @@ import io.github.dsheirer.source.tuner.hydrasdr.HydraSdrTuner; import io.github.dsheirer.source.tuner.hydrasdr.HydraSdrTunerConfiguration; import io.github.dsheirer.source.tuner.hydrasdr.HydraSdrTunerController; import io.github.dsheirer.source.tuner.hydrasdr.HydraSdrTunerEditor; +import io.github.dsheirer.source.tuner.limesdr.LimeSdrTuner; +import io.github.dsheirer.source.tuner.limesdr.LimeSdrTunerConfiguration; +import io.github.dsheirer.source.tuner.limesdr.LimeSdrTunerController; +import io.github.dsheirer.source.tuner.limesdr.LimeSdrTunerEditor; import io.github.dsheirer.source.tuner.manager.DiscoveredTuner; import io.github.dsheirer.source.tuner.manager.TunerManager; import io.github.dsheirer.source.tuner.recording.RecordingTunerConfiguration; @@ -313,6 +317,8 @@ public class TunerFactory return new HackRFTuner(new HackRFTunerController(bus, portAddress, tunerErrorListener), tunerErrorListener, channelizerType); case HYDRASDR: return new HydraSdrTuner(new HydraSdrTunerController(bus, portAddress, tunerErrorListener), tunerErrorListener, channelizerType); + case LIMESDR: + return new LimeSdrTuner(new LimeSdrTunerController(bus, portAddress, tunerErrorListener), tunerErrorListener, channelizerType); case RTL2832: return new RTL2832Tuner(new RTL2832TunerController(bus, portAddress, tunerErrorListener), tunerErrorListener, channelizerType); default: @@ -352,6 +358,8 @@ public class TunerFactory return new HackRFTunerConfiguration(uniqueID); case HYDRASDR_R828D: return new HydraSdrTunerConfiguration(uniqueID); + case LIMESDR: + return new LimeSdrTunerConfiguration(uniqueID); case RAFAELMICRO_R820T: return new R820TTunerConfiguration(uniqueID); case RAFAELMICRO_R828D: @@ -399,6 +407,8 @@ public class TunerFactory return new HackRFTunerEditor(userPreferences, tunerManager, discoveredTuner); case HYDRASDR: return new HydraSdrTunerEditor(userPreferences, tunerManager, discoveredTuner); + case LIMESDR: + return new LimeSdrTunerEditor(userPreferences, tunerManager, discoveredTuner); case RSP: if(discoveredTuner instanceof DiscoveredRspTuner discoveredRspTuner) { 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 4b119be7..2c867ae6 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/TunerType.java +++ b/src/main/java/io/github/dsheirer/source/tuner/TunerType.java @@ -39,6 +39,7 @@ public enum TunerType HACKRF_JAWBREAKER("Jawbreaker"), HACKRF_RAD1O("RAD1O"), HYDRASDR_R828D("R828D"), + LIMESDR("LimeSDR"), RAFAELMICRO_R820T("R820T"), RAFAELMICRO_R828D("R828D"), RSP_1("RSP1"), @@ -76,7 +77,7 @@ public enum TunerType * Supported USB tuner types */ public static final EnumSet SUPPORTED_USB_TUNERS = EnumSet.of(AIRSPY_R820T, BLADERF, ELONICS_E4000, FITIPOWER_FC0012, - HACKRF_JAWBREAKER, HACKRF_RAD1O, HACKRF_ONE, HACKRF_PRO, HYDRASDR_R828D, RAFAELMICRO_R820T); + HACKRF_JAWBREAKER, HACKRF_RAD1O, HACKRF_ONE, HACKRF_PRO, HYDRASDR_R828D, LIMESDR, RAFAELMICRO_R820T); /** * Supported sound card tuner types 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 7cc87531..cf923e40 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 @@ -31,6 +31,7 @@ import io.github.dsheirer.source.tuner.fcd.proV1.FCD1TunerConfiguration; import io.github.dsheirer.source.tuner.fcd.proplusV2.FCD2TunerConfiguration; import io.github.dsheirer.source.tuner.hackrf.HackRFTunerConfiguration; import io.github.dsheirer.source.tuner.hydrasdr.HydraSdrTunerConfiguration; +import io.github.dsheirer.source.tuner.limesdr.LimeSdrTunerConfiguration; import io.github.dsheirer.source.tuner.recording.RecordingTunerConfiguration; import io.github.dsheirer.source.tuner.rtl.e4k.E4KTunerConfiguration; import io.github.dsheirer.source.tuner.rtl.fc0012.FC0012TunerConfiguration; @@ -56,6 +57,7 @@ import io.github.dsheirer.source.tuner.sdrplay.RspTunerConfiguration; @JsonSubTypes.Type(value = FCD2TunerConfiguration.class, name = "fcd2TunerConfiguration"), @JsonSubTypes.Type(value = HackRFTunerConfiguration.class, name = "hackRFTunerConfiguration"), @JsonSubTypes.Type(value = HydraSdrTunerConfiguration.class, name = "hydraSdrTunerConfiguration"), + @JsonSubTypes.Type(value = LimeSdrTunerConfiguration.class, name = "limeSdrTunerConfiguration"), @JsonSubTypes.Type(value = RecordingTunerConfiguration.class, name = "recordingTunerConfiguration"), @JsonSubTypes.Type(value = R820TTunerConfiguration.class, name = "r820TTunerConfiguration"), @JsonSubTypes.Type(value = R828DTunerConfiguration.class, name = "r828DTunerConfiguration"), diff --git a/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrDeviceInformation.java b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrDeviceInformation.java new file mode 100644 index 00000000..f6882601 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrDeviceInformation.java @@ -0,0 +1,35 @@ +/* + * ***************************************************************************** + * 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.limesdr; + +/** + * LimeSDR device details for display. + * @param serial board serial number, 16 hex digits + * @param board name, such as LimeSDR-USB or LimeSDR-Mini + * @param firmware version + * @param hardware version + * @param gateware (FPGA) version + * @param connection USB link, such as USB3.0 + * @param library version of LimeSuiteNG + */ +public record LimeSdrDeviceInformation(String serial, String board, String firmware, String hardware, String gateware, + String connection, String library) +{ +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrSampleRate.java b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrSampleRate.java new file mode 100644 index 00000000..1c8cdeca --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrSampleRate.java @@ -0,0 +1,112 @@ +/* + * ***************************************************************************** + * 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.limesdr; + +import java.util.ArrayList; +import java.util.List; + +/** + * LimeSDR sample rates offered to the user. The LMS7002M supports any rate in its range, so these are rates that divide + * evenly into polyphase channelizer channels. + */ +public enum LimeSdrSampleRate +{ + RATE_1_25(1_250_000, "1.25 MHz"), + RATE_2_5(2_500_000, "2.50 MHz"), + RATE_5_0(5_000_000, "5.00 MHz"), + RATE_7_5(7_500_000, "7.50 MHz"), + RATE_10_0(10_000_000, "10.00 MHz"), + RATE_12_5(12_500_000, "12.50 MHz"), + RATE_15_0(15_000_000, "15.00 MHz"), + RATE_20_0(20_000_000, "20.00 MHz"), + RATE_25_0(25_000_000, "25.00 MHz"), + RATE_30_0(30_000_000, "30.00 MHz"), + RATE_30_72(30_720_000, "30.72 MHz"), + RATE_40_0(40_000_000, "40.00 MHz"), + RATE_50_0(50_000_000, "50.00 MHz"), + RATE_60_0(60_000_000, "60.00 MHz"), + RATE_61_44(61_440_000, "61.44 MHz"); + + public static final LimeSdrSampleRate DEFAULT = RATE_10_0; + private final int mRate; + private final String mLabel; + + LimeSdrSampleRate(int rate, String label) + { + mRate = rate; + mLabel = label; + } + + /** + * Sample rate in Hertz + */ + public int getRate() + { + return mRate; + } + + @Override + public String toString() + { + return mLabel; + } + + /** + * Sample rates within the device range, which is the lower of what the board and its USB connection support. + * @param minimumRate in Hertz + * @param maximumRate in Hertz + * @return rates, lowest to highest + */ + public static List getRates(double minimumRate, double maximumRate) + { + List rates = new ArrayList<>(); + + for(LimeSdrSampleRate rate : values()) + { + if(rate.getRate() >= minimumRate && rate.getRate() <= maximumRate) + { + rates.add(rate); + } + } + + return rates; + } + + /** + * Finds the highest rate from the list that doesn't exceed the requested rate. + * @param rates to search, lowest to highest + * @param requested rate in Hertz + * @return closest rate, or the lowest rate when the requested rate is below all of them. + */ + public static LimeSdrSampleRate getClosest(List rates, int requested) + { + LimeSdrSampleRate closest = rates.getFirst(); + + for(LimeSdrSampleRate rate : rates) + { + if(rate.getRate() <= requested) + { + closest = rate; + } + } + + return closest; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTuner.java b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTuner.java new file mode 100644 index 00000000..0c11f6ec --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTuner.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.limesdr; + +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; + +/** + * LimeSDR tuner + */ +public class LimeSdrTuner extends Tuner +{ + //The USB link carries 12-bit I and Q samples, packed into 3 bytes + private static final int LINK_BITS_PER_SAMPLE = 24; + private static final double SAMPLE_SIZE_12_BIT = 12.0; + + /** + * Constructs an instance + * @param controller for the LimeSDR + * @param tunerErrorListener to listen for errors from this tuner + * @param channelizerType for the channelizer + */ + public LimeSdrTuner(LimeSdrTunerController controller, ITunerErrorListener tunerErrorListener, + ChannelizerType channelizerType) + { + super(controller, tunerErrorListener, channelizerType); + } + + /** + * LimeSDR tuner controller + */ + public LimeSdrTunerController getController() + { + return (LimeSdrTunerController)getTunerController(); + } + + @Override + public String getPreferredName() + { + LimeSdrDeviceInformation info = getController().getDeviceInfo(); + return (info != null ? info.board() : "LimeSDR") + " " + getUniqueID(); + } + + @Override + public String getUniqueID() + { + return getController().getDeviceInfo().serial(); + } + + @Override + public TunerClass getTunerClass() + { + return TunerClass.LIMESDR; + } + + @Override + public double getSampleSize() + { + return SAMPLE_SIZE_12_BIT; + } + + @Override + public int getMaximumUSBBitsPerSecond() + { + return LimeSdrSampleRate.RATE_61_44.getRate() * LINK_BITS_PER_SAMPLE; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerConfiguration.java b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerConfiguration.java new file mode 100644 index 00000000..a9bda9ba --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerConfiguration.java @@ -0,0 +1,97 @@ +/* + * ***************************************************************************** + * 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.limesdr; + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import io.github.dsheirer.source.tuner.TunerType; +import io.github.dsheirer.source.tuner.configuration.TunerConfiguration; + +/** + * LimeSDR tuner configuration + */ +public class LimeSdrTunerConfiguration extends TunerConfiguration +{ + private int mSampleRate = LimeSdrSampleRate.DEFAULT.getRate(); + private int mGain = LimeSdrTunerController.GAIN_DEFAULT; + private String mAntenna = LimeSdrTunerController.ANTENNA_DEFAULT; + + /** + * Default constructor for Jackson + */ + public LimeSdrTunerConfiguration() + { + super(LimeSdrTunerController.MINIMUM_TUNABLE_FREQUENCY_HZ, LimeSdrTunerController.MAXIMUM_TUNABLE_FREQUENCY_HZ); + } + + /** + * Constructs an instance + * @param uniqueID for the tuner + */ + public LimeSdrTunerConfiguration(String uniqueID) + { + super(uniqueID); + } + + @Override + @JacksonXmlProperty(isAttribute = true, localName = "type", namespace = "http://www.w3.org/2001/XMLSchema-instance") + public TunerType getTunerType() + { + return TunerType.LIMESDR; + } + + @JacksonXmlProperty(isAttribute = true, localName = "sample_rate") + public int getSampleRate() + { + return mSampleRate; + } + + public void setSampleRate(int sampleRate) + { + mSampleRate = sampleRate; + } + + /** + * Combined receive gain in dB, 0 to 73. + */ + @JacksonXmlProperty(isAttribute = true, localName = "gain") + public int getGain() + { + return mGain; + } + + public void setGain(int gain) + { + mGain = gain; + } + + /** + * Receive antenna port name, such as LNAW. + */ + @JacksonXmlProperty(isAttribute = true, localName = "antenna") + public String getAntenna() + { + return mAntenna; + } + + public void setAntenna(String antenna) + { + mAntenna = antenna; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerController.java b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerController.java new file mode 100644 index 00000000..84e58f73 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerController.java @@ -0,0 +1,1074 @@ +/* + * ***************************************************************************** + * 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.limesdr; + +import io.github.dsheirer.buffer.FloatNativeBuffer; +import io.github.dsheirer.buffer.INativeBuffer; +import io.github.dsheirer.limesdr.DeviceEntry; +import io.github.dsheirer.limesdr.DeviceInfo; +import io.github.dsheirer.limesdr.Direction; +import io.github.dsheirer.limesdr.LimeSdr; +import io.github.dsheirer.limesdr.LimeSdrDevice; +import io.github.dsheirer.limesdr.LimeSdrException; +import io.github.dsheirer.limesdr.LogLevel; +import io.github.dsheirer.limesdr.Range; +import io.github.dsheirer.limesdr.SampleBuffer; +import io.github.dsheirer.limesdr.SampleFormat; +import io.github.dsheirer.limesdr.StreamConfig; +import io.github.dsheirer.sample.Listener; +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.source.tuner.manager.TunerManager; +import io.github.dsheirer.util.ThreadPool; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.usb4java.Context; +import org.usb4java.Device; +import org.usb4java.DeviceDescriptor; +import org.usb4java.DeviceHandle; +import org.usb4java.DeviceList; +import org.usb4java.LibUsb; + +/** + * LimeSDR tuner controller, implemented with the jLimeSDR library (LimeSuiteNG). + * + * The tuner is discovered with LibUsb at a USB bus and port, like all other USB tuners, however LimeSuiteNG owns the USB + * device and its transfers. LimeSuiteNG identifies USB devices only by serial number, so the serial number is read + * from the device at the bus and port. When it can't be read, such as on Windows where the LimeSDR-USB uses the + * Cypress driver instead of WinUSB, the device is opened when it is the only LimeSDR of its kind. + * + * Only the first receive channel is used. The LimeSDR-USB has a second receive channel, but it shares the local + * oscillator and sample rate with the first, so it couldn't be tuned as an independent tuner. + * + * The RX chain is calibrated (DC offset and IQ imbalance) when the sample stream starts, and again when the sample rate + * or antenna changes. Calibration also adjusts the amplifiers, so the gain is reapplied afterward. + */ +public class LimeSdrTunerController extends TunerController +{ + private static final Logger mLog = LoggerFactory.getLogger(LimeSdrTunerController.class); + private static final Logger LIME_SUITE_LOG = LoggerFactory.getLogger("LimeSuiteNG"); + private static final AtomicBoolean LOG_HANDLER_INSTALLED = new AtomicBoolean(); + //The LMS7002M range. The device reports its own range when it's started. + public static final long MINIMUM_TUNABLE_FREQUENCY_HZ = 100_000L; + public static final long MAXIMUM_TUNABLE_FREQUENCY_HZ = 3_800_000_000L; + public static final int GAIN_MINIMUM = 0; + public static final int GAIN_MAXIMUM = 73; + public static final int GAIN_DEFAULT = 40; + //Wideband LNA port. LimeSuiteNG selects LNAL after initialization. + public static final String ANTENNA_DEFAULT = "LNAW"; + private static final long FREQUENCY_DEFAULT = 101_100_000L; + private static final int CHANNEL = 0; + //12-bit samples in 16-bit values, which is the USB link format, so LimeSuiteNG only unpacks them. + private static final SampleFormat SAMPLE_FORMAT = SampleFormat.I12; + private static final float SAMPLE_SCALE = 1.0f / 2048.0f; + //The samples are offset by -0.5 LSB, which appears as a DC spike at the center of the spectrum. Measured on a + //LimeSDR-USB at -0.48 to -0.50 LSB on both I and Q, independent of frequency, sample rate and calibration. + private static final float SAMPLE_OFFSET = 0.5f; + private static final int BUFFER_SAMPLE_COUNT = 65536; + //Sample FIFO between the LimeSuiteNG receive loop and the stream thread, in seconds. This is the LimeSuiteNG + //default, which the 1M sample default of jLimeSDR (26 ms at 40 MHz) replaces. + private static final double FIFO_SECONDS = 0.25; + //Requests the largest USB transfers (jLimeSDR 25.1.0.1 and later), which keeps about 8.7 ms of transfers in + //flight at 40 MHz instead of 1.1 ms, so that the LimeSuiteNG receive thread, which doesn't get realtime priority + //without the rtprio limit on Linux, loses fewer packets when the system is busy. + private static final float THROUGHPUT_VS_LATENCY = 1.0f; + //Analog low-pass filter bandwidth relative to the sample rate. At 1.0, the noise floor is 2.4 dB down at 90% of + //the band, and at 1.2 it is 1.8 dB down. The LMS7002M decimation filters prevent aliasing from the wider filter. + private static final double LPF_BANDWIDTH_RATIO = 1.2; + private static final double USABLE_BANDWIDTH_PERCENT = 0.85; + //The USB link carries 3 bytes per sample (12-bit I and Q). USB 2.0 sustains about 32 MBytes per second. + private static final double USB2_MAXIMUM_SAMPLE_RATE = 32_000_000.0 / 3.0; + private static final long STREAM_CHECK_INTERVAL_SECONDS = 1; + + private final int mBus; + private final String mPortAddress; + private LimeSdrDevice mDevice; + private LimeSdrDeviceInformation mDeviceInfo; + private boolean mStreaming; + private ScheduledFuture mStreamCheck; + private boolean mCalibrated; + private List mSampleRates = List.of(); + private LimeSdrSampleRate mSampleRate; + private List mAntennaPorts = List.of(); + private List mAntennas = List.of(); + private String mAntenna; + private Range mLpfRange; + private long mMinimumTunableFrequency = MINIMUM_TUNABLE_FREQUENCY_HZ; + private long mMaximumTunableFrequency = MAXIMUM_TUNABLE_FREQUENCY_HZ; + private int mGain = GAIN_DEFAULT; + private volatile float mSamplesPerMillisecond = 0.0f; + //Reused by the stream thread for each buffer of samples from the library + private short[] mReceiveBuffer = new short[0]; + //Hardware timestamp (sample count) expected for the next buffer, or -1 when the stream has just started. A larger + //timestamp means that samples were dropped, either on the USB link or because the stream FIFO overflowed. + private long mExpectedTimestamp = -1; + private final AtomicLong mDroppedSamples = new AtomicLong(); + //Calibrating while streaming makes small timestamp gaps that aren't dropped samples, so gaps aren't counted until + //shortly after a calibration (System.nanoTime) + private volatile long mIgnoreGapsUntil = Long.MIN_VALUE; + private static final long CALIBRATION_GAP_GRACE_NANOSECONDS = TimeUnit.MILLISECONDS.toNanos(250); + + /** + * Constructs an instance + * @param bus usb + * @param portAddress usb + * @param tunerErrorListener to receive error notifications from this controller + */ + public LimeSdrTunerController(int bus, String portAddress, ITunerErrorListener tunerErrorListener) + { + super(tunerErrorListener); + mBus = bus; + mPortAddress = portAddress; + setMinimumFrequency(MINIMUM_TUNABLE_FREQUENCY_HZ); + setMaximumFrequency(MAXIMUM_TUNABLE_FREQUENCY_HZ); + setMiddleUnusableHalfBandwidth(0); + setUsableBandwidthPercentage(USABLE_BANDWIDTH_PERCENT); + } + + @Override + public TunerType getTunerType() + { + return TunerType.LIMESDR; + } + + @Override + public int getBufferSampleCount() + { + return BUFFER_SAMPLE_COUNT; + } + + @Override + public void start() throws SourceException + { + getLock().lock(); + + try + { + installLogHandler(); + UsbIdentity identity = readUsbIdentity(); + DeviceEntry entry = findDevice(identity, LimeSdr.listDevices()); + mDevice = LimeSdr.open(entry); + mDevice.init(); + mDevice.enableChannel(Direction.RX, CHANNEL, true); + mSampleRate = null; + mCalibrated = false; + + DeviceInfo info = mDevice.deviceInfo(); + mDeviceInfo = new LimeSdrDeviceInformation(info.serialNumberHex(), info.deviceName(), + info.firmwareVersion(), info.hardwareVersion(), info.gatewareVersion(), entry.media(), + LimeSdr.libraryVersion()); + + Range frequencies = mDevice.frequencyRange(Direction.RX); + mMinimumTunableFrequency = (long)frequencies.min(); + mMaximumTunableFrequency = (long)frequencies.max(); + setFrequencyExtents(mMinimumTunableFrequency, mMaximumTunableFrequency); + + Range rates = mDevice.sampleRateRange(Direction.RX); + mSampleRates = LimeSdrSampleRate.getRates(rates.min(), getMaximumSampleRate(rates, entry.media())); + + if(mSampleRates.isEmpty()) + { + throw new SourceException("LimeSDR sample rate range [" + rates + "] doesn't include any usable rates"); + } + + mLpfRange = mDevice.lpfBandwidthRange(Direction.RX); + mAntennaPorts = mDevice.antennas(Direction.RX, CHANNEL); + mAntennas = getSelectableAntennas(mAntennaPorts); + + if(mAntennas.isEmpty()) + { + throw new SourceException("LimeSDR doesn't report any receive antenna ports " + mAntennaPorts); + } + + setSampleRate(LimeSdrSampleRate.getClosest(mSampleRates, LimeSdrSampleRate.DEFAULT.getRate())); + setFrequency(FREQUENCY_DEFAULT); + setAntenna(getDefaultAntenna(mAntennas)); + setGain(GAIN_DEFAULT); + mLog.info("LimeSDR [" + mDeviceInfo.board() + "] serial [" + mDeviceInfo.serial() + "] started on [" + + mDeviceInfo.connection() + "] - LimeSuiteNG " + mDeviceInfo.library()); + } + catch(LimeSdrException | RuntimeException | LinkageError e) + { + closeDevice(mDevice); + mDevice = null; + throw new SourceException("Can't start LimeSDR tuner - " + e.getMessage(), e); + } + catch(SourceException se) + { + closeDevice(mDevice); + mDevice = null; + throw se; + } + finally + { + getLock().unlock(); + } + } + + @Override + public void stop() + { + LimeSdrDevice device; + + getLock().lock(); + + try + { + device = mDevice; + mDevice = null; + clearStream(); + } + finally + { + getLock().unlock(); + } + + mNativeBufferBroadcaster.clear(); + + //Closing the device also stops the sample stream + closeDevice(device); + } + + /** + * Closes the device. + * @param device to close, or null + */ + private static void closeDevice(LimeSdrDevice device) + { + if(device != null) + { + try + { + device.close(); + } + catch(LimeSdrException | RuntimeException e) + { + mLog.error("Error closing LimeSDR device", e); + } + } + } + + /** + * Sends LimeSuiteNG log messages to the application log. Errors are also included in the message of the exception + * from the failed call. + */ + private static void installLogHandler() + { + if(LOG_HANDLER_INSTALLED.compareAndSet(false, true)) + { + LimeSdr.setLogHandler(LimeSdrTunerController::log); + } + } + + /** + * Logs a LimeSuiteNG message. Its informational messages, such as calibration progress, are logged at debug level. + * Verbose and debug messages are discarded, since some are logged from the LimeSuiteNG receive loop for each lost + * packet, and writing them to the log stalls the receive loop, which loses more packets. Lost samples are detected + * from the sample timestamps instead and reported by the stream check. + */ + static void log(LogLevel level, String message) + { + switch(level) + { + case CRITICAL, ERROR -> LIME_SUITE_LOG.error(message); + case WARNING -> LIME_SUITE_LOG.warn(message); + case INFO -> LIME_SUITE_LOG.debug(message); + default -> {} + } + } + + /** + * Number of samples in the stream FIFO for the sample rate. + */ + static int getFifoSize(LimeSdrSampleRate rate) + { + return Math.max(StreamConfig.DEFAULT_FIFO_SIZE, (int)(rate.getRate() * FIFO_SECONDS)); + } + + /** + * Highest sample rate supported by the hardware and the USB connection. + * @param range of sample rates reported by the device + * @param connection type, such as USB3.0 or USB2.0 + * @return maximum sample rate + */ + static double getMaximumSampleRate(Range range, String connection) + { + if(connection != null && connection.toUpperCase(Locale.ROOT).startsWith("USB2")) + { + return Math.min(range.max(), USB2_MAXIMUM_SAMPLE_RATE); + } + + return range.max(); + } + + /** + * Receive antenna ports that a user can select, which excludes the 'None' port and the internal loopback ports. + * @param ports reported by the device, where each port's index is its position in the list. + * @return port names + */ + static List getSelectableAntennas(List ports) + { + List antennas = new ArrayList<>(); + + for(String port : ports) + { + String name = port.toUpperCase(Locale.ROOT); + + if(!name.equals("NONE") && !name.startsWith("LB")) + { + antennas.add(port); + } + } + + return antennas; + } + + /** + * Default antenna port: the wideband LNA when the device has one, otherwise the first port. + */ + static String getDefaultAntenna(List antennas) + { + for(String antenna : antennas) + { + if(antenna.equalsIgnoreCase(ANTENNA_DEFAULT)) + { + return antenna; + } + } + + return antennas.getFirst(); + } + + /** + * Identifies the USB device at this controller's bus and port. + * @param vendor id + * @param product id + * @param serial number, or null when it can't be read + */ + record UsbIdentity(int vendor, int product, String serial) {} + + /** + * Finds the LimeSuiteNG entry for the USB device. The entry address is the USB vendor and product ID, so the device + * is matched by serial number, or when the serial number isn't available, by being the only device of its kind. + * @param identity of the USB device + * @param entries from LimeSuiteNG + * @return matching entry + * @throws SourceException if there isn't exactly one match + */ + static DeviceEntry findDevice(UsbIdentity identity, List entries) throws SourceException + { + List candidates = new ArrayList<>(); + + for(DeviceEntry entry : entries) + { + if(isUsbDevice(entry, identity.vendor(), identity.product())) + { + if(identity.serial() != null && entry.hasSerialNumber(identity.serial())) + { + return entry; + } + + candidates.add(entry); + } + } + + if(identity.serial() == null && candidates.size() == 1) + { + return candidates.getFirst(); + } + + if(candidates.isEmpty()) + { + throw new SourceException("LimeSDR library can't access the USB device - if using linux, check the udev " + + "rules. If using windows, install the LimeSDR-USB (Cypress) or LimeSDR Mini (FTDI D3XX) driver."); + } + + if(identity.serial() == null) + { + throw new SourceException("Can't read the LimeSDR USB serial number to choose between " + + candidates.size() + " LimeSDR devices"); + } + + throw new SourceException("LimeSDR library can't access the device with serial number [" + identity.serial() + + "] - it may be in use by another application"); + } + + /** + * Indicates if the LimeSuiteNG entry is a USB device with the vendor and product ID. + */ + private static boolean isUsbDevice(DeviceEntry entry, int vendor, int product) + { + String[] ids = entry.address().split(":"); + + if(ids.length != 2 || !entry.media().toUpperCase(Locale.ROOT).startsWith("USB")) + { + return false; + } + + try + { + return Integer.parseInt(ids[0].trim(), 16) == vendor && Integer.parseInt(ids[1].trim(), 16) == product; + } + catch(NumberFormatException e) + { + return false; + } + } + + /** + * Reads the vendor ID, product ID and serial number of the USB device at this controller's bus and port. The device + * is closed again before returning so that LimeSuiteNG can open it. + */ + private UsbIdentity readUsbIdentity() throws SourceException + { + Context context = new Context(); + int status = LibUsb.init(context); + + if(status != LibUsb.SUCCESS) + { + throw new SourceException("Can't initialize libusb library - " + LibUsb.errorName(status)); + } + + try + { + DeviceList deviceList = new DeviceList(); + int count = LibUsb.getDeviceList(context, deviceList); + + if(count < 0) + { + throw new SourceException("Can't get the USB device list - " + LibUsb.errorName(count)); + } + + try + { + for(Device device : deviceList) + { + if(LibUsb.getBusNumber(device) == mBus && LibUsb.getPortNumber(device) > 0 && + mPortAddress != null && mPortAddress.equals(TunerManager.getPortAddress(device))) + { + return readUsbIdentity(device); + } + } + } + finally + { + LibUsb.freeDeviceList(deviceList, true); + } + } + finally + { + LibUsb.exit(context); + } + + throw new SourceException("Couldn't find USB device at bus [" + mBus + "] port [" + mPortAddress + "]"); + } + + /** + * Reads the vendor ID, product ID and serial number of the USB device. + */ + private static UsbIdentity readUsbIdentity(Device device) throws SourceException + { + DeviceDescriptor descriptor = new DeviceDescriptor(); + int status = LibUsb.getDeviceDescriptor(device, descriptor); + + if(status != LibUsb.SUCCESS) + { + throw new SourceException("Can't obtain tuner's device descriptor - " + LibUsb.errorName(status)); + } + + int vendor = descriptor.idVendor() & 0xFFFF; + int product = descriptor.idProduct() & 0xFFFF; + String serial = null; + + if(descriptor.iSerialNumber() != 0) + { + DeviceHandle handle = new DeviceHandle(); + status = LibUsb.open(device, handle); + + if(status == LibUsb.SUCCESS) + { + try + { + //LimeSuiteNG aborts the application when it opens a device that another application is using, + //since the open throws a C++ exception through its C API. Check that the device is free first. + status = LibUsb.claimInterface(handle, 0); + + if(status == LibUsb.ERROR_BUSY) + { + throw new SourceException("LimeSDR is in use by another application"); + } + else if(status == LibUsb.SUCCESS) + { + LibUsb.releaseInterface(handle, 0); + } + + serial = LibUsb.getStringDescriptor(handle, descriptor.iSerialNumber()); + serial = serial != null && !serial.isBlank() ? serial.trim() : null; + } + finally + { + LibUsb.close(handle); + } + } + else + { + //Expected on Windows, where libusb can't open a device that uses the Cypress or FTDI driver + mLog.debug("Can't open LimeSDR to read its USB serial number - " + LibUsb.errorName(status)); + } + } + + return new UsbIdentity(vendor, product, serial); + } + + @Override + public void apply(TunerConfiguration tunerConfiguration) throws SourceException + { + if(!(tunerConfiguration instanceof LimeSdrTunerConfiguration config)) + { + throw new IllegalArgumentException("Invalid tuner config:" + tunerConfiguration.getClass()); + } + + //Saved limits can be wider than this device, since the defaults are for the LimeSDR-USB. + config.setMinimumFrequency(Math.max(config.getMinimumFrequency(), mMinimumTunableFrequency)); + config.setMaximumFrequency(Math.min(config.getMaximumFrequency() > 0 ? config.getMaximumFrequency() : + mMaximumTunableFrequency, mMaximumTunableFrequency)); + + LimeSdrSampleRate rate = LimeSdrSampleRate.getClosest(mSampleRates, config.getSampleRate()); + setSampleRate(rate); + config.setSampleRate(rate.getRate()); + + //Invoke super for frequency, frequency correction and autoPPM + super.apply(config); + + String antenna = mAntennas.contains(config.getAntenna()) ? config.getAntenna() : getDefaultAntenna(mAntennas); + setAntenna(antenna); + config.setAntenna(antenna); + + int gain = Math.clamp(config.getGain(), GAIN_MINIMUM, GAIN_MAXIMUM); + setGain(gain); + config.setGain(gain); + } + + @Override + public long getTunedFrequency() throws SourceException + { + return mFrequencyController.getTunedFrequency(); + } + + @Override + public void setTunedFrequency(long frequency) throws SourceException + { + getLock().lock(); + + try + { + if(mDevice != null) + { + mDevice.setFrequency(Direction.RX, CHANNEL, frequency); + } + } + catch(LimeSdrException e) + { + throw new SourceException("Can't tune LimeSDR to [" + frequency + "] - " + e.getMessage(), e); + } + finally + { + getLock().unlock(); + } + } + + @Override + public double getCurrentSampleRate() + { + return mSampleRate != null ? mSampleRate.getRate() : 0; + } + + /** + * Sample rates supported by the device across its current USB connection. + */ + public List getSampleRates() + { + return mSampleRates; + } + + /** + * Current sample rate. + */ + public LimeSdrSampleRate getLimeSdrSampleRate() + { + return mSampleRate; + } + + /** + * Sets the sample rate and the matching analog filter bandwidth. + * @param rate to set + * @throws SourceException if the sample rate is locked or can't be set + */ + public void setSampleRate(LimeSdrSampleRate rate) throws SourceException + { + getLock().lock(); + + try + { + if(rate != mSampleRate && mDevice != null) + { + if(isLockedSampleRate()) + { + throw new SourceException("Can't change LimeSDR sample rate while channels are allocated"); + } + + //The stream is restarted because LimeSuiteNG sizes it for the sample rate when it starts. Raising the + //rate of a running stream limits it to about 38 MHz on a LimeSDR-USB. + boolean streaming = mStreaming; + + try + { + if(streaming) + { + clearStream(); + mDevice.stopRx(); + } + + mDevice.setSampleRate(rate.getRate(), 0); + mDevice.setLpfBandwidth(Direction.RX, CHANNEL, getLpfBandwidth(rate)); + mSampleRate = rate; + mSamplesPerMillisecond = rate.getRate() / 1000.0f; + mFrequencyController.setSampleRate(rate.getRate()); + } + catch(LimeSdrException e) + { + throw new SourceException("Can't set LimeSDR sample rate to [" + rate + "] - " + e.getMessage(), e); + } + finally + { + //Calibrated when the stream starts + mCalibrated = false; + + if(streaming && mDevice != null) + { + startStream(); + } + } + } + } + finally + { + getLock().unlock(); + } + } + + /** + * Analog low-pass filter bandwidth for the sample rate, within the filter's range. + */ + private double getLpfBandwidth(LimeSdrSampleRate rate) + { + return Math.clamp(rate.getRate() * LPF_BANDWIDTH_RATIO, mLpfRange.min(), mLpfRange.max()); + } + + /** + * Receive antenna ports that can be selected. + */ + public List getAntennas() + { + return mAntennas; + } + + /** + * Current receive antenna port. + */ + public String getAntenna() + { + return mAntenna; + } + + /** + * Selects the receive antenna port. + * @param antenna name from {@link #getAntennas()} + * @throws SourceException if the port can't be selected + */ + public void setAntenna(String antenna) throws SourceException + { + getLock().lock(); + + try + { + if(mDevice != null && !antenna.equals(mAntenna)) + { + int index = mAntennaPorts.indexOf(antenna); + + if(index < 0 || !mAntennas.contains(antenna)) + { + throw new SourceException("LimeSDR doesn't have antenna port [" + antenna + "]"); + } + + mDevice.setAntenna(Direction.RX, CHANNEL, index); + mAntenna = antenna; + invalidateCalibration(); + } + } + catch(LimeSdrException e) + { + throw new SourceException("Can't set LimeSDR antenna to [" + antenna + "] - " + e.getMessage(), e); + } + 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; + } + + /** + * Current gain in dB. + */ + public int getGain() + { + return mGain; + } + + /** + * Sets the combined receive gain, which LimeSuiteNG distributes across the LNA, TIA and PGA amplifiers. + * @param gain in dB, 0 to 73 + * @throws SourceException if the gain can't be set + */ + public void setGain(int gain) throws SourceException + { + getLock().lock(); + + try + { + mGain = gain; + + if(mDevice != null) + { + mDevice.setGain(Direction.RX, CHANNEL, gain); + } + } + catch(LimeSdrException | IllegalArgumentException e) + { + throw new SourceException("Can't set LimeSDR gain to [" + gain + "] - " + e.getMessage(), e); + } + finally + { + getLock().unlock(); + } + } + + /** + * Marks the calibration as out of date, and calibrates now when the sample stream is running. Otherwise, the + * calibration is deferred until the stream starts, so that start-up and configuration changes calibrate once. + */ + private void invalidateCalibration() + { + mCalibrated = false; + + if(mStreaming) + { + calibrate(); + } + } + + /** + * Calibrates the receive chain for the current frequency, sample rate and antenna, and reapplies the gain which + * the calibration changes. A failed calibration is logged, since the tuner is still usable without it. + */ + private void calibrate() + { + if(mDevice == null || mSampleRate == null) + { + return; + } + + mIgnoreGapsUntil = Long.MAX_VALUE; + + try + { + mDevice.calibrate(Direction.RX, CHANNEL, getLpfBandwidth(mSampleRate)); + } + catch(LimeSdrException e) + { + mLog.warn("LimeSDR calibration failed - " + e.getMessage()); + } + finally + { + mIgnoreGapsUntil = System.nanoTime() + CALIBRATION_GAP_GRACE_NANOSECONDS; + } + + //Avoid repeating a calibration that failed + mCalibrated = true; + + try + { + mDevice.setGain(Direction.RX, CHANNEL, mGain); + } + catch(LimeSdrException e) + { + mLog.error("Can't restore LimeSDR gain after calibration - " + e.getMessage()); + } + } + + /** + * Device information, available once the tuner has been started. + */ + public LimeSdrDeviceInformation getDeviceInfo() + { + return mDeviceInfo; + } + + /** + * Adds the IQ buffer listener and automatically starts streaming, if not already started. + */ + @Override + public void addBufferListener(Listener listener) + { + getLock().lock(); + + try + { + if(mDevice != null) + { + super.addBufferListener(listener); + + if(!mStreaming) + { + startStream(); + } + } + } + catch(SourceException se) + { + mLog.error("Error starting streaming on LimeSDR 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() && mStreaming && mDevice != null) + { + clearStream(); + + try + { + mDevice.stopRx(); + } + catch(LimeSdrException | RuntimeException e) + { + mLog.error("Error stopping LimeSDR sample stream", e); + } + } + } + finally + { + getLock().unlock(); + } + } + + /** + * Starts the sample stream, calibrating first if the settings have changed. + */ + private void startStream() throws SourceException + { + if(!mCalibrated) + { + calibrate(); + } + + try + { + StreamConfig defaults = StreamConfig.of(CHANNEL, SAMPLE_FORMAT); + StreamConfig config = new StreamConfig(CHANNEL, SAMPLE_FORMAT, getFifoSize(mSampleRate), + defaults.linkFormat(), false, THROUGHPUT_VS_LATENCY); + mExpectedTimestamp = -1; + mDroppedSamples.set(0); + mDevice.startRx(config, BUFFER_SAMPLE_COUNT, this::receive); + mStreaming = true; + mStreamCheck = ThreadPool.SCHEDULED.scheduleAtFixedRate(this::checkStream, STREAM_CHECK_INTERVAL_SECONDS, + STREAM_CHECK_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + catch(LimeSdrException | RuntimeException e) + { + throw new SourceException("Can't start LimeSDR sample stream - " + e.getMessage(), e); + } + } + + /** + * Clears the sample stream state so that it's no longer monitored. The caller is responsible for stopping it. + */ + private void clearStream() + { + if(mStreamCheck != null) + { + mStreamCheck.cancel(false); + mStreamCheck = null; + } + + mStreaming = false; + } + + /** + * Detects a stream that ended by itself, such as when the device is unplugged, and flags the tuner. + */ + private void checkStream() + { + String message = null; + + getLock().lock(); + + try + { + if(mStreaming && mDevice != null && !mDevice.isReceiving()) + { + Throwable failure = mDevice.receiveFailure(); + message = "LimeSDR sample stream stopped - " + (failure != null ? failure.getMessage() : "unknown error"); + clearStream(); + } + } + finally + { + getLock().unlock(); + } + + long dropped = mDroppedSamples.getAndSet(0); + float samplesPerMillisecond = mSamplesPerMillisecond; + + if(dropped > 0 && samplesPerMillisecond > 0) + { + mLog.warn("LimeSDR dropped [" + dropped + "] samples (" + Math.round(dropped / samplesPerMillisecond) + + " ms) - the computer or USB connection isn't keeping up with the sample rate"); + } + + //Outside of the lock because this stops the tuner + if(message != null) + { + mLog.error(message); + setErrorMessage(message); + } + } + + /** + * Receives samples on the stream thread and dispatches them to the buffer listeners. Exceptions are contained here + * since an exception reaching the library stops the sample stream. + * @param buffer from the library, which is only valid until this method returns. + * @return true to continue streaming + */ + private boolean receive(SampleBuffer buffer) + { + try + { + countDroppedSamples(buffer); + int length = buffer.sampleCount() * 2; + + if(mReceiveBuffer.length != length) + { + mReceiveBuffer = new short[length]; + } + + buffer.copyShorts(mReceiveBuffer); + broadcast(new FloatNativeBuffer(scale(mReceiveBuffer), System.currentTimeMillis(), mSamplesPerMillisecond)); + } + catch(RuntimeException e) + { + mLog.error("Error dispatching LimeSDR sample buffer", e); + } + + return true; + } + + /** + * Counts the samples missing before this buffer, from its hardware timestamp. Called on the stream thread. + */ + private void countDroppedSamples(SampleBuffer buffer) + { + long gap = getTimestampGap(mExpectedTimestamp, buffer.timestamp()); + + if(gap > 0 && System.nanoTime() - mIgnoreGapsUntil > 0) + { + mDroppedSamples.addAndGet(gap); + } + + mExpectedTimestamp = buffer.timestamp() + buffer.sampleCount(); + } + + /** + * Number of samples missing between the expected and actual timestamp of a buffer. + * @param expected timestamp, or -1 for the first buffer of a stream + * @param actual timestamp + * @return missing samples, or zero when there's no gap or the timestamps restarted + */ + static long getTimestampGap(long expected, long actual) + { + return expected >= 0 && actual > expected ? actual - expected : 0; + } + + /** + * Scales 12-bit samples to the range -1 to 1 and removes the -0.5 LSB offset of the ADC. + * @param samples interleaved complex samples + * @return scaled interleaved complex samples + */ + static float[] scale(short[] samples) + { + float[] scaled = new float[samples.length]; + + for(int x = 0; x < samples.length; x++) + { + scaled[x] = (samples[x] + SAMPLE_OFFSET) * SAMPLE_SCALE; + } + + return scaled; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerEditor.java b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerEditor.java new file mode 100644 index 00000000..d8bd3217 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerEditor.java @@ -0,0 +1,363 @@ +/* + * ***************************************************************************** + * 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.limesdr; + +import io.github.dsheirer.preference.UserPreferences; +import io.github.dsheirer.source.tuner.manager.DiscoveredTuner; +import io.github.dsheirer.source.tuner.manager.TunerManager; +import io.github.dsheirer.source.tuner.manager.TunerStatus; +import io.github.dsheirer.source.tuner.ui.TunerEditor; +import java.io.Serial; +import java.util.List; +import net.miginfocom.swing.MigLayout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.swing.DefaultComboBoxModel; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JSeparator; +import javax.swing.JSlider; +import javax.swing.SpinnerNumberModel; + +/** + * LimeSDR tuner editor + */ +public class LimeSdrTunerEditor extends TunerEditor +{ + @Serial + private static final long serialVersionUID = 1L; + private static final Logger mLog = LoggerFactory.getLogger(LimeSdrTunerEditor.class); + private JButton mTunerInfoButton; + private JComboBox mSampleRateCombo; + private JComboBox mAntennaCombo; + private JSlider mGainSlider; + private JLabel mGainValueLabel; + + /** + * Constructs an instance + * @param userPreferences for wide-band recordings + * @param tunerManager for saving tuner configuration + * @param discoveredTuner for the optionally usable LimeSDR tuner and controller + */ + public LimeSdrTunerEditor(UserPreferences userPreferences, TunerManager tunerManager, DiscoveredTuner discoveredTuner) + { + super(userPreferences, tunerManager, discoveredTuner); + init(); + tunerStatusUpdated(); + } + + @Override + public long getMinimumTunableFrequency() + { + return hasTuner() ? getTuner().getController().getMinimumTunableFrequency() : + LimeSdrTunerController.MINIMUM_TUNABLE_FREQUENCY_HZ; + } + + @Override + public long getMaximumTunableFrequency() + { + return hasTuner() ? getTuner().getController().getMaximumTunableFrequency() : + LimeSdrTunerController.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 JLabel("Antenna:")); + add(getAntennaCombo(), "wrap"); + + add(new JSeparator(), "span,growx"); + + add(new JLabel("Gain:")); + add(getGainSlider()); + add(getGainValueLabel()); + + //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) + { + LimeSdrTunerController controller = getTuner().getController(); + List rates = controller.getSampleRates(); + getSampleRateCombo().setModel(new DefaultComboBoxModel<>(rates.toArray(new LimeSdrSampleRate[0]))); + getSampleRateCombo().setSelectedItem(controller.getLimeSdrSampleRate()); + getSampleRateCombo().setEnabled(!controller.isLockedSampleRate()); + + List antennas = controller.getAntennas(); + getAntennaCombo().setModel(new DefaultComboBoxModel<>(antennas.toArray(new String[0]))); + getAntennaCombo().setSelectedItem(controller.getAntenna()); + getAntennaCombo().setEnabled(true); + + getGainSlider().setValue(controller.getGain()); + getGainSlider().setEnabled(true); + getGainValueLabel().setEnabled(true); + } + else + { + getSampleRateCombo().setModel(new DefaultComboBoxModel<>()); + getSampleRateCombo().setEnabled(false); + getAntennaCombo().setModel(new DefaultComboBoxModel<>()); + getAntennaCombo().setEnabled(false); + getGainSlider().setEnabled(false); + getGainValueLabel().setEnabled(false); + } + + updateGainLabel(); + 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().getAntenna() != null; + } + + private void updateGainLabel() + { + getGainValueLabel().setText(getGainSlider().getValue() + " dB"); + } + + /** + * Button that provides tuner information + */ + private JButton getTunerInfoButton() + { + if(mTunerInfoButton == null) + { + mTunerInfoButton = new JButton("Info"); + mTunerInfoButton.setEnabled(false); + mTunerInfoButton.addActionListener(_ -> JOptionPane.showMessageDialog(LimeSdrTunerEditor.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()) + { + LimeSdrSampleRate rate = (LimeSdrSampleRate)mSampleRateCombo.getSelectedItem(); + + try + { + getTuner().getController().setSampleRate(rate); + + //Adjust the min/max values for the sample rate. + adjustForSampleRate(rate.getRate()); + + save(); + } + catch(Exception e1) + { + mLog.error("Error setting LimeSDR sample rate", e1); + JOptionPane.showMessageDialog(LimeSdrTunerEditor.this, "Couldn't set sample rate to " + rate); + } + } + }); + } + + return mSampleRateCombo; + } + + private JComboBox getAntennaCombo() + { + if(mAntennaCombo == null) + { + mAntennaCombo = new JComboBox<>(); + mAntennaCombo.setEnabled(false); + mAntennaCombo.setToolTipText("Receive port: LNAW wideband (default), LNAL low band, LNAH high band"); + mAntennaCombo.addActionListener(_ -> + { + if(hasTuner() && !isLoading()) + { + String antenna = (String)mAntennaCombo.getSelectedItem(); + + try + { + getTuner().getController().setAntenna(antenna); + save(); + } + catch(Exception e1) + { + mLog.error("Error setting LimeSDR antenna", e1); + JOptionPane.showMessageDialog(mAntennaCombo, "Couldn't set antenna to " + antenna); + } + } + }); + } + + return mAntennaCombo; + } + + private JSlider getGainSlider() + { + if(mGainSlider == null) + { + mGainSlider = new JSlider(JSlider.HORIZONTAL, LimeSdrTunerController.GAIN_MINIMUM, + LimeSdrTunerController.GAIN_MAXIMUM, LimeSdrTunerController.GAIN_DEFAULT); + mGainSlider.setEnabled(false); + mGainSlider.setMajorTickSpacing(5); + mGainSlider.setPaintTicks(true); + mGainSlider.addChangeListener(_ -> + { + int gain = mGainSlider.getValue(); + + if(hasTuner() && !isLoading()) + { + try + { + getTuner().getController().setGain(gain); + save(); + } + catch(Exception e) + { + mLog.error("Couldn't set LimeSDR gain to:" + gain, e); + JOptionPane.showMessageDialog(mGainSlider, "Couldn't set gain value to " + gain); + } + } + + updateGainLabel(); + }); + } + + return mGainSlider; + } + + private JLabel getGainValueLabel() + { + if(mGainValueLabel == null) + { + mGainValueLabel = new JLabel("0 dB"); + mGainValueLabel.setEnabled(false); + } + + return mGainValueLabel; + } + + @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 LimeSdrSampleRate rate) + { + getConfiguration().setSampleRate(rate.getRate()); + } + + if(getAntennaCombo().getSelectedItem() instanceof String antenna) + { + getConfiguration().setAntenna(antenna); + } + + getConfiguration().setGain(getGainSlider().getValue()); + 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()) + { + LimeSdrDeviceInformation info = getTuner().getController().getDeviceInfo(); + StringBuilder sb = new StringBuilder(); + sb.append("

LimeSDR Tuner

"); + sb.append("Board: ").append(info.board()).append("
"); + sb.append("Serial: ").append(info.serial()).append("
"); + sb.append("Hardware: ").append(info.hardware()).append("
"); + sb.append("Firmware: ").append(info.firmware()).append("
"); + sb.append("Gateware: ").append(info.gateware()).append("
"); + sb.append("Connection: ").append(info.connection()).append("
"); + sb.append("LimeSuiteNG: ").append(info.library()).append("
"); + return sb.toString(); + } + + return null; + } +} diff --git a/src/main/resources/65-limesdr.rules b/src/main/resources/65-limesdr.rules new file mode 100644 index 00000000..f8dde3c3 --- /dev/null +++ b/src/main/resources/65-limesdr.rules @@ -0,0 +1,7 @@ +# LimeSDR udev rules +# Grants access to the logged-in user (uaccess) and to the plugdev group. + +# LimeSDR-USB +SUBSYSTEM=="usb", ATTR{idVendor}=="1d50", ATTR{idProduct}=="6108", MODE="0660", GROUP="plugdev", TAG+="uaccess" +# LimeSDR Mini and LimeSDR Mini 2.0 +SUBSYSTEM=="usb", ATTR{idVendor}=="0403", ATTR{idProduct}=="601f", MODE="0660", GROUP="plugdev", TAG+="uaccess" diff --git a/src/test/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerTest.java b/src/test/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerTest.java new file mode 100644 index 00000000..479a04d7 --- /dev/null +++ b/src/test/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerTest.java @@ -0,0 +1,204 @@ +/* + * ***************************************************************************** + * 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.limesdr; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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.dsp.filter.channelizer.ComplexPolyphaseChannelizerM2; +import io.github.dsheirer.limesdr.DeviceEntry; +import io.github.dsheirer.limesdr.LogLevel; +import io.github.dsheirer.limesdr.Range; +import io.github.dsheirer.limesdr.StreamConfig; +import io.github.dsheirer.source.SourceException; +import io.github.dsheirer.source.tuner.TunerClass; +import io.github.dsheirer.source.tuner.TunerFactory; +import io.github.dsheirer.source.tuner.TunerType; +import io.github.dsheirer.source.tuner.configuration.TunerConfiguration; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests for the parts of the LimeSDR tuner that don't require a device. + */ +class LimeSdrTunerTest +{ + private static final DeviceEntry USB_A = DeviceEntry.parse("LimeSDR-USB, media=USB3.0, addr=1d50:6108, serial=0009060B00462008"); + private static final DeviceEntry USB_B = DeviceEntry.parse("LimeSDR-USB, media=USB3.0, addr=1d50:6108, serial=0009060B00471B22"); + private static final DeviceEntry MINI = DeviceEntry.parse("LimeSDR-Mini, media=USB3.0, addr=0403:601f, serial=1D3AC9A5C7B4D2"); + + @Test + void lookupFindsEachUsbProduct() + { + assertEquals(TunerClass.LIMESDR, TunerClass.lookup((short)0x1D50, (short)0x6108)); + assertEquals(TunerClass.LIMESDR, TunerClass.lookup((short)0x0403, (short)0x601F)); + assertTrue(TunerClass.LIMESDR.isSupportedUsbTuner()); + assertTrue(TunerType.LIMESDR.isSupportedUsbTuner()); + } + + @Test + void scalesSamplesAndRemovesHalfBitOffset() + { + short[] samples = {2047, -2048, -1, 0}; + + assertArrayEquals(new float[]{2047.5f / 2048, -2047.5f / 2048, -0.5f / 2048, 0.5f / 2048}, + LimeSdrTunerController.scale(samples)); + } + + @Test + void sampleRatesAreLimitedByTheDevice() + { + //LimeSDR-USB on USB 3.0 + assertEquals(List.of(LimeSdrSampleRate.values()), LimeSdrSampleRate.getRates(100_000, 61_440_000)); + //LimeSDR Mini + assertEquals(LimeSdrSampleRate.RATE_30_72, LimeSdrSampleRate.getRates(100_000, 30_720_000).getLast()); + } + + @Test + void usb2LimitsTheSampleRate() + { + Range range = new Range(100_000, 61_440_000, 0); + + assertEquals(61_440_000, LimeSdrTunerController.getMaximumSampleRate(range, "USB3.0")); + double usb2 = LimeSdrTunerController.getMaximumSampleRate(range, "USB2.0"); + assertEquals(LimeSdrSampleRate.RATE_10_0, LimeSdrSampleRate.getRates(range.min(), usb2).getLast()); + } + + @Test + void savedRateAboveTheDeviceMaximumIsReduced() + { + List rates = LimeSdrSampleRate.getRates(100_000, 30_720_000); + + assertEquals(LimeSdrSampleRate.RATE_30_72, LimeSdrSampleRate.getClosest(rates, LimeSdrSampleRate.RATE_61_44.getRate())); + assertEquals(LimeSdrSampleRate.RATE_1_25, LimeSdrSampleRate.getClosest(rates, 1)); + } + + @Test + void sampleRatesDivideIntoPolyphaseChannels() + { + for(LimeSdrSampleRate rate : LimeSdrSampleRate.values()) + { + int channelCount = ComplexPolyphaseChannelizerM2.getChannelCount(rate.getRate()); + assertEquals(0, channelCount % 2, rate.toString()); + assertTrue((double)rate.getRate() / channelCount >= 25_000.0, rate.toString()); + } + } + + @Test + void maximumUsbRateDoesNotOverflow() + { + assertTrue(LimeSdrSampleRate.RATE_61_44.getRate() * 24L <= Integer.MAX_VALUE); + } + + @Test + void antennaListExcludesNoneAndLoopbackPorts() + { + List ports = List.of("None", "LNAH", "LNAL", "LNAW", "LB1", "LB2"); + List antennas = LimeSdrTunerController.getSelectableAntennas(ports); + + assertEquals(List.of("LNAH", "LNAL", "LNAW"), antennas); + assertEquals("LNAW", LimeSdrTunerController.getDefaultAntenna(antennas)); + assertEquals("LNAH", LimeSdrTunerController.getDefaultAntenna(List.of("LNAH"))); + } + + @Test + void deviceIsFoundBySerialNumber() throws SourceException + { + LimeSdrTunerController.UsbIdentity identity = new LimeSdrTunerController.UsbIdentity(0x1D50, 0x6108, + "0009060B00471B22"); + + assertEquals(USB_B, LimeSdrTunerController.findDevice(identity, List.of(MINI, USB_A, USB_B))); + } + + @Test + void deviceWithoutSerialIsFoundWhenItIsTheOnlyOneOfItsKind() throws SourceException + { + LimeSdrTunerController.UsbIdentity mini = new LimeSdrTunerController.UsbIdentity(0x0403, 0x601F, null); + LimeSdrTunerController.UsbIdentity usb = new LimeSdrTunerController.UsbIdentity(0x1D50, 0x6108, null); + + assertEquals(MINI, LimeSdrTunerController.findDevice(mini, List.of(USB_A, MINI))); + assertThrows(SourceException.class, () -> LimeSdrTunerController.findDevice(usb, List.of(USB_A, USB_B, MINI))); + } + + @Test + void deviceThatLimeSuiteCantSeeIsAnError() + { + LimeSdrTunerController.UsbIdentity identity = new LimeSdrTunerController.UsbIdentity(0x1D50, 0x6108, + "0009060B00462008"); + + assertThrows(SourceException.class, () -> LimeSdrTunerController.findDevice(identity, List.of(MINI))); + assertThrows(SourceException.class, () -> LimeSdrTunerController.findDevice(identity, List.of(USB_B))); + } + + @Test + void configurationSurvivesSerialization() throws Exception + { + LimeSdrTunerConfiguration configuration = new LimeSdrTunerConfiguration("test"); + configuration.setSampleRate(LimeSdrSampleRate.RATE_5_0.getRate()); + configuration.setGain(55); + configuration.setAntenna("LNAH"); + + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + String json = mapper.writeValueAsString(configuration); + TunerConfiguration restored = mapper.readValue(json, TunerConfiguration.class); + + LimeSdrTunerConfiguration limeSdr = assertInstanceOf(LimeSdrTunerConfiguration.class, restored); + assertEquals(TunerType.LIMESDR, limeSdr.getTunerType()); + assertEquals(LimeSdrSampleRate.RATE_5_0.getRate(), limeSdr.getSampleRate()); + assertEquals(55, limeSdr.getGain()); + assertEquals("LNAH", limeSdr.getAntenna()); + assertEquals("test", limeSdr.getUniqueID()); + } + + @Test + void factoryCreatesTheConfiguration() + { + assertInstanceOf(LimeSdrTunerConfiguration.class, TunerFactory.getTunerConfiguration(TunerType.LIMESDR, "id")); + } + + @Test + void fifoHoldsAQuarterSecondOfSamples() + { + assertEquals(StreamConfig.DEFAULT_FIFO_SIZE, LimeSdrTunerController.getFifoSize(LimeSdrSampleRate.RATE_1_25)); + assertEquals(10_000_000, LimeSdrTunerController.getFifoSize(LimeSdrSampleRate.RATE_40_0)); + } + + @Test + void debugMessagesAreDiscarded() + { + LimeSdrTunerController.log(LogLevel.DEBUG, "Loss: pkt:4622555 exp: 000000017a863dc0, got: 000000017a864860"); + LimeSdrTunerController.log(LogLevel.VERBOSE, "Set Rx LO frequency (450100180 Hz)"); + } + + @Test + void droppedSamplesAreFoundFromTimestampGaps() + { + assertEquals(0, LimeSdrTunerController.getTimestampGap(-1, 5_000_000)); + assertEquals(0, LimeSdrTunerController.getTimestampGap(65536, 65536)); + assertEquals(718_080, LimeSdrTunerController.getTimestampGap(32_309_248, 33_027_328)); + assertEquals(0, LimeSdrTunerController.getTimestampGap(32_309_248, 0)); + } +}