diff --git a/build.gradle b/build.gradle index e2da044d..a3deeacb 100644 --- a/build.gradle +++ b/build.gradle @@ -124,8 +124,9 @@ dependencies { implementation 'com.mpatric:mp3agic:0.9.1' implementation 'commons-io:commons-io:2.22.0' implementation 'eu.hansolo:charts:1.0.5' - implementation 'io.github.dsheirer:jbladerf:2.6.0' //bladeRF tuners - GitHub release, see settings.gradle implementation 'io.github.dsheirer:jairspy:1.0.12' //Airspy tuners - GitHub release, see settings.gradle + implementation 'io.github.dsheirer:jairspyhf:1.8.1' //Airspy HF+ tuners - GitHub release, see settings.gradle + implementation 'io.github.dsheirer:jbladerf:2.6.0' //bladeRF 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:jrtl:1.4.0.1' //RTL-SDR 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 ede6cff6..718e25f3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,6 +17,14 @@ dependencyResolutionManagement { content { includeModule 'io.github.dsheirer', 'jairspy' } } + //jAirspyHF is published as a GitHub release, with the native libraries for all platforms in the jar + ivy { + url = 'https://github.com/DSheirer/jAirspyHF/releases/download' + patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' } + metadataSources { artifact() } + content { includeModule 'io.github.dsheirer', 'jairspyhf' } + } + //jRTL is published as a GitHub release, with the native libraries for all platforms in the jar ivy { url = 'https://github.com/DSheirer/jRTL/releases/download' diff --git a/src/main/java/io/github/dsheirer/buffer/airspy/AirspyHfNativeBuffer.java b/src/main/java/io/github/dsheirer/buffer/airspy/AirspyHfNativeBuffer.java deleted file mode 100644 index ae34f952..00000000 --- a/src/main/java/io/github/dsheirer/buffer/airspy/AirspyHfNativeBuffer.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * ***************************************************************************** - * Copyright (C) 2014-2025 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.buffer.airspy; - -import io.github.dsheirer.buffer.AbstractNativeBuffer; -import io.github.dsheirer.sample.complex.ComplexSamples; -import io.github.dsheirer.sample.complex.InterleavedComplexSamples; -import java.util.Iterator; - -/** - * Native buffer implementation for Airspy HF+ & Discovery tuners. - */ -public class AirspyHfNativeBuffer extends AbstractNativeBuffer -{ - public static final float SCALE = 1.0f / 32768.0f; - private short[] mInterleavedSamples; - private float mAverageDc; - - /** - * Constructs an instance. - * @param timestamp of the first sample of the buffer - * @param interleavedSamples pairs of 16-bit complex samples. - */ - public AirspyHfNativeBuffer(long timestamp, float samplesPerMillisecond, float averageDc, short[] interleavedSamples) - { - super(timestamp, samplesPerMillisecond); - mInterleavedSamples = interleavedSamples; - mAverageDc = averageDc; - } - - @Override - public Iterator iterator() - { - return new IteratorComplexSamples(); - } - - @Override - public Iterator iteratorInterleaved() - { - return new IteratorInterleaved(); - } - - @Override - public int sampleCount() - { - return mInterleavedSamples.length / 2; - } - - /** - * Scalar implementation of complex samples buffer iterator - */ - public class IteratorComplexSamples implements Iterator - { - private boolean mHasNext = true; - - @Override - public boolean hasNext() - { - return mHasNext; - } - - @Override - public ComplexSamples next() - { - int length = mInterleavedSamples.length / 2; - float[] i = new float[length]; - float[] q = new float[length]; - - int index = 0; - - for(int x = 0; x < mInterleavedSamples.length; x += 2) - { - index = x / 2; - //Native ordering is in Q, I, Q, I ... reverse it to I, Q, I, Q - i[index] = (mInterleavedSamples[x + 1] * SCALE) - mAverageDc; - q[index] = (mInterleavedSamples[x] * SCALE) - mAverageDc; - } - - mHasNext = false; - - return new ComplexSamples(i, q, getTimestamp()); - } - } - - /** - * Scalar implementation of interleaved sample buffer iterator. - */ - public class IteratorInterleaved implements Iterator - { - private boolean mHasNext = true; - - @Override - public boolean hasNext() - { - return mHasNext; - } - - @Override - public InterleavedComplexSamples next() - { - float[] samples = new float[mInterleavedSamples.length]; - - for(int x = 0; x < mInterleavedSamples.length; x += 2) - { - //Native ordering is in Q, I, Q, I ... reverse it to I, Q, I, Q - samples[x] = (mInterleavedSamples[x + 1] * SCALE) - mAverageDc; - samples[x + 1] = (mInterleavedSamples[x] * SCALE) - mAverageDc; - } - - mHasNext = false; - - return new InterleavedComplexSamples(samples, getTimestamp()); - } - } -} diff --git a/src/main/java/io/github/dsheirer/buffer/airspy/AirspyHfNativeBufferFactory.java b/src/main/java/io/github/dsheirer/buffer/airspy/AirspyHfNativeBufferFactory.java deleted file mode 100644 index 789bef1a..00000000 --- a/src/main/java/io/github/dsheirer/buffer/airspy/AirspyHfNativeBufferFactory.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * ***************************************************************************** - * Copyright (C) 2014-2025 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.buffer.airspy; - -import io.github.dsheirer.buffer.AbstractNativeBufferFactory; -import io.github.dsheirer.buffer.DcCorrectionManager; -import io.github.dsheirer.buffer.INativeBuffer; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.ShortBuffer; - -/** - * Airspy HF+ native buffer factory. - * - * Note: the tuner provides 1024 samples in each transfer where samples are 16-bit interleaved complex. - */ -public class AirspyHfNativeBufferFactory extends AbstractNativeBufferFactory -{ - private DcCorrectionManager mDcCorrectionManager = new DcCorrectionManager(); - - /** - * Constructs an instance - */ - public AirspyHfNativeBufferFactory() - { - } - - /** - * Converts the samples byte buffer into a native buffer and calculates DC offset - * @param samples byte array copied from native memory - * @param timestamp of the samples - * @return constructed native buffer - */ - @Override - public INativeBuffer getBuffer(ByteBuffer samples, long timestamp) - { - ShortBuffer shortBuffer = samples.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer(); - short[] converted = new short[shortBuffer.capacity()]; - shortBuffer.get(converted); - - if(mDcCorrectionManager.shouldCalculateDc()) - { - calculateDc(converted); - } - - return new AirspyHfNativeBuffer(timestamp, getSamplesPerMillisecond(), mDcCorrectionManager.getAverageDc(), converted); - } - - /** - * Calculates the average DC in the sample stream so that it can be subtracted from the samples when the - * native buffer is used. - * @param samples containing DC offset - */ - private void calculateDc(short[] samples) - { - float dcAccumulator = 0; - - for(short sample: samples) - { - dcAccumulator += sample; - } - - dcAccumulator /= samples.length; - mDcCorrectionManager.adjust(dcAccumulator * AirspyHfNativeBuffer.SCALE); - } -} diff --git a/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/AirspyHfTunerController.java b/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/AirspyHfTunerController.java index 5521a80b..723d1a09 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/AirspyHfTunerController.java +++ b/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/AirspyHfTunerController.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2025 Dennis Sheirer + * 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 @@ -19,52 +19,63 @@ package io.github.dsheirer.source.tuner.airspy.hf; -import io.github.dsheirer.buffer.INativeBufferFactory; -import io.github.dsheirer.buffer.airspy.AirspyHfNativeBufferFactory; +import io.github.dsheirer.airspyhf.AirspyHf; +import io.github.dsheirer.airspyhf.AirspyHfDevice; +import io.github.dsheirer.airspyhf.AirspyHfException; +import io.github.dsheirer.airspyhf.BoardInfo; +import io.github.dsheirer.airspyhf.SampleBuffer; +import io.github.dsheirer.buffer.FloatNativeBuffer; +import io.github.dsheirer.buffer.INativeBuffer; +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.usb.USBTunerController; -import io.github.dsheirer.util.ByteUtil; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.IntBuffer; +import io.github.dsheirer.source.tuner.manager.TunerManager; import java.util.ArrayList; +import java.util.Collections; import java.util.List; 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; /** - * Airspy HF+ and HF Discovery tuner controller. + * Airspy HF+ Dual Port, HF+ Discovery and Ranger tuner controller, implemented with the jAirspyHF library + * (libairspyhf). + * + * The tuner is discovered with LibUsb at a USB bus and port, like all other USB tuners, however libairspyhf owns the + * USB device and its transfers. The library also corrects the IQ imbalance of the zero IF sample rates and shifts the + * signal so that the tuned frequency is at the center of the spectrum, without a DC spike, for every sample rate. */ -public class AirspyHfTunerController extends USBTunerController +public class AirspyHfTunerController extends TunerController { private static final Logger mLog = LoggerFactory.getLogger(AirspyHfTunerController.class); - private static final AirspyHfSampleRate DEFAULT_SAMPLE_RATE = new AirspyHfSampleRate(0, 768_000, false); - private static final long IF_SHIFT_LIF = 0; - private static final long IF_SHIFT_ZIF = 5_000; public static final long MINIMUM_TUNABLE_FREQUENCY_HZ = 500_000; public static final long MAXIMUM_TUNABLE_FREQUENCY_HZ = 260_000_000; - private static final byte REQUEST_TYPE_IN = LibUsb.ENDPOINT_IN | LibUsb.REQUEST_TYPE_VENDOR | LibUsb.RECIPIENT_DEVICE; - private static final byte REQUEST_TYPE_OUT = LibUsb.ENDPOINT_OUT | LibUsb.REQUEST_TYPE_VENDOR | LibUsb.RECIPIENT_DEVICE; - private static final int BUFFER_SAMPLE_COUNT = 1024; - private static final int BUFFER_BYTE_SIZE = BUFFER_SAMPLE_COUNT * 2 * 2; //2x 16-bit samples (4-bytes total) per complex. - private AirspyHfNativeBufferFactory mNativeBufferFactory = new AirspyHfNativeBufferFactory(); - private List mAvailableSampleRates; + private static final long FREQUENCY_DEFAULT = 10_000_000; + private static final AirspyHfSampleRate DEFAULT_SAMPLE_RATE = new AirspyHfSampleRate(0, 912_000, false); + //Sample count of each buffer from libairspyhf, used until the device is opened. + private static final int DEFAULT_BUFFER_SAMPLE_COUNT = 4096; + private final int mBus; + private final String mPortAddress; + private AirspyHfDevice mDevice; + private BoardId mBoardId = BoardId.UNKNOWN; + private String mSerialNumber = "UNKNOWN"; + private String mFirmwareVersion; + private List mAvailableSampleRates = Collections.emptyList(); private AirspyHfSampleRate mCurrentSampleRate = DEFAULT_SAMPLE_RATE; - private BoardId mBoardId; - private String mSerialNumber; private Attenuation mAttenuation = Attenuation.A0; private boolean mAgcEnabled; private boolean mLnaEnabled; - private long mTunedFrequency; - private static final int CALIBRATION_MAGIC = 0xA5CA71B0; - private int mCalibrationRecordMagicNumber; - private int mCalibrationRecordPPB; - private int mCalibrationRecordVctcxo; + private volatile float mSamplesPerMillisecond = DEFAULT_SAMPLE_RATE.getSampleRate() / 1000.0f; + private volatile int mBufferSampleCount = DEFAULT_BUFFER_SAMPLE_COUNT; + private boolean mStreaming = false; /** * Constructs an instance @@ -74,79 +85,504 @@ public class AirspyHfTunerController extends USBTunerController */ public AirspyHfTunerController(int bus, String portAddress, ITunerErrorListener tunerErrorListener) { - super(bus, portAddress, tunerErrorListener); + super(tunerErrorListener); + mBus = bus; + mPortAddress = portAddress; setMinimumFrequency(MINIMUM_TUNABLE_FREQUENCY_HZ); setMaximumFrequency(MAXIMUM_TUNABLE_FREQUENCY_HZ); setUsableBandwidthPercentage(.9); //90% usable after filter rolloff - setMiddleUnusableHalfBandwidth(3000); + //libairspyhf offsets the LO from the tuned frequency and removes the IQ imbalance, so the center is usable. + setMiddleUnusableHalfBandwidth(0); } @Override - public void apply(TunerConfiguration config) throws SourceException + public TunerType getTunerType() { - super.apply(config); - - if(config instanceof AirspyHfTunerConfiguration airspyConfig) - { - int sampleRate = airspyConfig.getSampleRate(); - - boolean found = false; - //The tuner supports several sample rates, but in testing the only rate that seems to function - //correctly is the 912kHz rate, which should be index 0. -// for(AirspyHfSampleRate rate: getAvailableSampleRates()) -// { -// if(rate.getSampleRate() == sampleRate) -// { -// setSampleRate(rate); -// found = true; -// break; -// } -// } - - if(!found) - { - setSampleRate(getAvailableSampleRates().get(0)); - } - - try - { - setAgc(airspyConfig.isAgc()); - } - catch(IOException ioe) - { - mLog.error("Error setting AGC enabled [" + airspyConfig.isAgc() + "]"); - } - - try - { - setLna(airspyConfig.isLna()); - } - catch(IOException ioe) - { - mLog.error("Error setting LNA enabled [" + airspyConfig.isLna() + "]"); - } - - try - { - setAttenuation(airspyConfig.getAttenuation()); - } - catch(IOException ioe) - { - mLog.error("Error setting attenuation [" + airspyConfig.getAttenuation() + "]"); - } - } + return TunerType.AIRSPY_HF_PLUS; } @Override public int getBufferSampleCount() { - return 1024; + return mBufferSampleCount; + } + + @Override + public void start() throws SourceException + { + long serialNumber = readSerialNumber(); + + getLock().lock(); + + try + { + mDevice = AirspyHf.open(serialNumber); + + BoardInfo boardInfo = mDevice.boardInfo(); + mBoardId = BoardId.fromValue((int)boardInfo.partId()); + mSerialNumber = String.format("%016X", boardInfo.serialNumber()); + mFirmwareVersion = mDevice.firmwareVersion(); + mBufferSampleCount = mDevice.outputSize(); + + List sampleRates = new ArrayList<>(); + long[] rates = mDevice.sampleRates(); + + for(int x = 0; x < rates.length; x++) + { + mDevice.setSampleRate(rates[x]); + sampleRates.add(new AirspyHfSampleRate(x, (int)rates[x], mDevice.isLowIf())); + } + + if(sampleRates.isEmpty()) + { + sampleRates.add(DEFAULT_SAMPLE_RATE); + } + + mAvailableSampleRates = Collections.unmodifiableList(sampleRates); + + //Force the sample rate to be applied, since the rate discovery leaves the device at the last rate. + mCurrentSampleRate = null; + setFrequency(FREQUENCY_DEFAULT); + setSampleRate(mAvailableSampleRates.get(0)); + } + catch(AirspyHfException | SourceException | RuntimeException | LinkageError e) + { + closeDevice(mDevice); + mDevice = null; + throw new SourceException("Can't start Airspy HF tuner - " + e.getMessage(), e); + } + finally + { + getLock().unlock(); + } + } + + @Override + public void stop() + { + AirspyHfDevice device; + + getLock().lock(); + + try + { + device = mDevice; + mDevice = null; + mStreaming = false; + } + finally + { + getLock().unlock(); + } + + mNativeBufferBroadcaster.clear(); + closeDevice(device); + } + + /** + * Closes the device, which also stops streaming. + * @param device to close, or null + */ + private static void closeDevice(AirspyHfDevice device) + { + if(device != null) + { + try + { + device.close(); + } + catch(AirspyHfException | RuntimeException e) + { + mLog.error("Error closing Airspy HF device", e); + } + } + } + + /** + * Reads the serial number of the device at this controller's USB bus and port, since libairspyhf opens a specific + * device by serial number. The device is closed again before returning so that libairspyhf can open it. + * @return serial number + * @throws SourceException if the device isn't found or can't be accessed. + */ + private long readSerialNumber() 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 readSerialNumber(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 serial number from the device's USB serial string, formatted as: AIRSPYHF SN:0123456789ABCDEF + * @param device to read + * @return serial number + * @throws SourceException if the device can't be opened or the serial string isn't valid. + */ + private static long readSerialNumber(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)); + } + + DeviceHandle handle = new DeviceHandle(); + status = LibUsb.open(device, handle); + + if(status == LibUsb.ERROR_ACCESS) + { + mLog.error("Access to USB tuner denied - (windows) reinstall zadig driver or (linux) blacklist driver and/or check udev rules"); + throw new SourceException("access denied - if using linux, blacklist the default driver and/or install udev rules"); + } + else if(status != LibUsb.SUCCESS) + { + mLog.error("Can't open USB tuner - check driver or Linux udev rules"); + throw new SourceException("Can't open USB tuner - reinstall driver? - " + LibUsb.errorName(status)); + } + + String serial; + + try + { + serial = LibUsb.getStringDescriptor(handle, descriptor.iSerialNumber()); + } + finally + { + LibUsb.close(handle); + } + + try + { + return Long.parseUnsignedLong(serial.substring(serial.lastIndexOf(':') + 1).trim(), 16); + } + catch(NullPointerException | NumberFormatException e) + { + throw new SourceException("Can't read Airspy HF serial number from USB serial string [" + serial + "]", e); + } + } + + /** + * Operation that requires the device. + */ + private interface DeviceOperation + { + void execute(AirspyHfDevice device) throws AirspyHfException; + } + + /** + * Executes the operation under the controller lock so that the device can't be closed while it is in use. The + * operation is ignored when the device isn't open, which allows for calls that arrive during tuner shutdown. + * @param operation to execute + * @throws SourceException if the operation fails + */ + private void execute(DeviceOperation operation) throws SourceException + { + getLock().lock(); + + try + { + if(mDevice != null) + { + operation.execute(mDevice); + } + } + catch(AirspyHfException e) + { + throw new SourceException(e.getMessage(), e); + } + finally + { + getLock().unlock(); + } + } + + /** + * Receives samples on the libairspyhf consumer thread and dispatches them to the buffer listeners. Exceptions + * are contained here since an exception reaching the library would stop 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 + { + mBufferSampleCount = buffer.sampleCount(); + broadcast(new FloatNativeBuffer(buffer.copyFloats(), System.currentTimeMillis(), mSamplesPerMillisecond)); + } + catch(RuntimeException e) + { + mLog.error("Error dispatching Airspy HF sample buffer", e); + } + + return true; + } + + /** + * Starts the sample stream if it's not already started. + */ + private void startStreaming() throws SourceException + { + if(!mStreaming) + { + execute(device -> device.startRx(this::receive)); + mStreaming = mDevice != null; + } + } + + /** + * Stops the sample stream if it's started. + */ + private void stopStreaming() throws SourceException + { + if(mStreaming) + { + mStreaming = false; + execute(AirspyHfDevice::stopRx); + } + } + + /** + * 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); + startStreaming(); + } + } + catch(SourceException se) + { + mLog.error("Error starting streaming on Airspy HF 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()) + { + stopStreaming(); + } + } + catch(SourceException se) + { + mLog.error("Error stopping streaming on Airspy HF tuner", se); + } + finally + { + getLock().unlock(); + } + } + + @Override + public void apply(TunerConfiguration config) throws SourceException + { + //Invoke super for frequency, frequency correction and autoPPM + super.apply(config); + + if(config instanceof AirspyHfTunerConfiguration airspyConfig) + { + AirspyHfSampleRate sampleRate = getSampleRate(airspyConfig.getSampleRate()); + setSampleRate(sampleRate != null ? sampleRate : getAvailableSampleRates().get(0)); + + try + { + setLna(airspyConfig.isLna()); + setAttenuation(airspyConfig.getAttenuation()); + setAgc(airspyConfig.isAgc()); + } + catch(SourceException se) + { + throw new SourceException("Couldn't apply settings from Airspy HF config", se); + } + } } @Override public long getTunedFrequency() throws SourceException { - return mTunedFrequency; + return mFrequencyController.getTunedFrequency(); + } + + /** + * Sets the tuned frequency. libairspyhf applies the device's frequency calibration and the IF shift. + * @param frequency to set + * @throws SourceException if there is an error + */ + @Override + public void setTunedFrequency(long frequency) throws SourceException + { + if(MINIMUM_TUNABLE_FREQUENCY_HZ <= frequency && frequency <= MAXIMUM_TUNABLE_FREQUENCY_HZ) + { + execute(device -> device.setFrequency(frequency)); + } + else + { + throw new SourceException("Frequency [" + frequency + "] outside of tunable range " + + MINIMUM_TUNABLE_FREQUENCY_HZ + "-" + MAXIMUM_TUNABLE_FREQUENCY_HZ); + } + } + + @Override + public double getCurrentSampleRate() + { + return mCurrentSampleRate != null ? mCurrentSampleRate.getSampleRate() : 0; + } + + /** + * Access the current sample rate object. + * @return current sample rate. + */ + public AirspyHfSampleRate getCurrentAirspySampleRate() + { + return mCurrentSampleRate; + } + + /** + * Sets the sample rate for the tuner. The sample stream is stopped for the change so that the library's consumer + * thread doesn't process samples while the rate, filter gain and IF mode are changing. + * @param sampleRate to apply. + * @throws SourceException if the rate can't be set + */ + public void setSampleRate(AirspyHfSampleRate sampleRate) throws SourceException + { + if(sampleRate == null || sampleRate.equals(mCurrentSampleRate)) + { + return; + } + + if(!isSupportedSampleRate(sampleRate.getSampleRate())) + { + throw new SourceException("Unsupported Airspy HF sample rate: " + sampleRate); + } + + getLock().lock(); + + try + { + boolean streaming = mStreaming; + stopStreaming(); + + try + { + execute(device -> device.setSampleRate(sampleRate.getSampleRate())); + mSamplesPerMillisecond = sampleRate.getSampleRate() / 1000.0f; + mCurrentSampleRate = sampleRate; + mFrequencyController.setSampleRate(sampleRate.getSampleRate()); + } + finally + { + if(streaming) + { + startStreaming(); + } + } + } + finally + { + getLock().unlock(); + } + } + + /** + * Indicates if the sample rate is supported. + * @param sampleRate to check + * @return true if the rate matches one of the available sample rates. + */ + public boolean isSupportedSampleRate(int sampleRate) + { + return getSampleRate(sampleRate) != null; + } + + /** + * Sample rate object that matches the rate in hertz. + * @param sampleRate in hertz + * @return matching sample rate or null + */ + private AirspyHfSampleRate getSampleRate(int sampleRate) + { + for(AirspyHfSampleRate availableRate: mAvailableSampleRates) + { + if(availableRate.getSampleRate() == sampleRate) + { + return availableRate; + } + } + + return null; + } + + /** + * Available/supported sample rates. + * @return available sample rates + * @throws IllegalStateException if the device has not yet been started + */ + public List getAvailableSampleRates() + { + if(mAvailableSampleRates.isEmpty()) + { + throw new IllegalStateException("Device must be started before accessing available sample rates"); + } + + return mAvailableSampleRates; } /** @@ -168,413 +604,12 @@ public class AirspyHfTunerController extends USBTunerController } /** - * Sets the tuned frequency. - * @param frequency to set - * @throws SourceException if there is an error + * Firmware version string, available once the tuner is started. + * @return firmware version or null */ - @Override - public void setTunedFrequency(long frequency) throws SourceException + public String getFirmwareVersion() { - double if_shift = mCurrentSampleRate.isLowIf() ? IF_SHIFT_ZIF : IF_SHIFT_LIF; - double adjusted_freq_hz = frequency * (1.0e9 + mCalibrationRecordPPB) * 1.0e-9; - int freq_khz = (int)((adjusted_freq_hz + if_shift) * 1e-3); - ByteBuffer buffer = ByteBuffer.allocateDirect(4); - buffer.putInt(freq_khz).flip(); - - try - { - writeData(Request.SET_FREQUENCY, buffer); - mTunedFrequency = frequency; - } - catch(IOException ioe) - { - mLog.info("Error setting tuned frequency to " + frequency, ioe); - throw new SourceException("Error setting frequency", ioe); - } - - try - { - byte[] deltaBytes = read(Request.GET_FREQUENCY_DELTA, 0, 4); - int delta = ByteUtil.toInteger(deltaBytes, 0); - - if(delta != 0) - { - mLog.warn("Frequency delta after setting tuned frequency [" + frequency + "] is: " + delta); - } - } - catch(IOException ioe) - { - mLog.error("Error reading frequency delta from Airspy HF+ tuner", ioe); - } - } - - @Override - public double getCurrentSampleRate() throws SourceException - { - loadSampleRates(); - return mCurrentSampleRate.getSampleRate(); - } - - /** - * Access the current sample rate object. - * @return current sample rate. - */ - public AirspyHfSampleRate getCurrentAirspySampleRate() - { - return mCurrentSampleRate; - } - - /** - * Sets the sample rate for the tuner. - * @param sampleRate to apply. - */ - public void setSampleRate(AirspyHfSampleRate sampleRate) throws SourceException - { - if(sampleRate != null) - { - if(isSupportedSampleRate(sampleRate.getSampleRate())) - { - getNativeBufferFactory().setSamplesPerMillisecond((float)sampleRate.getSampleRate() / 1000.0f); - - LibUsb.clearHalt(getDeviceHandle(), USB_BULK_TRANSFER_ENDPOINT); - int status = writeIndex(Request.SET_SAMPLE_RATE, sampleRate.getIndex()); - - if(status != 0) - { - throw new SourceException("Unable to set Airspy HF sample rate to: " + sampleRate); - } - - mFrequencyController.setSampleRate(sampleRate.getSampleRate()); - getNativeBufferFactory().setSamplesPerMillisecond(sampleRate.getSampleRate() / 1000.0f); - mCurrentSampleRate = sampleRate; - - //Set the frequency again to adjust for any switch between ZIF & LIF - setTunedFrequency(mTunedFrequency); - } - } - } - - /** - * Indicates if the sample rate is supported. - * @param sampleRate to check - * @return true if the rate matches one of the available sample rates. - */ - public boolean isSupportedSampleRate(int sampleRate) - { - if(mAvailableSampleRates != null && !mAvailableSampleRates.isEmpty()) - { - for(AirspyHfSampleRate availableRate: mAvailableSampleRates) - { - if(availableRate.getSampleRate() == sampleRate) - { - return true; - } - } - } - - return false; - } - - /** - * Available/supported sample rates. - * @return available sample rates - * @throws IllegalStateException if the device has not yet been started - */ - public List getAvailableSampleRates() - { - if(mAvailableSampleRates == null || mAvailableSampleRates.isEmpty()) - { - throw new IllegalStateException("Device must be started before accessing available sample rates"); - } - - return mAvailableSampleRates; - } - - /** - * Tuner type. - * @return for this tuner - */ - @Override - public TunerType getTunerType() - { - return TunerType.AIRSPY_HF_PLUS; - } - - /** - * Native buffer factory for creating native buffers from USB transfer buffers - * @return factory - */ - @Override - protected INativeBufferFactory getNativeBufferFactory() - { - return mNativeBufferFactory; - } - - /** - * Byte size of USB transfer buffers - */ - @Override - protected int getTransferBufferSize() - { - return BUFFER_BYTE_SIZE; - } - - /** - * Implements additional device-specific start operations. - * @throws SourceException if there is an issue - */ - @Override - protected void deviceStart() throws SourceException - { - int status = LibUsb.setInterfaceAltSetting(getDeviceHandle(), 0, 1); - - if(status != LibUsb.SUCCESS) - { - throw new SourceException("Can't set Airspy HF interface 0 alternate setting 1 - " + LibUsb.errorName(status)); - } - - loadBoardIdAndSerialNumber(); - loadSampleRates(); - - try - { - loadDeviceConfig(); - } - catch(IOException ioe) - { - mLog.info("Error getting device configuration and calibration info", ioe); - } - } - - /** - * Implements additional device-specific stop operations. - * @throws SourceException if there is an issue - */ - @Override - protected void deviceStop() - { - try - { - setReceiverMode(false); - } - catch(Exception e) - { - mLog.error("Error setting Airspy HF tuner receiver mode to false for device stop."); - } - } - - /** - * Preparation operations for starting sample stream. - */ - @Override - protected void prepareStreaming() - { - try - { - setReceiverMode(false); - } - catch(SourceException ioe) - { - mLog.error("Error setting Airspy HF tuner receiver mode to false to reset before we start streaming."); - setErrorMessage("Unable to set receiver mode off"); - return; - } - - LibUsb.clearHalt(getDeviceHandle(), USB_BULK_TRANSFER_ENDPOINT); - - try - { - setReceiverMode(true); - } - catch(SourceException ioe) - { - mLog.error("Error setting Airspy HF tuner receiver mode to true to start streaming."); - setErrorMessage("Unable to set receiver mode on"); - } - } - - /** - * Cleanup operations for shutting down sample stream. - */ - @Override - protected void streamingCleanup() - { - try - { - setReceiverMode(false); - } - catch(SourceException ioe) - { - mLog.error("Error setting Airspy HF tuner receiver mode to false to stop streaming."); - } - } - - /** - * LibUsb control transfer to request data from the tuner. - * @param request to submit - * @param count parameter associated with the request - * @param bufferLength is the byte size of the buffer to receive the read data. - * @return byte array containing the response value - * @throws IOException if there is an error or the request cannot be completed. - */ - private byte[] read(Request request, int count, int bufferLength) throws IOException - { - //Allocate a native/direct byte buffer outside of the JVM - ByteBuffer buffer = ByteBuffer.allocateDirect(bufferLength); - int status = LibUsb.controlTransfer(getDeviceHandle(), REQUEST_TYPE_IN, request.getRequest(), (short)0, - (short)count, buffer, 0); - - if(status < 0) - { - throw new IOException("Unable to complete read request [" + request.name() + "] - libusb status [" + status + - "] - " + LibUsb.errorName(status)); - } - - byte[] result = new byte[bufferLength]; - buffer.get(result); - return result; - } - - /** - * LibUsb control transfer to write data to the tuner with a value of zero and an index of zero. - * @param request to submit - * @param dataBuffer to write - * @return direct byte buffer containing the value(s) to write in little-endian format - * @throws IOException if there is an error or the request cannot be completed. - */ - private void writeData(Request request, ByteBuffer dataBuffer) throws IOException - { - if(!dataBuffer.isDirect()) - { - throw new IllegalArgumentException("Cannot write - must use a direct/native byte buffer"); - } - - int status = LibUsb.controlTransfer(getDeviceHandle(), REQUEST_TYPE_OUT, request.getRequest(), (short)0, (short)0, - dataBuffer, 0); - - if(status < 0) - { - throw new IOException("Unable to complete write request [" + request.name() + "] - libusb status [" + - status + "] - " + LibUsb.errorName(status)); - } - } - - /** - * Writes the request with the specified value using an index of zero. - * @param request type - * @param value value to write - * @return status code of the write operation. - */ - private int writeValue(Request request, short value) - { - return LibUsb.controlTransfer(getDeviceHandle(), REQUEST_TYPE_OUT, request.getRequest(), value, (short)0, - ByteBuffer.allocateDirect(0), 0); - } - - /** - * Writes the request with the specified index with a value of zero. - * @param request type - * @param index to write - * @return status code of the write operation. - */ - private int writeIndex(Request request, short index) - { - return LibUsb.controlTransfer(getDeviceHandle(), REQUEST_TYPE_OUT, request.getRequest(), (short)0, index, - ByteBuffer.allocateDirect(0), 0); - } - - /** - * Sets the receiver mode to start/stop the sample stream - * @param running true to start and false to stop. - * @throws SourceException if there is an error - */ - private void setReceiverMode(boolean running) throws SourceException - { - int status = writeValue(Request.RECEIVER_MODE, running ? (short)1 : (short)0); - - if(status != LibUsb.SUCCESS) - { - throw new SourceException("Unable to set receiver mode started to " + running + " - status [" + status + - "] " + LibUsb.errorName(status)); - } - } - - /** - * Reads the board ID and serial number from the device. - * @throws IOException if there is an issue - */ - private void loadBoardIdAndSerialNumber() - { - try - { - byte[] bytes = read(Request.GET_SERIAL_NUMBER_BOARD_ID, 0, 20); - - int boardId = ByteUtil.toInteger(bytes, 0); - mBoardId = BoardId.fromValue(boardId); - - int serial1 = ByteUtil.toInteger(bytes, 4); - int serial2 = ByteUtil.toInteger(bytes, 8); - int serial3 = ByteUtil.toInteger(bytes, 12); - int serial4 = ByteUtil.toInteger(bytes, 16); - - StringBuilder sb = new StringBuilder(); - sb.append(Integer.toHexString(serial1)); - sb.append(Integer.toHexString(serial2)); - sb.append(Integer.toHexString(serial3)); - sb.append(Integer.toHexString(serial4)); - mSerialNumber = sb.toString().toUpperCase(); - } - catch(IOException ioe) - { - mLog.error("Error reading board ID and serial number from device", ioe); - mBoardId = BoardId.UNKNOWN; - mSerialNumber = "UNKNOWN"; - } - } - - /** - * Loads the sample rates and LIF/ZIF modes from the tuner's firmware or uses the default sample rate of 768 kHz. - */ - private void loadSampleRates() - { - if(mAvailableSampleRates == null) - { - mAvailableSampleRates = new ArrayList<>(); - - try - { - //Read the first 4 bytes to get the count of sample rates. - byte[] bytes = read(Request.GET_SAMPLE_RATES, 0, 4); - - int count = ByteUtil.toInteger(bytes, 0); - - if(count > 0) - { - //Read the count (again) plus the number of 4-byte integer values. - bytes = read(Request.GET_SAMPLE_RATES, count, count * 4); - - //Read the architectures array. - byte[] architectures = read(Request.GET_SAMPLE_RATE_ARCHITECTURES, count, count); - - for(int x = 0; x < count; x++) - { - int sampleRate = ByteUtil.toInteger(bytes, (x * 4)); - boolean lowIf = architectures[x] == (byte)0x1; - mAvailableSampleRates.add(new AirspyHfSampleRate(x, sampleRate, lowIf)); - } - } - } - catch(Exception e) - { - mLog.error("Error reading sample rates from Airspy HF tuner", e); - } - - //If we can't read sample rates from tuner, use the default sample rate. - if(mAvailableSampleRates.isEmpty()) - { - mAvailableSampleRates.add(DEFAULT_SAMPLE_RATE); - } - } + return mFirmwareVersion; } /** @@ -587,19 +622,13 @@ public class AirspyHfTunerController extends USBTunerController } /** - * Sets the Automatic Gain Control (AGC) + * Sets the Automatic Gain Control (AGC). The attenuation setting applies when the AGC is off. * @param enabled true to turn on AGC or false to turn off AGC - * @throws IOException if there is an error + * @throws SourceException if there is an error */ - public void setAgc(boolean enabled) throws IOException + public void setAgc(boolean enabled) throws SourceException { - int status = writeValue(Request.SET_HF_AGC, enabled ? (short)1 : (short)0); - - if(status != 0) - { - throw new IOException("Unable to set AGC enabled [" + enabled + "] - status code: " + status); - } - + execute(device -> device.setHfAgc(enabled)); mAgcEnabled = enabled; } @@ -615,17 +644,11 @@ public class AirspyHfTunerController extends USBTunerController /** * Sets the Low Noise Amplifier (LNA) * @param enabled true to turn on or false to turn off - * @throws IOException if there is an error + * @throws SourceException if there is an error */ - public void setLna(boolean enabled) throws IOException + public void setLna(boolean enabled) throws SourceException { - int status = writeValue(Request.SET_HF_LNA, enabled ? (short)1 : (short)0); - - if(status != 0) - { - throw new IOException("Unable to set LNA enabled [" + enabled + "] - status code: " + status); - } - + execute(device -> device.setHfLna(enabled)); mLnaEnabled = enabled; } @@ -638,31 +661,13 @@ public class AirspyHfTunerController extends USBTunerController } /** - * Sets the attenuation value. + * Sets the attenuation value, which applies when the AGC is off. * @param attenuation value to set: 0 - 8. Increases attenuation (ie decrease gain) by 6dB with each step. - * @throws IOException if there is an error + * @throws SourceException if there is an error */ - public void setAttenuation(Attenuation attenuation) throws IOException + public void setAttenuation(Attenuation attenuation) throws SourceException { - int status = writeValue(Request.SET_HF_ATT, attenuation.getValue()); - - if(status != 0) - { - throw new IOException("Unable to set attenuation [" + attenuation + "] - status code: " + status); - } + execute(device -> device.setAttenuationStep(attenuation.getValue())); mAttenuation = attenuation; } - - /** - * Reads the device configuration from the tuner - * @return byte array with configuration contents. - */ - private void loadDeviceConfig() throws IOException - { - byte[] bytes = read(Request.CONFIG_READ, 0, 256); - IntBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer(); - mCalibrationRecordMagicNumber = buffer.get(0); - mCalibrationRecordPPB = buffer.get(1); - mCalibrationRecordVctcxo = buffer.get(2); - } } diff --git a/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/AirspyHfTunerEditor.java b/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/AirspyHfTunerEditor.java index 850215c0..2fa78e35 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/AirspyHfTunerEditor.java +++ b/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/AirspyHfTunerEditor.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2024 Dennis Sheirer + * 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 @@ -24,7 +24,6 @@ import io.github.dsheirer.source.SourceException; import io.github.dsheirer.source.tuner.manager.DiscoveredTuner; import io.github.dsheirer.source.tuner.manager.TunerManager; import io.github.dsheirer.source.tuner.ui.TunerEditor; -import java.io.IOException; import net.miginfocom.swing.MigLayout; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -147,11 +146,9 @@ public class AirspyHfTunerEditor extends TunerEditor(Attenuation.values()); + mAttenuationCombo.setToolTipText("Attenuation applies when the AGC is off"); mAttenuationCombo.setEnabled(false); mAttenuationCombo.addActionListener(e -> { if(!isLoading()) @@ -270,12 +268,12 @@ public class AirspyHfTunerEditor extends TunerEditor - * **************************************************************************** - */ - -package io.github.dsheirer.source.tuner.airspy.hf; - -/** - * Requests/Commands supported by the Airspy HF - */ -public enum Request -{ - INVALID(0), - RECEIVER_MODE(1), - SET_FREQUENCY(2), - GET_SAMPLE_RATES(3), - SET_SAMPLE_RATE(4), - CONFIG_READ(5), - CONFIG_WRITE(6), - GET_SERIAL_NUMBER_BOARD_ID(7), - SET_USER_OUTPUT(8), - GET_VERSION_STRING(9), - SET_HF_AGC(10), - SET_HF_AGC_THRESHOLD(11), - SET_HF_ATT(12), - SET_HF_LNA(13), - GET_SAMPLE_RATE_ARCHITECTURES(14), - GET_FILTER_GAIN(15), - GET_FREQUENCY_DELTA(16), - SET_VCTCXO_CALIBRATION(17); - - private int mValue; - - /** - * Constructs an instance - * @param value of the entry - */ - Request(int value) - { - mValue = value; - } - - /** - * Value associated with the entry - * @return value cast to a byte. - */ - public byte getRequest() - { - return (byte)mValue; - } -} diff --git a/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/Response.java b/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/Response.java deleted file mode 100644 index 6f741c1f..00000000 --- a/src/main/java/io/github/dsheirer/source/tuner/airspy/hf/Response.java +++ /dev/null @@ -1,33 +0,0 @@ -package io.github.dsheirer.source.tuner.airspy.hf; - -/** - * Responses to Airspy HF requests - */ -public enum Response -{ - SUCCESS(0), - ERROR(-1), - UNSUPPORTED(-2); - - private int mValue; - - Response(int value) - { - mValue = value; - } - - /** - * Lookup the enumeration entry from the value. - * @param value to lookup - * @return entry or UNSUPPORTED if there are no matches. - */ - public static Response fromValue(int value) - { - return switch(value) - { - case 0 -> SUCCESS; - case -1 -> ERROR; - default -> UNSUPPORTED; - }; - } -} diff --git a/src/main/resources/52-airspyhf.rules b/src/main/resources/52-airspyhf.rules new file mode 100755 index 00000000..d7dee457 --- /dev/null +++ b/src/main/resources/52-airspyhf.rules @@ -0,0 +1 @@ +ATTR{idVendor}=="03eb", ATTR{idProduct}=="800c", SYMLINK+="airspyhf-%k", MODE="660", GROUP="plugdev"