Compare commits

..

1 Commits

Author SHA1 Message Date
denny
66c5727145 Initial Signal Hound tuner support. 2026-11-12 07:02:07 -05:00
51 changed files with 3030 additions and 1665 deletions

View File

@@ -14,9 +14,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with:
# Full history and tags, so the build can describe the commit relative to the latest version tag
fetch-depth: 0
- name: Set up JDK 27 - name: Set up JDK 27
uses: actions/setup-java@v6 uses: actions/setup-java@v6
@@ -37,7 +34,7 @@ jobs:
- name: Create Build Info file - name: Create Build Info file
run: | run: |
echo -e "ref: $GITHUB_REF \ncommit: $GITHUB_SHA\ndescribe: $(git describe --tags --match 'v[0-9]*' --dirty)\nbuild: $(date +"%Y-%m-%dT%H:%M:%SZ")" > build/image/build_info.txt echo -e "ref: $GITHUB_REF \ncommit: $GITHUB_SHA\nbuild: $(date +"%Y-%m-%dT%H:%M:%SZ")" > build/image/build_info.txt
- name: Add build artifacts to nightly release - name: Add build artifacts to nightly release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3

View File

@@ -1,6 +1,6 @@
# Publishes a release when a version tag is pushed: v0.6.2 for a final release, # Publishes a release when a version tag is pushed. The tag has to match
# or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release. The build derives the # projectVersion in gradle.properties with a leading 'v': v0.6.2 for a final
# version from the tag (see build.gradle). # release, or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release.
name: Release name: Release
on: on:
@@ -22,6 +22,15 @@ jobs:
# Full history and tags, to find the previous release tag for the release notes # Full history and tags, to find the previous release tag for the release notes
fetch-depth: 0 fetch-depth: 0
- id: version
run: |
expected="v$(sed -n 's/^projectVersion=//p' gradle.properties | tr -d '[:space:]')"
if [ "$GITHUB_REF_NAME" != "$expected" ]; then
echo "::error::Tag $GITHUB_REF_NAME does not match projectVersion in gradle.properties ($expected)"
exit 1
fi
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# Release notes list the PRs since the previous version tag (never 'nightly'). A final release # Release notes list the PRs since the previous version tag (never 'nightly'). A final release
# starts from the previous final release, so it covers every alpha and beta in between. # starts from the previous final release, so it covers every alpha and beta in between.
- id: previous - id: previous
@@ -45,17 +54,6 @@ jobs:
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@v6 uses: gradle/actions/setup-gradle@v6
# The version comes from 'git describe', which can pick a different tag when more than one
# version tag points at the same commit. Fail rather than publish zips with the wrong version.
- id: version
run: |
version="$(./gradlew -q printVersion --console=plain | tail -n 1)"
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
echo "::error::Build version $version does not match tag $GITHUB_REF_NAME"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Build and test - name: Build and test
run: ./gradlew clean build --console=plain run: ./gradlew clean build --console=plain

View File

@@ -54,19 +54,17 @@ committed to the code base, so it's not really 'nightly' as much as it is 'curre
Releases are built and published automatically by the [Release](.github/workflows/release.yml) GitHub workflow Releases are built and published automatically by the [Release](.github/workflows/release.yml) GitHub workflow
whenever a version tag is pushed to the repository. whenever a version tag is pushed to the repository.
The version is taken from the tag, so there is no version number to update in the code. Create and push a tag 1. Update `projectVersion` in `gradle.properties` to the new version, following the naming convention described in
with a leading `v`, following the naming convention described in `gradle.properties` (e.g. `v0.6.2-alpha-1`, that file (e.g. `0.6.2-alpha-1`, `0.6.2-beta-1` or `0.6.2`).
`v0.6.2-beta-1` or `v0.6.2`): 2. Commit and push the change to `master`.
3. Create and push a tag that matches the version with a leading `v`:
``` ```
git tag v0.6.2-beta-1 git tag v0.6.2-beta-1
git push origin v0.6.2-beta-1 git push origin v0.6.2-beta-1
``` ```
Builds made from an untagged commit get a version like `0.6.2-beta-1-14-g4626d567` (14 commits after the
`v0.6.2-beta-1` tag, at commit `4626d567`), with `-dirty` appended when there are uncommitted changes.
The workflow then: The workflow then:
* Verifies that the version the build derives from git matches the tag and fails if it does not. * Verifies that the tag matches `projectVersion` in `gradle.properties` and fails if it does not.
* Builds and tests the code, then creates the release zips for Linux, Mac and Windows (x86-64 and aarch64). * Builds and tests the code, then creates the release zips for Linux, Mac and Windows (x86-64 and aarch64).
* Creates a GitHub release titled `Version <version>` with auto-generated release notes (a "What's Changed" list of * Creates a GitHub release titled `Version <version>` with auto-generated release notes (a "What's Changed" list of
the PRs merged since the previous version tag), and attaches the six zip the PRs merged since the previous version tag), and attaches the six zip

View File

@@ -52,44 +52,8 @@ plugins {
id 'org.beryx.runtime' version '2.0.1' id 'org.beryx.runtime' version '2.0.1'
} }
/** //Version is defined in gradle.properties so that we can CLI override it for nightly builds.
* Version is derived from the most recent 'v<version>' git tag (e.g. v1.0.0-beta-1 -> 1.0.0-beta-1). Commits after version = project.findProperty('projectVersion')
* the tag produce a version like 1.0.0-beta-1-14-g4626d567 and uncommitted changes append '-dirty'. The moving
* 'nightly' tag is ignored. Override with -PprojectVersion=<version> (the nightly build uses 'nightly' so that the
* nightly release asset names stay the same). Falls back to 'unknown' when built outside a git checkout.
*/
String gitDescribe = describeGitVersion()
version = project.findProperty('projectVersion') ?: (gitDescribe ? gitDescribe.substring(1) : 'unknown')
String describeGitVersion()
{
try
{
def describe = providers.exec {
commandLine 'git', 'describe', '--tags', '--match', 'v[0-9]*', '--dirty'
ignoreExitValue = true
}
if(describe.result.get().exitValue == 0)
{
return describe.standardOutput.asText.get().trim()
}
}
catch(Exception e)
{
logger.warn("Unable to determine version from git tags: " + e.message)
}
return null
}
tasks.register('printVersion') {
description = 'Prints the project version'
def projectVersion = version
doLast {
println projectVersion
}
}
//Java 27 is required for this version of the Project Panama preview/incubator feature //Java 27 is required for this version of the Project Panama preview/incubator feature
java { java {
@@ -168,6 +132,7 @@ dependencies {
implementation 'io.github.dsheirer:jlimesdr:25.1.0.2' //LimeSDR tuners - GitHub release, see settings.gradle implementation 'io.github.dsheirer:jlimesdr:25.1.0.2' //LimeSDR tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jrtl:1.4.0.3' //RTL-SDR tuners - GitHub release, see settings.gradle implementation 'io.github.dsheirer:jrtl:1.4.0.3' //RTL-SDR tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle implementation 'io.github.dsheirer:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jsignalhound:2026.10.05' //Signal Hound tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:radio-reference-api:18.0.0' implementation 'io.github.dsheirer:radio-reference-api:18.0.0'
implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib
implementation 'org.apache.commons:commons-lang3:3.21.0' implementation 'org.apache.commons:commons-lang3:3.21.0'
@@ -238,7 +203,6 @@ jar {
attributes ( attributes (
'Implementation-Title' : 'sdrtrunk project', 'Implementation-Title' : 'sdrtrunk project',
'Implementation-Version' : archiveVersion, 'Implementation-Version' : archiveVersion,
'Git-Describe' : gitDescribe ?: 'unknown',
'Created-By' : "Gradle ${gradle.gradleVersion}", 'Created-By' : "Gradle ${gradle.gradleVersion}",
'Build-Timestamp' : new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()), 'Build-Timestamp' : new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()),
'Build-JDK' : "${System.properties['java.version']} (${System.properties['java.vendor']} ${System.properties['java.vm.version']}", 'Build-JDK' : "${System.properties['java.version']} (${System.properties['java.vendor']} ${System.properties['java.vm.version']}",

View File

@@ -17,16 +17,15 @@
# ***************************************************************************** # *****************************************************************************
# #
# The project version is no longer defined here. It is derived from the most recent 'v<version>' git tag (see # Version naming conventions:
# build.gradle) and can be overridden with -PprojectVersion=<version>. # Alpha: maj.min.patch-alpha-build
# Beta: maj.min.patch-beta-build
# Release: maj.min.patch
# #
# Version tag naming conventions: # Example: 0.6.0-beta-5
# Alpha: vmaj.min.patch-alpha-build
# Beta: vmaj.min.patch-beta-build
# Release: vmaj.min.patch
# #
# Example: v0.6.0-beta-5 # Note: this is so that the file names for the resultant release build products match the GitLab release asset
#
# Note: this is so that the file names for the resultant release build products match the GitHub release asset
# tag name, specifically the dashes inserted before and after the alpha/beta labels. # tag name, specifically the dashes inserted before and after the alpha/beta labels.
# See: https://github.com/DSheirer/sdrtrunk/issues/1651 # See: https://github.com/DSheirer/sdrtrunk/issues/1651
projectVersion=1.0.0-beta-1

View File

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

View File

@@ -68,29 +68,6 @@ public class Golay24
*/ */
public static int checkAndCorrect(CorrectedBinaryMessage message, int startIndex) public static int checkAndCorrect(CorrectedBinaryMessage message, int startIndex)
{ {
//The correction below indexes the word from bit 0 and takes the parity over the whole message, so it is only
//correct for a message that is exactly the 24-bit word. Decode any other word in a 24-bit copy.
if(startIndex != 0 || message.size() != 24)
{
CorrectedBinaryMessage word = new CorrectedBinaryMessage(24);
int original = message.getInt(startIndex, startIndex + 23);
word.load(0, 24, original);
int result = checkAndCorrectWord(word);
int corrected = word.getInt(0, 23);
message.load(startIndex, 24, corrected);
message.incrementCorrectedBitCount(Integer.bitCount(original ^ corrected));
return result;
}
return checkAndCorrectWord(message);
}
/**
* Error detection and correction of a message that is exactly one 24-bit word.
*/
private static int checkAndCorrectWord(CorrectedBinaryMessage message)
{
int startIndex = 0;
boolean parityError = message.cardinality() % 2 != 0; boolean parityError = message.cardinality() % 2 != 0;
int syndrome = getSyndrome(message, startIndex); int syndrome = getSyndrome(message, startIndex);

View File

@@ -1,6 +1,6 @@
/* /*
* ***************************************************************************** * *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer * Copyright (C) 2014-2025 Dennis Sheirer
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@@ -60,7 +60,6 @@ import io.github.dsheirer.source.tuner.ui.TunerSpectralDisplayManager;
import io.github.dsheirer.spectrum.DisableSpectrumWaterfallMenuItem; import io.github.dsheirer.spectrum.DisableSpectrumWaterfallMenuItem;
import io.github.dsheirer.spectrum.ShowTunerMenuItem; import io.github.dsheirer.spectrum.ShowTunerMenuItem;
import io.github.dsheirer.spectrum.SpectralDisplayPanel; import io.github.dsheirer.spectrum.SpectralDisplayPanel;
import io.github.dsheirer.util.DuplicateLibraryDetector;
import io.github.dsheirer.util.ThreadPool; import io.github.dsheirer.util.ThreadPool;
import io.github.dsheirer.util.TimeStamp; import io.github.dsheirer.util.TimeStamp;
import io.github.dsheirer.vector.calibrate.CalibrationManager; import io.github.dsheirer.vector.calibrate.CalibrationManager;
@@ -82,7 +81,6 @@ import java.nio.file.FileSystems;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.prefs.Preferences; import java.util.prefs.Preferences;
import javafx.application.Platform; import javafx.application.Platform;
@@ -155,17 +153,6 @@ public class SDRTrunk implements Listener<TunerEvent>
mApplicationLog = new ApplicationLog(mUserPreferences); mApplicationLog = new ApplicationLog(mUserPreferences);
mApplicationLog.start(); mApplicationLog.start();
Map<String,List<String>> duplicateLibraries = DuplicateLibraryDetector.findDuplicates();
if(!duplicateLibraries.isEmpty())
{
mLog.warn("More than one version of these libraries is in the library folder [" +
DuplicateLibraryDetector.getFolder(duplicateLibraries) + "]. This happens when a new version of " +
"sdrtrunk is unzipped over an older one, and it can load the older library code. Delete the " +
"sdrtrunk folder and unzip the release into an empty folder.\n" +
DuplicateLibraryDetector.describe(duplicateLibraries));
}
mResourceMonitor = new ResourceMonitor(mUserPreferences); mResourceMonitor = new ResourceMonitor(mUserPreferences);
ThreadPool.logSettings(); ThreadPool.logSettings();
@@ -266,11 +253,6 @@ public class SDRTrunk implements Listener<TunerEvent>
{ {
updateTitle(tuner.getPreferredName()); updateTitle(tuner.getPreferredName());
} }
if(!duplicateLibraries.isEmpty())
{
showDuplicateLibrariesDialog(duplicateLibraries);
}
} }
if(calibrating && !GraphicsEnvironment.isHeadless()) if(calibrating && !GraphicsEnvironment.isHeadless())
@@ -927,28 +909,6 @@ public class SDRTrunk implements Listener<TunerEvent>
} }
/**
* Warns the user that the library folder holds more than one version of some libraries, which happens when a new
* release is unzipped over an older one. The launch scripts put every jar in the library folder on the class
* path, so the older library code can load instead of the newer code.
* @param duplicates library name to jar paths, from DuplicateLibraryDetector
*/
private void showDuplicateLibrariesDialog(Map<String,List<String>> duplicates)
{
String message = "<html><body style='width: 450px'>" +
"<p>The sdrtrunk library folder has more than one version of these libraries:</p><br>" +
"<pre>" + DuplicateLibraryDetector.describe(duplicates) + "</pre>" +
"<p>This happens when a new version of sdrtrunk is unzipped over an older one. sdrtrunk can load " +
"the older library code, which can cause crashes or errors that the newer version fixed.</p><br>" +
"<p>Close sdrtrunk, delete the sdrtrunk folder that contains this library folder and unzip the " +
"release into a new, empty folder:</p><br>" +
"<p><b>" + DuplicateLibraryDetector.getFolder(duplicates) + "</b></p><br>" +
"<p>Your playlist and settings are stored in your user folder and won't be affected.</p>" +
"</body></html>";
JOptionPane.showMessageDialog(mMainGui, message, "Duplicate Library Versions", JOptionPane.WARNING_MESSAGE);
}
/** /**
* Launch the application. * Launch the application.
*/ */

View File

@@ -1,6 +1,6 @@
/* /*
* ***************************************************************************** * *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer * Copyright (C) 2014-2024 Dennis Sheirer
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@@ -133,7 +133,6 @@ public class ApplicationLog
Attributes atts = findManifestAttributes(); Attributes atts = findManifestAttributes();
if (atts != null) { if (atts != null) {
mLog.info("SDRTrunk Version : " + atts.getValue("Implementation-Version")); mLog.info("SDRTrunk Version : " + atts.getValue("Implementation-Version"));
mLog.info("Git Describe : " + atts.getValue("Git-Describe"));
mLog.info("Gradle Version : " + atts.getValue("Created-By")); mLog.info("Gradle Version : " + atts.getValue("Created-By"));
mLog.info("Build Timestamp : " + atts.getValue("Build-Timestamp")); mLog.info("Build Timestamp : " + atts.getValue("Build-Timestamp"));
mLog.info("Build-JDK : " + atts.getValue("Build-JDK")); mLog.info("Build-JDK : " + atts.getValue("Build-JDK"));

View File

@@ -123,23 +123,25 @@ public class LRRPPacket extends Packet
} }
else else
{ {
String message = getMessage().getSubMessage(getOffset(), getOffset() + ((characterCount + 2) * 8)).toHexString();
int x = 0;
RequestedTokens requestedTokens = new RequestedTokens(); RequestedTokens requestedTokens = new RequestedTokens();
while(characterCount > 0 && offset < getMessage().size()) while(characterCount > 0 && offset < getMessage().size())
{ {
String tokenId = getTokenIdentifier(offset); String tokenId = getTokenIdentifier(offset);
TokenType tokenType = TokenType.fromRequestValue(tokenId); TokenType tokenType = TokenType.fromValue(tokenId);
if(tokenType.isRequestParameterToken()) if(tokenType.isRequestParameterToken())
{ {
Token token = TokenFactory.createToken(tokenType, getMessage(), offset, characterCount); Token token = TokenFactory.createToken(tokenId, getMessage(), offset, characterCount);
mTokens.add(token); mTokens.add(token);
characterCount -= token.getByteLength(); characterCount -= token.getByteLength();
offset += (8 * token.getByteLength()); offset += (8 * token.getByteLength());
} }
else else
{ {
//Single-byte flag naming a field that the radio should include in its location report
if(tokenType != TokenType.UNKNOWN) if(tokenType != TokenType.UNKNOWN)
{ {
requestedTokens.add(tokenType); requestedTokens.add(tokenType);

View File

@@ -26,7 +26,7 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
* LRRP Identity Token * LRRP Identity Token
* *
* Start Token: 0x22 * Start Token: 0x22
* Total Length: 2 bytes plus a 1-4 byte request identifier * Total Length: 6 bytes
*/ */
public class Identity extends Token public class Identity extends Token
{ {
@@ -34,9 +34,9 @@ public class Identity extends Token
private static final int[] BYTE_VALUE = new int[]{0, 1, 2, 3, 4, 5, 6, 7}; private static final int[] BYTE_VALUE = new int[]{0, 1, 2, 3, 4, 5, 6, 7};
/** /**
* Constructs an instance of an identity token. * Constructs an instance of a heading token.
* *
* @param message containing the identity * @param message containing the heading
* @param offset to the start of the token * @param offset to the start of the token
*/ */
public Identity(CorrectedBinaryMessage message, int offset) public Identity(CorrectedBinaryMessage message, int offset)
@@ -50,47 +50,47 @@ public class Identity extends Token
return TokenType.IDENTITY; return TokenType.IDENTITY;
} }
/** private BinaryMessage getTokenContents()
* Request identifier bytes, as transmitted. The read is clamped to the end of the message so that a corrupted
* length field cannot produce bytes that were never received.
*/
private BinaryMessage getIdentifierBytes()
{ {
int start = getOffset() + IDENTIFIER_START; return getMessage().getSubMessage(getOffset(), getOffset() + 48);
int available = Math.max(0, getMessage().size() - start);
int length = Math.min(getVariableLengthPayloadSize() * 8, available);
return getMessage().getSubMessage(start, start + length);
} }
/** /**
* Request identifier assembled big-endian from the 1-4 payload bytes. Moto.Net (LRRPPacket.cs) and node-dmr-lib * Extracts the payload contents.
* (LRRP.js) both read the identifier this way.
* *
* @return identifier, or zero when the payload length is not 1-4 bytes. * Note: assumes that the values are 1-4 bytes in big-endian format? Not sure if this is correct because the
* values that I observed only had value in the LSB and the 3 MSBs were all zeros.
*/ */
public int getID() public int getID()
{ {
int length = getVariableLengthPayloadSize(); int length = getVariableLengthPayloadSize();
int payloadOffset = 16;
if(length < 1 || length > 4)
{
return 0;
}
int value = 0; int value = 0;
for(int x = 0; x < length; x++) while(0 < length && length <= 4)
{ {
value = (value << 8) | getMessage().getInt(BYTE_VALUE, getOffset() + IDENTIFIER_START + (x * 8)); int byteValue = getMessage().getInt(BYTE_VALUE, getOffset() + payloadOffset);
byteValue = Integer.rotateLeft(byteValue, payloadOffset - 16);
value += byteValue;
payloadOffset += 8;
length--;
} }
return value; return value;
} }
@Override @Override
public String toString() public String toString()
{ {
//Hexadecimal, as transmitted (e.g. ID:FFFFEE) int id = getID();
return "ID:" + getIdentifierBytes().toHexString();
if(id != 0)
{
return "ID:" + id;
}
return "ID:" + getTokenContents().toHexString();
} }
} }

View File

@@ -1,67 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* LRRP request parameter of unknown meaning that carries a 7-bit variable length integer value.
*
* Start Tokens: 0x42 and 0x44
* Total Length: 1 byte identifier plus a variable length integer value (0-5 bytes)
*
* Observed in Motorola P25 triggered location start requests as 0x44 0x64 (100) and 0x42 0x9C 0x10 (3600), placed
* among the requested-field flags. Moto.Net emits a bare 0x42 as a "trigger on GPIO" flag, so the value is treated
* as optional and the token is rendered without a value when none follows.
*/
public class RequestParameter extends VarIntToken
{
private TokenType mTokenType;
/**
* Constructs an instance of a request parameter token.
*
* @param tokenType of the parameter (REQUEST_PARAMETER_42 or REQUEST_PARAMETER_44)
* @param message containing the token
* @param offset to the start of the token
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
*/
public RequestParameter(TokenType tokenType, CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
{
super(message, offset, remainingCharacterCount);
mTokenType = tokenType;
}
@Override
public TokenType getTokenType()
{
return mTokenType;
}
@Override
public String toString()
{
if(hasValue())
{
return getTokenType() + ":" + getValue();
}
return getTokenType().toString();
}
}

View File

@@ -63,28 +63,12 @@ public class RequestedTokens extends Token
Collections.sort(mUnrecognizedRequestedTokens); Collections.sort(mUnrecognizedRequestedTokens);
} }
/**
* Recognized requested token types, in the order that they appeared in the packet.
*/
public List<TokenType> getRequestedTokens()
{
return mRequestedTokens;
}
/**
* Unrecognized requested token identifiers (hexadecimal), sorted.
*/
public List<String> getUnrecognizedRequestedTokens()
{
return mUnrecognizedRequestedTokens;
}
/** /**
* Indicates if this object has requested tokens. * Indicates if this object has requested tokens.
*/ */
public boolean hasRequestedTokens() public boolean hasRequestedTokens()
{ {
return !mRequestedTokens.isEmpty() || !mUnrecognizedRequestedTokens.isEmpty(); return !mRequestedTokens.isEmpty();
} }
@Override @Override

View File

@@ -28,7 +28,7 @@ import java.util.TimeZone;
/** /**
* LRRP Timestamp Token * LRRP Timestamp Token
* *
* Start Token: 0x34 (response packets; in a request packet 0x34 is the Periodic Trigger token) * Start Token: 0x34
* Total Length: 6 bytes * Total Length: 6 bytes
*/ */
public class Timestamp extends Token public class Timestamp extends Token

View File

@@ -88,11 +88,6 @@ public abstract class Token
{ {
return getVariableLengthPayloadSize() + 2; //Variable Length Payload + Id + Length Field return getVariableLengthPayloadSize() + 2; //Variable Length Payload + Id + Length Field
} }
else if(getTokenType().isVarInt())
{
//Id + 7-bit variable length integer. VarIntToken overrides this to bound the read by the payload.
return VarInt.length(getMessage(), getOffset() + 8, VarInt.MAX_BYTES) + 1;
}
else else
{ {
return getTokenType().getLength() + 1; //Fixed Length Payload + Id return getTokenType().getLength() + 1; //Fixed Length Payload + Id

View File

@@ -26,32 +26,10 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
*/ */
public class TokenFactory public class TokenFactory
{ {
/**
* Creates a token using the response packet meaning of the token identifier.
*
* @param id hexadecimal token identifier
* @param message containing the token
* @param offset to the start of the token identifier
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
* @return token
*/
public static Token createToken(String id, CorrectedBinaryMessage message, int offset, int remainingCharacterCount) public static Token createToken(String id, CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
{ {
return createToken(TokenType.fromValue(id), message, offset, remainingCharacterCount); TokenType tokenType = TokenType.fromValue(id);
}
/**
* Creates a token of the specified type.
*
* @param tokenType resolved by the caller, using TokenType.fromRequestValue() for request packets
* @param message containing the token
* @param offset to the start of the token identifier
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
* @return token
*/
public static Token createToken(TokenType tokenType, CorrectedBinaryMessage message, int offset,
int remainingCharacterCount)
{
switch(tokenType) switch(tokenType)
{ {
case HEADING: case HEADING:
@@ -74,17 +52,14 @@ public class TokenFactory
return new Success(message, offset); return new Success(message, offset);
case TIMESTAMP: case TIMESTAMP:
return new Timestamp(message, offset); return new Timestamp(message, offset);
case TRIGGER_CONDITION:
return new TriggerCondition(message, offset);
case TRIGGER_DISTANCE: case TRIGGER_DISTANCE:
return new TriggerDistance(message, offset, remainingCharacterCount); return new TriggerDistance(message, offset);
case TRIGGER_INTERVAL: case TRIGGER_GPIO:
return new TriggerInterval(message, offset, remainingCharacterCount); return new TriggerGpio(message, offset);
case TRIGGER_ON_MOVE:
return new TriggerOnMove(message, offset);
case TRIGGER_PERIODIC: case TRIGGER_PERIODIC:
return new TriggerPeriodic(message, offset); return new TriggerPeriodic(message, offset);
case REQUEST_PARAMETER_42:
case REQUEST_PARAMETER_44:
return new RequestParameter(tokenType, message, offset, remainingCharacterCount);
case VERSION: case VERSION:
return new Version(message, offset); return new Version(message, offset);
case UNKNOWN_23: case UNKNOWN_23:

View File

@@ -26,37 +26,21 @@ import java.util.Map;
/** /**
* LRRP Token Types * LRRP Token Types
* *
* Fixed length tokens consist of an identifier followed by a fixed number of value bytes. Variable length tokens * Variable length tokens consist of an identifier, a length indicator, and then a sequence of bytes equal in length
* consist of an identifier, a length indicator, and then a sequence of bytes equal in length to the length indicator. * to the length indicator.
* Varint tokens consist of an identifier followed by a 7-bit variable length integer (see VarInt).
*
* A few identifiers have a different meaning in a request than in a response (0x34 is the periodic trigger flag in a
* request and the timestamp in a response). Use fromRequestValue() for request packets and fromValue() otherwise.
*
* Note: the declaration order (ordinal) controls the order that tokens are listed when a packet is rendered as text.
*/ */
public enum TokenType public enum TokenType
{ {
//Tokens common to request and response packets
IDENTITY("22", -1, "IDENTITY"), IDENTITY("22", -1, "IDENTITY"),
UNKNOWN_23("23", 1, "UNKNOWN 23"), UNKNOWN_23("23", 1, "UNKNOWN 23"),
TRIGGER_PERIODIC("31", 1, "TRIGGER PERIODIC"),
//Request parameter tokens. Integer values are 7-bit variable length integers (varints).
TRIGGER_PERIODIC("34", 0, "PERIODIC", true), //Request meaning of 0x34. See TIMESTAMP for the response meaning.
TRIGGER_INTERVAL("31", -2, "TRIGGER INTERVAL"),
TRIGGER_CONDITION("4A", 1, "TRIGGER CONDITION"),
TRIGGER_DISTANCE("78", -2, "TRIGGER DISTANCE"),
REQUEST_PARAMETER_42("42", -2, "PARAM-42"), //Meaning unknown. Observed value: 3600
REQUEST_PARAMETER_44("44", -2, "PARAM-44"), //Meaning unknown. Observed value: 100
REQUEST_61("61", 1, "REQUEST-61"),
REQUEST_73("73", 1, "REQUEST-73"),
//Response tokens and single-byte requested field flags
TIMESTAMP("34", 5, "TIMESTAMP"), TIMESTAMP("34", 5, "TIMESTAMP"),
VERSION("36", 1, "VERSION"), VERSION("36", 1, "VERSION"),
RESPONSE("37", -1, "RESPONSE"), RESPONSE("37", -1, "RESPONSE"),
SUCCESS( "38", 0, "SUCCESS"), SUCCESS( "38", 0, "SUCCESS"),
REQUEST_3A("3A", 0,"REQUEST-3A"), REQUEST_3A("3A", 0,"REQUEST-3A"),
TRIGGER_GPIO("42", 0, "TRIGGER GPIO"),
TRIGGER_DISTANCE("4A", 1, "TRIGGER DISTANCE"), //Guess - not sure
ALTITUDE_ACCURACY("50", 0,"ALTITUDE ACCURACY"), ALTITUDE_ACCURACY("50", 0,"ALTITUDE ACCURACY"),
CIRCLE_2D("51", 10, "CIRCLE 2D"), CIRCLE_2D("51", 10, "CIRCLE 2D"),
TIME("52", 0,"TIME"), TIME("52", 0,"TIME"),
@@ -64,55 +48,37 @@ public enum TokenType
CIRCLE_3D("55", 15, "CIRCLE 3D"), CIRCLE_3D("55", 15, "CIRCLE 3D"),
HEADING("56", 1, "HEADING"), HEADING("56", 1, "HEADING"),
HORIZONTAL_DIRECTION("57", 0,"HORIZONTAL DIRECTION"), HORIZONTAL_DIRECTION("57", 0,"HORIZONTAL DIRECTION"),
HORIZONTAL_SPEED("62", 0,"HORIZONTAL SPEED"), REQUEST_61("61", 1, "REQUEST-61"),
REQUEST_62("62", 0,"REQUEST-62"),
REQUEST_64("64", 0,"REQUEST-64"), REQUEST_64("64", 0,"REQUEST-64"),
POINT_2D("66", 8, "POINT 2D"), POINT_2D("66", 8, "POINT 2D"),
POINT_3D("69", 11, "POINT 3D"), POINT_3D("69", 11, "POINT 3D"),
SPEED("6C", 2, "SPEED"), SPEED("6C", 2, "SPEED"),
REQUEST_73("73", 1, "REQUEST-73"),
TRIGGER_ON_MOVE("78", 1, "TRIGGER ON MOVE"),
REQUESTED_TOKENS("000", 0, "REQUESTED TOKENS"), REQUESTED_TOKENS("000", 0, "REQUESTED TOKENS"),
UNKNOWN("0",0, "UNKNOWN"); UNKNOWN("0",0, "UNKNOWN");
private static final int VARIABLE_LENGTH = -1;
private static final int VARINT_LENGTH = -2;
private String mValue; private String mValue;
private int mLength; private int mLength;
private String mLabel; private String mLabel;
private boolean mRequestOnly;
/** /**
* Tokens that have parameters in request packet messages * Tokens that have parameters in request packet messages
*/ */
private static EnumSet<TokenType> REQUEST_PARAMETER_TOKENS = EnumSet.of(IDENTITY, TRIGGER_PERIODIC, private static EnumSet<TokenType> REQUEST_PARAMETER_TOKENS = EnumSet.of(IDENTITY, TRIGGER_DISTANCE, TRIGGER_GPIO,
TRIGGER_INTERVAL, TRIGGER_CONDITION, TRIGGER_DISTANCE, REQUEST_PARAMETER_42, REQUEST_PARAMETER_44, TRIGGER_ON_MOVE, TRIGGER_PERIODIC, REQUEST_61, REQUEST_73);
REQUEST_61, REQUEST_73);
/** /**
* Constructs an instance * Constructs an instance
* @param value of the token * @param value of the token
* @param length of the token, exclusive of the identifier, where -1 indicates a variable length token and -2 * @param length of the token, exclusive of the identifier, where -1 indicates a variable length token.
* indicates a 7-bit variable length integer (varint) value.
* @param label for pretty printing
*/ */
TokenType(String value, int length, String label) TokenType(String value, int length, String label)
{
this(value, length, label, false);
}
/**
* Constructs an instance
* @param value of the token
* @param length of the token, exclusive of the identifier, where -1 indicates a variable length token and -2
* indicates a 7-bit variable length integer (varint) value.
* @param label for pretty printing
* @param requestOnly true when this meaning of the value applies to request packets only
*/
TokenType(String value, int length, String label, boolean requestOnly)
{ {
mValue = value; mValue = value;
mLength = length; mLength = length;
mLabel = label; mLabel = label;
mRequestOnly = requestOnly;
} }
/** /**
@@ -128,23 +94,7 @@ public enum TokenType
*/ */
public boolean isVariableLength() public boolean isVariableLength()
{ {
return mLength == VARIABLE_LENGTH; return mLength == -1;
}
/**
* Indicates if this token's value is a 7-bit variable length integer (varint).
*/
public boolean isVarInt()
{
return mLength == VARINT_LENGTH;
}
/**
* Indicates if this meaning of the token value applies to request packets only.
*/
public boolean isRequestOnly()
{
return mRequestOnly;
} }
/** /**
@@ -166,26 +116,17 @@ public enum TokenType
private static final Map<String,TokenType> LOOKUP_MAP = new HashMap<>(); private static final Map<String,TokenType> LOOKUP_MAP = new HashMap<>();
private static final Map<String,TokenType> REQUEST_LOOKUP_MAP = new HashMap<>();
static static
{ {
for(TokenType tokenType: TokenType.values()) for(TokenType tokenType: TokenType.values())
{ {
if(tokenType.isRequestOnly()) LOOKUP_MAP.put(tokenType.getValue(), tokenType);
{
REQUEST_LOOKUP_MAP.put(tokenType.getValue(), tokenType);
}
else
{
LOOKUP_MAP.put(tokenType.getValue(), tokenType);
REQUEST_LOOKUP_MAP.putIfAbsent(tokenType.getValue(), tokenType);
}
} }
} }
/** /**
* Lookup a token from a 2-character string value, using the response packet meaning of the value. * Lookup a token from a 2-character string value
* @param value that represents the token * @param value that represents the token
* @return matching token or UNKNOWN. * @return matching token or UNKNOWN.
*/ */
@@ -199,21 +140,6 @@ public enum TokenType
return UNKNOWN; return UNKNOWN;
} }
/**
* Lookup a token from a 2-character string value, using the request packet meaning of the value.
* @param value that represents the token
* @return matching token or UNKNOWN.
*/
public static TokenType fromRequestValue(String value)
{
if(value != null && REQUEST_LOOKUP_MAP.containsKey(value))
{
return REQUEST_LOOKUP_MAP.get(value);
}
return UNKNOWN;
}
@Override @Override
public String toString() public String toString()
{ {

View File

@@ -1,135 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* LRRP Trigger Condition Token
*
* Start Token: 0x4A
* Total Length: 2 bytes
*
* Event that causes the radio to send a triggered location report. Condition values are from node-dmr-lib
* (src/DMRConst.js) and match captured requests: 0x4A 0x02 emergency, 0x4A 0x42 pin status change (telemetry),
* 0x4A 0x50 PTT and 0x4A 0x51 PTT emergency.
*/
public class TriggerCondition extends Token
{
private static final int[] CONDITION = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
/**
* Trigger condition values
*/
public enum Condition
{
EMERGENCY(0x02, "EMERGENCY"),
PIN_STATUS_CHANGE(0x42, "PIN STATUS CHANGE"),
PTT(0x50, "PTT"),
PTT_EMERGENCY(0x51, "PTT EMERGENCY"),
UNKNOWN(-1, "UNKNOWN");
private int mValue;
private String mLabel;
Condition(int value, String label)
{
mValue = value;
mLabel = label;
}
/**
* Numeric value of the condition
*/
public int getValue()
{
return mValue;
}
/**
* Lookup a condition from a value
* @param value to lookup
* @return matching condition or UNKNOWN
*/
public static Condition fromValue(int value)
{
for(Condition condition: Condition.values())
{
if(condition.getValue() == value)
{
return condition;
}
}
return UNKNOWN;
}
@Override
public String toString()
{
return mLabel;
}
}
/**
* Constructs an instance of a trigger condition token.
*
* @param message containing the token
* @param offset to the start of the token
*/
public TriggerCondition(CorrectedBinaryMessage message, int offset)
{
super(message, offset);
}
@Override
public TokenType getTokenType()
{
return TokenType.TRIGGER_CONDITION;
}
/**
* Raw condition value
*/
public int getConditionValue()
{
return getMessage().getInt(CONDITION, getOffset());
}
/**
* Trigger condition
*/
public Condition getCondition()
{
return Condition.fromValue(getConditionValue());
}
@Override
public String toString()
{
Condition condition = getCondition();
if(condition == Condition.UNKNOWN)
{
return "TRIGGER CONDITION:UNKNOWN[0x" + String.format("%02X", getConditionValue()) + "]";
}
return "TRIGGER CONDITION:" + condition;
}
}

View File

@@ -24,23 +24,21 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
/** /**
* LRRP Trigger Distance Token * LRRP Trigger Distance Token
* *
* Start Token: 0x78 * Total Length: 2 bytes
* Total Length: 1 byte identifier plus a 7-bit variable length integer value (1-5 bytes)
*
* Distance in meters that the radio must move before it sends a triggered location report, e.g. 0x78 0x83 0x74 = 500.
*/ */
public class TriggerDistance extends VarIntToken public class TriggerDistance extends Token
{ {
private static final int[] DISTANCE = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
/** /**
* Constructs an instance of a trigger distance token. * Constructs an instance of a heading token.
* *
* @param message containing the token * @param message containing the heading
* @param offset to the start of the token * @param offset to the start of the token
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
*/ */
public TriggerDistance(CorrectedBinaryMessage message, int offset, int remainingCharacterCount) public TriggerDistance(CorrectedBinaryMessage message, int offset)
{ {
super(message, offset, remainingCharacterCount); super(message, offset);
} }
@Override @Override
@@ -54,12 +52,12 @@ public class TriggerDistance extends VarIntToken
*/ */
public int getDistance() public int getDistance()
{ {
return getValue(); return getMessage().getInt(DISTANCE, getOffset());
} }
@Override @Override
public String toString() public String toString()
{ {
return "TRIGGER DISTANCE:" + getDistance() + " METERS"; return "TRIGGER DISTANCE:" + getDistance();
} }
} }

View File

@@ -1,6 +1,6 @@
/* /*
* ***************************************************************************** * *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer * Copyright (C) 2014-2023 Dennis Sheirer
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@@ -16,49 +16,38 @@
* along with this program. If not, see <http://www.gnu.org/licenses/> * along with this program. If not, see <http://www.gnu.org/licenses/>
* **************************************************************************** * ****************************************************************************
*/ */
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token; package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage; import io.github.dsheirer.bits.CorrectedBinaryMessage;
/** /**
* LRRP Trigger Interval Token * LRRP Trigger on GPIO Token
* *
* Start Token: 0x31 * Total Length: 1 byte
* Total Length: 1 byte identifier plus a 7-bit variable length integer value (1-5 bytes)
*
* Reporting interval in seconds for a periodic triggered location request, e.g. 0x31 0x84 0x58 = 600 seconds.
*/ */
public class TriggerInterval extends VarIntToken public class TriggerGpio extends Token
{ {
/** /**
* Constructs an instance of a trigger interval token. * Constructs an instance of a heading token.
* *
* @param message containing the token * @param message containing the heading
* @param offset to the start of the token * @param offset to the start of the token
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
*/ */
public TriggerInterval(CorrectedBinaryMessage message, int offset, int remainingCharacterCount) public TriggerGpio(CorrectedBinaryMessage message, int offset)
{ {
super(message, offset, remainingCharacterCount); super(message, offset);
} }
@Override @Override
public TokenType getTokenType() public TokenType getTokenType()
{ {
return TokenType.TRIGGER_INTERVAL; return TokenType.TRIGGER_GPIO;
}
/**
* Interval in seconds
*/
public int getSeconds()
{
return getValue();
} }
@Override @Override
public String toString() public String toString()
{ {
return "INTERVAL:" + getSeconds() + " SECS"; return "TRIGGER ON GPIO";
} }
} }

View File

@@ -0,0 +1,53 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* LRRP Trigger on Move Token
*
* Total Length: 1 byte
*/
public class TriggerOnMove extends Token
{
/**
* Constructs an instance of a heading token.
*
* @param message containing the heading
* @param offset to the start of the token
*/
public TriggerOnMove(CorrectedBinaryMessage message, int offset)
{
super(message, offset);
}
@Override
public TokenType getTokenType()
{
return TokenType.TRIGGER_ON_MOVE;
}
@Override
public String toString()
{
return "TRIGGER ON MOVE";
}
}

View File

@@ -22,20 +22,18 @@ package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage; import io.github.dsheirer.bits.CorrectedBinaryMessage;
/** /**
* LRRP Periodic Trigger Token (request packets) * LRRP Trigger Periodic Token
* *
* Start Token: 0x34 (in a response packet 0x34 is the Timestamp token) * Total Length: 2 bytes
* Total Length: 1 byte
*
* Flags a triggered location request as periodic. The reporting interval, when present, follows in a separate
* Trigger Interval (0x31) token, and a Trigger Condition (0x4A) or Trigger Distance (0x78) token can follow instead.
*/ */
public class TriggerPeriodic extends Token public class TriggerPeriodic extends Token
{ {
private static final int[] SECONDS = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
/** /**
* Constructs an instance of a periodic trigger token. * Constructs an instance of a heading token.
* *
* @param message containing the token * @param message containing the heading
* @param offset to the start of the token * @param offset to the start of the token
*/ */
public TriggerPeriodic(CorrectedBinaryMessage message, int offset) public TriggerPeriodic(CorrectedBinaryMessage message, int offset)
@@ -46,12 +44,20 @@ public class TriggerPeriodic extends Token
@Override @Override
public TokenType getTokenType() public TokenType getTokenType()
{ {
return TokenType.TRIGGER_PERIODIC; return TokenType.TRIGGER_DISTANCE;
}
/**
* Seconds for trigger
*/
public int getSeconds()
{
return getMessage().getInt(SECONDS, getOffset());
} }
@Override @Override
public String toString() public String toString()
{ {
return "PERIODIC"; return "TRIGGER EVERY:" + getSeconds() + " SECS";
} }
} }

View File

@@ -1,105 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.BinaryMessage;
/**
* LRRP variable length integer (varint) reader.
*
* LRRP encodes integer token values as one or more bytes where each byte carries seven value bits, most significant
* group first, and the high bit of a byte indicates that another byte follows. For example 0x84 0x58 decodes to
* (0x04 << 7) | 0x58 = 600, 0x9C 0x10 decodes to (0x1C << 7) | 0x10 = 3600 and a value smaller than 128 occupies a
* single byte (0x64 = 100).
*
* References:
* - Moto.Net, LRRPPacket.cs: "looks like this stuff is using VLQ ... for most of the fields"
* - node-dmr-lib, src/Protocols/LRRP.js: parseLRRPInt() and toLRRPInt()
*/
public final class VarInt
{
/**
* Maximum number of bytes consumed for a single value. Five 7-bit groups hold 35 bits, which covers any 32-bit
* value, and the cap keeps a corrupted run of continuation bits from consuming the rest of a packet.
*/
public static final int MAX_BYTES = 5;
private static final int[] BYTE_VALUE = {0, 1, 2, 3, 4, 5, 6, 7};
private static final int CONTINUATION_MASK = 0x80;
private static final int VALUE_MASK = 0x7F;
private VarInt()
{
//Static utility class
}
/**
* Number of bytes occupied by the varint that starts at the bit offset.
*
* @param message containing the value
* @param offset bit offset to the first byte of the value
* @param limit maximum number of bytes available for the value (ie bytes remaining in the packet payload)
* @return byte count from 0 to the lesser of limit and MAX_BYTES. Zero indicates that no value is present.
*/
public static int length(BinaryMessage message, int offset, int limit)
{
int maximum = Math.min(limit, MAX_BYTES);
int count = 0;
while(count < maximum)
{
int b = message.getInt(BYTE_VALUE, offset + (count * 8));
count++;
if((b & CONTINUATION_MASK) == 0)
{
break;
}
}
return count;
}
/**
* Decodes the varint that starts at the bit offset.
*
* @param message containing the value
* @param offset bit offset to the first byte of the value
* @param limit maximum number of bytes available for the value (ie bytes remaining in the packet payload)
* @return decoded value, or zero when no bytes are available.
*/
public static int value(BinaryMessage message, int offset, int limit)
{
int maximum = Math.min(limit, MAX_BYTES);
int value = 0;
for(int count = 0; count < maximum; count++)
{
int b = message.getInt(BYTE_VALUE, offset + (count * 8));
value = (value << 7) | (b & VALUE_MASK);
if((b & CONTINUATION_MASK) == 0)
{
break;
}
}
return value;
}
}

View File

@@ -1,88 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* Base class for LRRP tokens whose value is a 7-bit variable length integer (see VarInt).
*
* The value starts in the byte that follows the token identifier and occupies one or more bytes. The read is
* bounded by the number of payload bytes that remain, so a token at the end of the payload cannot consume bytes
* that belong to the next packet.
*/
public abstract class VarIntToken extends Token
{
private static final int VALUE_START = 8;
private int mRemainingCharacterCount;
/**
* Constructs an instance
*
* @param message containing the token and value
* @param offset to the start of the token identifier
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
*/
public VarIntToken(CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
{
super(message, offset);
mRemainingCharacterCount = remainingCharacterCount;
}
/**
* Maximum number of value bytes that can follow the token identifier.
*/
private int getValueLimit()
{
return Math.max(0, mRemainingCharacterCount - 1);
}
/**
* Number of bytes occupied by the value, exclusive of the token identifier.
*/
public int getValueLength()
{
return VarInt.length(getMessage(), getOffset() + VALUE_START, getValueLimit());
}
/**
* Indicates if a value follows the token identifier.
*/
public boolean hasValue()
{
return getValueLength() > 0;
}
/**
* Decoded integer value, or zero when the token has no value.
*/
public int getValue()
{
return VarInt.value(getMessage(), getOffset() + VALUE_START, getValueLimit());
}
/**
* Total token length: identifier plus the variable length value.
*/
@Override
public int getByteLength()
{
return 1 + getValueLength();
}
}

View File

@@ -192,14 +192,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
private final P25TrafficChannelManager mTrafficChannelManager; private final P25TrafficChannelManager mTrafficChannelManager;
private ServiceOptions mCurrentServiceOptions; private ServiceOptions mCurrentServiceOptions;
/**
* Encryption state of the current call as signalled by the algorithm ID in the HDU header or LDU2 encryption sync
* parameters (null when not yet known for this call). Some radios set the encryption flag in the service options of
* the LDU1 voice channel user link control while transmitting clear voice (HDU and LDU2 report ALGID 0x80), so the
* algorithm ID takes precedence over the link control service options when it is known.
*/
private Boolean mCallEncryptedByAlgorithm = null;
/** /**
* Constructs an APCO-25 decoder state with an optional traffic channel manager. * Constructs an APCO-25 decoder state with an optional traffic channel manager.
* @param channel with configuration details * @param channel with configuration details
@@ -427,12 +419,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
if(lcw instanceof IServiceOptionsProvider sop) if(lcw instanceof IServiceOptionsProvider sop)
{ {
serviceOptions = sop.getServiceOptions(); serviceOptions = sop.getServiceOptions();
//Trust the HDU/LDU2 algorithm ID over the link control service options encryption flag when known.
if(serviceOptions != null && serviceOptions.isEncrypted() && Boolean.FALSE.equals(mCallEncryptedByAlgorithm))
{
serviceOptions = VoiceServiceOptions.createUnencrypted(serviceOptions);
}
} }
else if(lcw.isEncrypted()) else if(lcw.isEncrypted())
{ {
@@ -852,7 +838,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
Identifier<?> radio = getIdentifierCollection().getFromIdentifier(); Identifier<?> radio = getIdentifierCollection().getFromIdentifier();
mCallEncryptedByAlgorithm = headerData.isEncryptedAudio();
mTrafficChannelManager.processP1TrafficCallStart(getCurrentFrequency(), talkgroup, radio, mTrafficChannelManager.processP1TrafficCallStart(getCurrentFrequency(), talkgroup, radio,
headerData.getEncryptionKey(), mCurrentServiceOptions, getCurrentChannel(), message.getTimestamp()); headerData.getEncryptionKey(), mCurrentServiceOptions, getCurrentChannel(), message.getTimestamp());
@@ -895,7 +880,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
if(esp != null && esp.isValid()) if(esp != null && esp.isValid())
{ {
getIdentifierCollection().update(esp.getIdentifiers()); getIdentifierCollection().update(esp.getIdentifiers());
mCallEncryptedByAlgorithm = esp.isEncryptedAudio();
if(esp.isEncryptedAudio()) if(esp.isEncryptedAudio())
{ {
@@ -919,7 +903,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
*/ */
private void processTDU(P25P1Message message) private void processTDU(P25P1Message message)
{ {
mCallEncryptedByAlgorithm = null;
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDU:" + message); mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDU:" + message);
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE)); broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
} }
@@ -938,7 +921,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
if(lcw != null && lcw.isValid()) if(lcw != null && lcw.isValid())
{ {
mCallEncryptedByAlgorithm = null;
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDULC:" + message); mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDULC:" + message);
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE)); broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
processLC(lcw, message.getTimestamp(), true); processLC(lcw, message.getTimestamp(), true);

View File

@@ -34,7 +34,7 @@ public class HDUMessage extends P25P1Message
private final static Logger mLog = LoggerFactory.getLogger(HDUMessage.class); private final static Logger mLog = LoggerFactory.getLogger(HDUMessage.class);
private static final int[] GOLAY_WORD_STARTS = {0, 18, 36, 54, 72, 90, 108, 126, 144, 162, 180, 198, 216, 234, 252, private static final int[] GOLAY_WORD_STARTS = {0, 18, 36, 54, 72, 90, 108, 126, 144, 162, 180, 198, 216, 234, 252,
270, 288, 306, 324, 342, 360, 378, 396, 414, 432, 450, 468, 486, 504, 522, 540, 558, 576, 594, 612, 630}; 270, 288, 306, 324, 342, 360, 278, 396, 414, 432, 450, 468, 486, 504, 522, 540, 558, 576, 594, 612, 630};
private static final int[] CW_HEX_0 = {0, 1, 2, 3, 4, 5}; private static final int[] CW_HEX_0 = {0, 1, 2, 3, 4, 5};
private static final int[] CW_HEX_1 = {18, 19, 20, 21, 22, 23}; private static final int[] CW_HEX_1 = {18, 19, 20, 21, 22, 23};

View File

@@ -24,7 +24,6 @@ import io.github.dsheirer.module.decode.p25.phase1.message.pdu.ambtc.AMBTCHeader
import io.github.dsheirer.module.decode.p25.phase1.message.pdu.packet.PacketHeader; import io.github.dsheirer.module.decode.p25.phase1.message.pdu.packet.PacketHeader;
import io.github.dsheirer.module.decode.p25.phase1.message.pdu.response.ResponseHeader; import io.github.dsheirer.module.decode.p25.phase1.message.pdu.response.ResponseHeader;
import io.github.dsheirer.module.decode.p25.reference.PDUFormat; import io.github.dsheirer.module.decode.p25.reference.PDUFormat;
import io.github.dsheirer.module.decode.p25.reference.ServiceAccessPoint;
public class PDUHeaderFactory public class PDUHeaderFactory
{ {
@@ -42,41 +41,8 @@ public class PDUHeaderFactory
case RESPONSE_PACKET_HEADER_FORMAT: case RESPONSE_PACKET_HEADER_FORMAT:
return new ResponseHeader(correctedBinaryMessage, passesCRC); return new ResponseHeader(correctedBinaryMessage, passesCRC);
case UNCONFIRMED_MULTI_BLOCK_TRUNKING_CONTROL: case UNCONFIRMED_MULTI_BLOCK_TRUNKING_CONTROL:
//Format 0x15 is the generic unconfirmed data header. Unconfirmed multi-block trunking control is
//only the trunking control SAP (61/63) use of it. With a packet data SAP the header has the same
//field layout as the confirmed packet data header and the data blocks carry an (IP) packet.
if(isPacketData(getServiceAccessPoint(correctedBinaryMessage)))
{
return new PacketHeader(correctedBinaryMessage, passesCRC);
}
return new PDUHeader(correctedBinaryMessage, passesCRC);
default: default:
return new PDUHeader(correctedBinaryMessage, passesCRC); return new PDUHeader(correctedBinaryMessage, passesCRC);
} }
} }
/**
* Service Access Point (SAP) from a packet data or unconfirmed data header message.
*/
public static ServiceAccessPoint getServiceAccessPoint(CorrectedBinaryMessage correctedBinaryMessage)
{
return ServiceAccessPoint.fromValue(correctedBinaryMessage.getInt(PacketHeader.SAP_ID));
}
/**
* Indicates if the SAP identifies packet data that is processed by the PDUMessageFactory as a packet message.
*/
public static boolean isPacketData(ServiceAccessPoint sap)
{
switch(sap)
{
case UNENCRYPTED_USER_DATA:
case PACKET_DATA:
case SNDCP_PACKET_DATA_CONTROL:
return true;
default:
return false;
}
}
} }

View File

@@ -173,13 +173,6 @@ public class PDUMessageFactory
case RESPONSE_PACKET_HEADER_FORMAT: case RESPONSE_PACKET_HEADER_FORMAT:
return new ResponseMessage(pduSequence, nac, timestamp); return new ResponseMessage(pduSequence, nac, timestamp);
case UNCONFIRMED_MULTI_BLOCK_TRUNKING_CONTROL: case UNCONFIRMED_MULTI_BLOCK_TRUNKING_CONTROL:
//The header factory creates a packet header when the unconfirmed data header carries a packet data
//SAP, so that unconfirmed IP packets are processed the same as confirmed (PACKET_DATA) packets.
if(pduSequence.getHeader() instanceof PacketHeader)
{
return createPacketData(pduSequence, nac, timestamp);
}
return createUMBTC(pduSequence, nac, timestamp); return createUMBTC(pduSequence, nac, timestamp);
default: default:
return new PDUSequenceMessage(pduSequence, nac, timestamp); return new PDUSequenceMessage(pduSequence, nac, timestamp);
@@ -236,7 +229,7 @@ public class PDUMessageFactory
} }
/** /**
* Creates a packet data message from a confirmed or unconfirmed packet data sequence. * Creates a packet data message
* *
* @param pduSequence containing a packet data header and zero or more data blocks * @param pduSequence containing a packet data header and zero or more data blocks
* @param nac network access code * @param nac network access code

View File

@@ -80,13 +80,4 @@ public class VoiceServiceOptions extends ServiceOptions
{ {
return new VoiceServiceOptions(4); return new VoiceServiceOptions(4);
} }
/**
* Creates a copy of the supplied service options with the encryption flag cleared and all other options unchanged.
* @param serviceOptions to copy
*/
public static VoiceServiceOptions createUnencrypted(ServiceOptions serviceOptions)
{
return new VoiceServiceOptions(serviceOptions.mServiceOptions & ~ENCRYPTION_FLAG);
}
} }

View File

@@ -1,6 +1,6 @@
/* /*
* ***************************************************************************** * *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer * Copyright (C) 2014-2024 Dennis Sheirer
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@@ -247,7 +247,6 @@ public class DiagnosticMonitor
Attributes atts = findManifestAttributes(); Attributes atts = findManifestAttributes();
if (atts != null) { if (atts != null) {
sb.append("\nVersion : " + atts.getValue("Implementation-Version")); sb.append("\nVersion : " + atts.getValue("Implementation-Version"));
sb.append("\nGit Describe : " + atts.getValue("Git-Describe"));
sb.append("\nGradle Version : " + atts.getValue("Created-By")); sb.append("\nGradle Version : " + atts.getValue("Created-By"));
sb.append("\nBuild Timestamp : " + atts.getValue("Build-Timestamp")); sb.append("\nBuild Timestamp : " + atts.getValue("Build-Timestamp"));
sb.append("\nBuild-JDK : " + atts.getValue("Build-JDK")); sb.append("\nBuild-JDK : " + atts.getValue("Build-JDK"));

View File

@@ -1,6 +1,6 @@
/* /*
* ***************************************************************************** * *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer * Copyright (C) 2014-2024 Dennis Sheirer
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@@ -48,7 +48,6 @@ public class SystemProperties
private static String PROPERTIES_FILENAME = "SDRTrunk.properties"; private static String PROPERTIES_FILENAME = "SDRTrunk.properties";
private static String MANIFEST_VERSION = "Implementation-Version"; private static String MANIFEST_VERSION = "Implementation-Version";
private static String BUILD_TIMESTAMP = "Build-Timestamp"; private static String BUILD_TIMESTAMP = "Build-Timestamp";
private static String GIT_DESCRIBE = "Git-Describe";
private static SystemProperties INSTANCE; private static SystemProperties INSTANCE;
private static Properties mProperties; private static Properties mProperties;
@@ -218,18 +217,12 @@ public class SystemProperties
{ {
String version = manifest.getMainAttributes().getValue(MANIFEST_VERSION); String version = manifest.getMainAttributes().getValue(MANIFEST_VERSION);
String timestamp = manifest.getMainAttributes().getValue(BUILD_TIMESTAMP); String timestamp = manifest.getMainAttributes().getValue(BUILD_TIMESTAMP);
String gitDescribe = manifest.getMainAttributes().getValue(GIT_DESCRIBE);
if(version != null) if(version != null)
{ {
if(version.contains("nightly") && timestamp != null) if(version.contains("nightly") && timestamp != null)
{ {
sb.append( " nightly - ").append(timestamp); sb.append( " nightly - ").append(timestamp);
if(gitDescribe != null && !gitDescribe.equals("unknown"))
{
sb.append(" (").append(gitDescribe).append(")");
}
} }
else else
{ {

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ import io.github.dsheirer.source.tuner.rtl.fc2580.FC2580TunerConfiguration;
import io.github.dsheirer.source.tuner.rtl.r8x.r820t.R820TTunerConfiguration; import io.github.dsheirer.source.tuner.rtl.r8x.r820t.R820TTunerConfiguration;
import io.github.dsheirer.source.tuner.rtl.r8x.r828d.R828DTunerConfiguration; import io.github.dsheirer.source.tuner.rtl.r8x.r828d.R828DTunerConfiguration;
import io.github.dsheirer.source.tuner.sdrplay.RspTunerConfiguration; import io.github.dsheirer.source.tuner.sdrplay.RspTunerConfiguration;
import io.github.dsheirer.source.tuner.signalhound.SignalHoundTunerConfiguration;
/** /**
* Abstract class to hold a configuration for a specific type of tuner * Abstract class to hold a configuration for a specific type of tuner
@@ -62,6 +63,7 @@ import io.github.dsheirer.source.tuner.sdrplay.RspTunerConfiguration;
@JsonSubTypes.Type(value = R820TTunerConfiguration.class, name = "r820TTunerConfiguration"), @JsonSubTypes.Type(value = R820TTunerConfiguration.class, name = "r820TTunerConfiguration"),
@JsonSubTypes.Type(value = R828DTunerConfiguration.class, name = "r828DTunerConfiguration"), @JsonSubTypes.Type(value = R828DTunerConfiguration.class, name = "r828DTunerConfiguration"),
@JsonSubTypes.Type(value = RspTunerConfiguration.class, name = "rspTunerConfiguration"), @JsonSubTypes.Type(value = RspTunerConfiguration.class, name = "rspTunerConfiguration"),
@JsonSubTypes.Type(value = SignalHoundTunerConfiguration.class, name = "signalHoundTunerConfiguration"),
}) })
@JacksonXmlRootElement(localName = "tuner_configuration") @JacksonXmlRootElement(localName = "tuner_configuration")
public abstract class TunerConfiguration public abstract class TunerConfiguration

View File

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

View File

@@ -0,0 +1,114 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.signalhound;
import io.github.dsheirer.preference.source.ChannelizerType;
import io.github.dsheirer.signalhound.DeviceDescriptor;
import io.github.dsheirer.source.tuner.TunerClass;
import io.github.dsheirer.source.tuner.TunerFactory;
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
/**
* Signal Hound tuner discovered from the device list of a Signal Hound API.
*/
public class DiscoveredSignalHoundTuner extends DiscoveredTuner
{
private final DeviceDescriptor mDescriptor;
private final ChannelizerType mChannelizerType;
/**
* Constructs an instance
* @param descriptor of the device, from the Signal Hound API device list
* @param channelizerType to use for the tuner
*/
public DiscoveredSignalHoundTuner(DeviceDescriptor descriptor, ChannelizerType channelizerType)
{
mDescriptor = descriptor;
mChannelizerType = channelizerType;
}
/**
* Descriptor of the device
*/
public DeviceDescriptor getDescriptor()
{
return mDescriptor;
}
/**
* Device family
*/
public SignalHoundFamily getFamily()
{
return SignalHoundFamily.of(mDescriptor.api());
}
@Override
public TunerClass getTunerClass()
{
return TunerClass.SIGNAL_HOUND;
}
/**
* Constructs and starts the tuner
*/
@Override
public void start()
{
if(isAvailable() && !hasTuner())
{
mTuner = TunerFactory.getSignalHoundTuner(mDescriptor, mChannelizerType, this);
try
{
mTuner.start();
}
catch(Exception se)
{
setErrorMessage("Error starting tuner [" + getId() + "] - " + se.getMessage());
mTuner = null;
}
}
}
/**
* Tuner ID, which identifies the tuner configuration. The family is used instead of the model, since the APIs
* don't always report the model before the device is opened, and the ID must be the same each time.
*/
@Override
public String getId()
{
return getId(mDescriptor);
}
/**
* Tuner ID of a device.
*/
public static String getId(DeviceDescriptor descriptor)
{
return "Signal Hound " + SignalHoundFamily.of(descriptor.api()) + " SER#" + descriptor.serialNumber();
}
@Override
public String toString()
{
return getId();
}
}

View File

@@ -0,0 +1,34 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.signalhound;
/**
* Signal Hound device details for display.
* @param model such as BB60D, SM200B or SP145
* @param serial number
* @param firmware version
* @param library name of the Signal Hound API library, such as bb_api
* @param libraryVersion of the Signal Hound API library
* @param libraryLocation where the Signal Hound API library was loaded from
*/
public record SignalHoundDeviceInformation(String model, int serial, String firmware, String library,
String libraryVersion, String libraryLocation)
{
}

View File

@@ -0,0 +1,180 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.signalhound;
import io.github.dsheirer.signalhound.SignalHoundApi;
import java.util.Locale;
/**
* Signal Hound device families, one for each Signal Hound API, with the limits and front-end controls of each family
* from the SDK headers (bb_api.h, sm_api.h and sp_api.h).
*
* The family names are used in tuner IDs, which persist the tuner configurations, so they must not change.
*/
public enum SignalHoundFamily
{
/**
* BB60A, BB60C and BB60D. Gain (0-3) and attenuation (0-30 dB in 10 dB steps) are automatic unless both are set.
*/
BB60(SignalHoundApi.BB, "BB60", 40_000_000, 9_000L, 6_400_000_000L, 3, 10, true, false),
/**
* SM200A/B/C and SM435B/C. Attenuation 0-30 dB in 5 dB steps, and an RF preselector up to 645 MHz.
*/
SM(SignalHoundApi.SM, "SM", 50_000_000, 100_000L, 20_600_000_000L, 6, 5, false, true),
/**
* SP145A and SP145B. Attenuation 0-30 dB in 5 dB steps, and an RF preselector on the SP145B up to 520 MHz.
*/
SP145(SignalHoundApi.SP, "SP145", 61_440_000, 9_000L, 15_000_000_000L, 6, 5, false, true);
//SM435 models tune higher than the SM200
private static final long SM435_MAXIMUM_FREQUENCY = 44_200_000_000L;
private final SignalHoundApi mApi;
private final String mLabel;
private final int mBaseSampleRate;
private final long mMinimumFrequency;
private final long mMaximumFrequency;
private final int mMaximumAttenuation;
private final int mAttenuationStepDb;
private final boolean mHasGain;
private final boolean mHasPreselector;
SignalHoundFamily(SignalHoundApi api, String label, int baseSampleRate, long minimumFrequency,
long maximumFrequency, int maximumAttenuation, int attenuationStepDb, boolean hasGain,
boolean hasPreselector)
{
mApi = api;
mLabel = label;
mBaseSampleRate = baseSampleRate;
mMinimumFrequency = minimumFrequency;
mMaximumFrequency = maximumFrequency;
mMaximumAttenuation = maximumAttenuation;
mAttenuationStepDb = attenuationStepDb;
mHasGain = hasGain;
mHasPreselector = hasPreselector;
}
/**
* Family of the devices that an API drives.
*/
public static SignalHoundFamily of(SignalHoundApi api)
{
return switch(api)
{
case BB -> BB60;
case SM -> SM;
case SP -> SP145;
};
}
/**
* API that drives the family.
*/
public SignalHoundApi getApi()
{
return mApi;
}
/**
* I/Q sample rate before decimation, in Hertz.
*/
public int getBaseSampleRate()
{
return mBaseSampleRate;
}
/**
* Lowest tunable frequency, in Hertz.
*/
public long getMinimumFrequency()
{
return mMinimumFrequency;
}
/**
* Highest tunable frequency of the family's default model, in Hertz.
*/
public long getMaximumFrequency()
{
return mMaximumFrequency;
}
/**
* Highest tunable frequency of a model, in Hertz.
* @param model as reported by the device, such as SM435B
*/
public long getMaximumFrequency(String model)
{
if(this == SM && model != null && model.toUpperCase(Locale.ROOT).startsWith("SM435"))
{
return SM435_MAXIMUM_FREQUENCY;
}
return mMaximumFrequency;
}
/**
* Highest attenuator setting, where each setting is {@link #getAttenuationStepDb()} dB.
*/
public int getMaximumAttenuation()
{
return mMaximumAttenuation;
}
/**
* Attenuation in dB of each attenuator setting.
*/
public int getAttenuationStepDb()
{
return mAttenuationStepDb;
}
/**
* Indicates if the family has a manual gain setting, in addition to the attenuator.
*/
public boolean hasGain()
{
return mHasGain;
}
/**
* Highest gain setting, for families that have one.
*/
public int getMaximumGain()
{
return mHasGain ? 3 : 0;
}
/**
* Indicates if devices of the family can have an RF preselector. For the SP145, only the SP145B has one.
*/
public boolean hasPreselector()
{
return mHasPreselector;
}
@Override
public String toString()
{
return mLabel;
}
}

View File

@@ -0,0 +1,86 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.signalhound;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
/**
* Signal Hound I/Q sample rate: the family's base sample rate divided by a power of two decimation.
* @param decimation power of two
* @param rate in Hertz
*/
public record SignalHoundSampleRate(int decimation, int rate)
{
//Decimations offered to the user. Higher decimations are supported by the APIs, but the sample rates are below
//what is useful for channelizing (600-780 kHz at the highest decimation offered here).
public static final int MAXIMUM_DECIMATION = 64;
public static final int DEFAULT_RATE = 10_000_000;
private static final DecimalFormat FORMAT = new DecimalFormat("0.00###");
/**
* Sample rates of a family, highest rate first. Every rate is an integer number of Hertz.
*/
public static List<SignalHoundSampleRate> getRates(SignalHoundFamily family)
{
List<SignalHoundSampleRate> rates = new ArrayList<>();
for(int decimation = 1; decimation <= MAXIMUM_DECIMATION; decimation *= 2)
{
rates.add(new SignalHoundSampleRate(decimation, family.getBaseSampleRate() / decimation));
}
return rates;
}
/**
* Finds the highest rate that doesn't exceed the requested rate.
* @param rates to search
* @param requested rate in Hertz
* @return closest rate, or the lowest rate when the requested rate is below all of them
*/
public static SignalHoundSampleRate getClosest(List<SignalHoundSampleRate> rates, int requested)
{
SignalHoundSampleRate closest = null;
SignalHoundSampleRate lowest = null;
for(SignalHoundSampleRate rate : rates)
{
if(rate.rate() <= requested && (closest == null || rate.rate() > closest.rate()))
{
closest = rate;
}
if(lowest == null || rate.rate() < lowest.rate())
{
lowest = rate;
}
}
return closest != null ? closest : lowest;
}
@Override
public String toString()
{
return FORMAT.format(rate / 1E6) + " MHz";
}
}

View File

@@ -0,0 +1,88 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.signalhound;
import io.github.dsheirer.preference.source.ChannelizerType;
import io.github.dsheirer.source.tuner.ITunerErrorListener;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.TunerClass;
/**
* Signal Hound tuner: BB60, SM200/SM435 or SP145
*/
public class SignalHoundTuner extends Tuner
{
//The BB60 and SM200 have 14-bit ADCs. The samples are floats after the API's corrections and filtering.
private static final double SAMPLE_SIZE_14_BIT = 14.0;
//The API transfers 16-bit I and Q samples from the device (32 bits per complex sample)
private static final int LINK_BITS_PER_SAMPLE = 32;
/**
* Constructs an instance
* @param controller for the Signal Hound device
* @param tunerErrorListener to listen for errors from this tuner
* @param channelizerType for the channelizer
*/
public SignalHoundTuner(SignalHoundTunerController controller, ITunerErrorListener tunerErrorListener,
ChannelizerType channelizerType)
{
super(controller, tunerErrorListener, channelizerType);
}
/**
* Signal Hound tuner controller
*/
public SignalHoundTunerController getController()
{
return (SignalHoundTunerController)getTunerController();
}
@Override
public String getPreferredName()
{
SignalHoundDeviceInformation info = getController().getDeviceInfo();
String model = info != null ? info.model() : getController().getFamily().toString();
return "Signal Hound " + model + " " + getUniqueID();
}
@Override
public String getUniqueID()
{
return Integer.toString(getController().getDescriptor().serialNumber());
}
@Override
public TunerClass getTunerClass()
{
return TunerClass.SIGNAL_HOUND;
}
@Override
public double getSampleSize()
{
return SAMPLE_SIZE_14_BIT;
}
@Override
public int getMaximumUSBBitsPerSecond()
{
return getController().getFamily().getBaseSampleRate() * LINK_BITS_PER_SAMPLE;
}
}

View File

@@ -0,0 +1,131 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.source.tuner.signalhound;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import io.github.dsheirer.source.tuner.TunerType;
import io.github.dsheirer.source.tuner.configuration.TunerConfiguration;
/**
* Signal Hound tuner configuration, for devices of all Signal Hound families.
*/
public class SignalHoundTunerConfiguration extends TunerConfiguration
{
private int mSampleRate = SignalHoundSampleRate.DEFAULT_RATE;
private int mReferenceLevel = SignalHoundTunerController.REFERENCE_LEVEL_DEFAULT;
private int mAttenuation = SignalHoundTunerController.AUTOMATIC;
private int mGain = SignalHoundTunerController.AUTOMATIC;
private boolean mPreselector = false;
/**
* Default constructor for Jackson
*/
public SignalHoundTunerConfiguration()
{
super(SignalHoundTunerController.MINIMUM_TUNABLE_FREQUENCY_HZ,
SignalHoundTunerController.MAXIMUM_TUNABLE_FREQUENCY_HZ);
}
/**
* Constructs an instance
* @param uniqueID for the tuner
*/
public SignalHoundTunerConfiguration(String uniqueID)
{
super(uniqueID);
}
@Override
@JacksonXmlProperty(isAttribute = true, localName = "type", namespace = "http://www.w3.org/2001/XMLSchema-instance")
public TunerType getTunerType()
{
return TunerType.SIGNAL_HOUND;
}
/**
* Sample rate in Hertz
*/
@JacksonXmlProperty(isAttribute = true, localName = "sample_rate")
public int getSampleRate()
{
return mSampleRate;
}
public void setSampleRate(int sampleRate)
{
mSampleRate = sampleRate;
}
/**
* Reference level in dBm: the strongest expected input level, which sets the gain and attenuation automatically.
*/
@JacksonXmlProperty(isAttribute = true, localName = "reference_level")
public int getReferenceLevel()
{
return mReferenceLevel;
}
public void setReferenceLevel(int referenceLevel)
{
mReferenceLevel = referenceLevel;
}
/**
* Attenuator setting, or -1 for automatic. The step size depends on the device family.
*/
@JacksonXmlProperty(isAttribute = true, localName = "attenuation")
public int getAttenuation()
{
return mAttenuation;
}
public void setAttenuation(int attenuation)
{
mAttenuation = attenuation;
}
/**
* Gain setting (BB60 only), or -1 for automatic.
*/
@JacksonXmlProperty(isAttribute = true, localName = "gain")
public int getGain()
{
return mGain;
}
public void setGain(int gain)
{
mGain = gain;
}
/**
* RF preselector (SM200/SM435 and SP145B) enabled.
*/
@JacksonXmlProperty(isAttribute = true, localName = "preselector")
public boolean isPreselector()
{
return mPreselector;
}
public void setPreselector(boolean preselector)
{
mPreselector = preselector;
}
}

View File

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

View File

@@ -1,140 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.util;
import java.io.File;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Detects more than one version of the same library jar on the class path.
*
* The release launch scripts use a 'lib/*' wildcard class path, so when a user unzips a new release over an older one,
* the stale jars from the older release stay on the class path next to the new ones. The JVM loads classes (and the
* tuner libraries extract their native libraries) from whichever jar sorts first, which can silently run an older
* library version, including native code with bugs that the newer version fixed.
*/
public class DuplicateLibraryDetector
{
/**
* Splits a jar file name into the library name and version. The version starts at the first dash that is
* followed by a digit, a 'v' and a digit, or 'nightly' (e.g. guava-33.8.0-jre, api-v2.1.1, sdr-trunk-nightly).
*/
private static final Pattern JAR_NAME = Pattern.compile("^(.+?)-(v?\\d.*|nightly)\\.jar$");
private DuplicateLibraryDetector()
{
}
/**
* Finds the libraries that have more than one version on this JVM's class path.
* @return map of library name to the sorted jar paths for that library, empty when there are no duplicates.
*/
public static Map<String,List<String>> findDuplicates()
{
String classPath = System.getProperty("java.class.path", "");
return findDuplicates(List.of(classPath.split(Pattern.quote(File.pathSeparator))));
}
/**
* Finds the libraries that have more than one version in the class path entries. Entries that are not jar files
* or that don't have a version in the file name are ignored.
* @param classPathEntries to check
* @return map of library name to the sorted jar paths for that library, empty when there are no duplicates.
*/
public static Map<String,List<String>> findDuplicates(List<String> classPathEntries)
{
Map<String,TreeSet<String>> versions = new TreeMap<>();
Map<String,TreeSet<String>> paths = new TreeMap<>();
for(String entry: classPathEntries)
{
if(entry == null || entry.isBlank())
{
continue;
}
Path fileName = Path.of(entry).getFileName();
if(fileName == null)
{
continue;
}
Matcher matcher = JAR_NAME.matcher(fileName.toString());
if(matcher.matches())
{
versions.computeIfAbsent(matcher.group(1), k -> new TreeSet<>()).add(matcher.group(2));
paths.computeIfAbsent(matcher.group(1), k -> new TreeSet<>()).add(entry);
}
}
Map<String,List<String>> duplicates = new TreeMap<>();
for(Map.Entry<String,TreeSet<String>> entry: versions.entrySet())
{
if(entry.getValue().size() > 1)
{
duplicates.put(entry.getKey(), new ArrayList<>(paths.get(entry.getKey())));
}
}
return duplicates;
}
/**
* Creates a description of the duplicate libraries for a log entry or a dialog.
* @param duplicates from findDuplicates()
* @return description, one line per library, listing the jar file names for each library.
*/
public static String describe(Map<String,List<String>> duplicates)
{
StringBuilder sb = new StringBuilder();
for(Map.Entry<String,List<String>> entry: duplicates.entrySet())
{
List<String> names = entry.getValue().stream().map(p -> Path.of(p).getFileName().toString()).toList();
sb.append(" ").append(entry.getKey()).append(": ").append(String.join(", ", names)).append("\n");
}
return sb.toString();
}
/**
* Folder that holds the duplicate jars, for telling the user which folder to replace.
* @param duplicates from findDuplicates()
* @return folder of the first duplicate jar, or null if there are no duplicates or the path has no parent.
*/
public static Path getFolder(Map<String,List<String>> duplicates)
{
if(duplicates.isEmpty())
{
return null;
}
return Path.of(duplicates.values().iterator().next().getFirst()).toAbsolutePath().normalize().getParent();
}
}

View File

@@ -1,85 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.edac;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests Golay(24,12) correction of the data bits of words that are not at the start of a 24-bit message.
*/
public class Golay24OffsetTest
{
private static int encode(int data)
{
int checksum = 0;
for(int bit = 0; bit < 12; bit++)
{
if((data & (1 << (11 - bit))) != 0)
{
checksum ^= Golay24.CHECKSUMS[bit];
}
}
int word23 = (data << 11) | checksum;
return (word23 << 1) | (Integer.bitCount(word23) & 1);
}
@Test
public void correctsUpToThreeErrorsInEveryWordOfALongerMessage()
{
Random random = new Random(1);
for(int trial = 0; trial < 2000; trial++)
{
int[] words = new int[3];
CorrectedBinaryMessage message = new CorrectedBinaryMessage(72);
for(int w = 0; w < 3; w++)
{
words[w] = encode(random.nextInt(4096));
int received = words[w];
for(int e = random.nextInt(4); e > 0; e--)
{
received ^= 1 << random.nextInt(24);
}
message.load(w * 24, 24, received);
}
for(int w = 0; w < 3; w++)
{
Golay24.checkAndCorrect(message, w * 24);
}
for(int w = 0; w < 3; w++)
{
//The 12 data bits. (The core correction can leave the overall parity bit wrong when it corrects other
//bits, which no caller reads.)
assertEquals(words[w] >> 12, message.getInt(w * 24, w * 24 + 11), "trial " + trial + " word " + w);
}
}
}
}

View File

@@ -1,216 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.Identity;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.RequestParameter;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.RequestedTokens;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.Token;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.TokenType;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.TriggerCondition;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.TriggerInterval;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.TriggerPeriodic;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* JUnit tests for LRRP request packet token parsing.
*
* The sample packets are Motorola triggered location start requests captured from a P25 system (UDP port 4001).
* Both carry the same requested fields (time, horizontal speed, horizontal direction) and the same two parameters of
* unknown meaning (0x44 = 100, 0x42 = 3600); one is a periodic trigger every 600 seconds and the other is an
* emergency trigger.
*/
public class LRRPPacketTest
{
//Periodic profile: ID FFFFEE, periodic trigger with a 600 second interval (0x31 0x84 0x58)
private static final String PERIODIC_REQUEST = "09112203FFFFEE524464429C10625734318458";
//Condition profile: ID FFFF02, trigger condition emergency (0x4A 0x02)
private static final String EMERGENCY_REQUEST = "09102203FFFF02524464429C106257344A02";
//PERIODIC_REQUEST as received with the packet type byte corrupted to 0xA3 (unknown type)
private static final String UNKNOWN_TYPE_REQUEST = "A3112203FFFFEE524464429C10625734318458";
//EMERGENCY_REQUEST with the packet type byte corrupted to 0x07 (immediate location response)
private static final String DAMAGED_TYPE_REQUEST = "07102203FFFF02524464429C106257344A02";
private static final List<TokenType> REQUESTED_FIELDS = List.of(TokenType.TIME, TokenType.HORIZONTAL_SPEED,
TokenType.HORIZONTAL_DIRECTION);
private static LRRPPacket parse(String hex)
{
return new LRRPPacket(CorrectedBinaryMessage.loadHex(hex), 0);
}
private static <T extends Token> T getToken(LRRPPacket packet, Class<T> clazz, TokenType tokenType)
{
for(Token token: packet.getTokens())
{
if(token.getTokenType() == tokenType && clazz.isInstance(token))
{
return clazz.cast(token);
}
}
fail("Packet does not contain token " + tokenType + ": " + packet);
return null;
}
private static boolean hasToken(LRRPPacket packet, TokenType tokenType)
{
for(Token token: packet.getTokens())
{
if(token.getTokenType() == tokenType)
{
return true;
}
}
return false;
}
/**
* Number of payload bytes accounted for by the parsed tokens: parameter tokens contribute their byte length and
* each requested field flag contributes one byte.
*/
private static int getConsumedBytes(LRRPPacket packet)
{
int consumed = 0;
for(Token token: packet.getTokens())
{
if(token instanceof RequestedTokens requested)
{
consumed += requested.getRequestedTokens().size() + requested.getUnrecognizedRequestedTokens().size();
}
else
{
consumed += token.getByteLength();
}
}
return consumed;
}
/**
* Asserts the fields that both sample requests share.
*/
private static void assertCommonRequestTokens(LRRPPacket packet, int expectedId)
{
Identity identity = getToken(packet, Identity.class, TokenType.IDENTITY);
assertEquals(expectedId, identity.getID());
assertEquals(5, identity.getByteLength());
assertNotNull(getToken(packet, TriggerPeriodic.class, TokenType.TRIGGER_PERIODIC));
RequestParameter parameter42 = getToken(packet, RequestParameter.class, TokenType.REQUEST_PARAMETER_42);
assertEquals(3600, parameter42.getValue()); //0x42 0x9C 0x10
assertEquals(3, parameter42.getByteLength());
RequestParameter parameter44 = getToken(packet, RequestParameter.class, TokenType.REQUEST_PARAMETER_44);
assertEquals(100, parameter44.getValue()); //0x44 0x64
assertEquals(2, parameter44.getByteLength());
RequestedTokens requested = getToken(packet, RequestedTokens.class, TokenType.REQUESTED_TOKENS);
assertEquals(REQUESTED_FIELDS, requested.getRequestedTokens());
assertTrue(requested.getUnrecognizedRequestedTokens().isEmpty(), "No stray tokens: " + packet);
//Every payload byte is accounted for exactly once
assertEquals(packet.getHeader().getPayloadLength(), getConsumedBytes(packet));
}
@Test
public void periodicTriggeredLocationStartRequest()
{
LRRPPacket packet = parse(PERIODIC_REQUEST);
assertEquals(LRRPPacketType.TRIGGERED_LOCATION_START_REQUEST, packet.getHeader().getLRRPPacketType());
assertEquals(17, packet.getHeader().getPayloadLength());
assertCommonRequestTokens(packet, 0xFFFFEE);
TriggerInterval interval = getToken(packet, TriggerInterval.class, TokenType.TRIGGER_INTERVAL);
assertEquals(600, interval.getSeconds()); //0x31 0x84 0x58
assertEquals(3, interval.getByteLength());
assertFalse(hasToken(packet, TokenType.TRIGGER_CONDITION));
assertEquals("LRRP TRIGGERED LOCATION START REQUEST ID:FFFFEE PERIODIC INTERVAL:600 SECS PARAM-42:3600 " +
"PARAM-44:100 REQUESTED TOKENS [TIME,HORIZONTAL SPEED,HORIZONTAL DIRECTION]", packet.toString());
}
@Test
public void emergencyTriggeredLocationStartRequest()
{
LRRPPacket packet = parse(EMERGENCY_REQUEST);
assertEquals(LRRPPacketType.TRIGGERED_LOCATION_START_REQUEST, packet.getHeader().getLRRPPacketType());
assertEquals(16, packet.getHeader().getPayloadLength());
assertCommonRequestTokens(packet, 0xFFFF02);
TriggerCondition condition = getToken(packet, TriggerCondition.class, TokenType.TRIGGER_CONDITION);
assertEquals(TriggerCondition.Condition.EMERGENCY, condition.getCondition()); //0x4A 0x02
assertEquals(2, condition.getByteLength());
assertFalse(hasToken(packet, TokenType.TRIGGER_INTERVAL));
assertEquals("LRRP TRIGGERED LOCATION START REQUEST ID:FFFF02 PERIODIC TRIGGER CONDITION:EMERGENCY " +
"PARAM-42:3600 PARAM-44:100 REQUESTED TOKENS [TIME,HORIZONTAL SPEED,HORIZONTAL DIRECTION]",
packet.toString());
}
/**
* A corrupted type byte that is not a known packet type takes the request parsing path, so the intact body still
* decodes completely.
*/
@Test
public void unknownTypeRequestBodyStillDecodes()
{
LRRPPacket packet = parse(UNKNOWN_TYPE_REQUEST);
assertEquals(LRRPPacketType.UNKNOWN, packet.getHeader().getLRRPPacketType());
assertEquals(0xA3, packet.getHeader().getLrrpPacketTypeValue());
assertCommonRequestTokens(packet, 0xFFFFEE);
assertEquals(600, getToken(packet, TriggerInterval.class, TokenType.TRIGGER_INTERVAL).getSeconds());
assertEquals("LRRP TYPE:UNKNOWN [163] ID:FFFFEE PERIODIC INTERVAL:600 SECS PARAM-42:3600 PARAM-44:100 " +
"REQUESTED TOKENS [TIME,HORIZONTAL SPEED,HORIZONTAL DIRECTION]", packet.toString());
}
/**
* A type byte corrupted to a valid response type (0x07) sends the body down the response parsing path, where
* 0x34 is a timestamp rather than the periodic trigger flag. The parse must still terminate within the payload
* and the identity and variable length integer parameters must still decode.
*/
@Test
public void damagedTypeRequestParsesAsResponseWithoutError()
{
LRRPPacket packet = parse(DAMAGED_TYPE_REQUEST);
assertEquals(LRRPPacketType.IMMEDIATE_LOCATION_RESPONSE, packet.getHeader().getLRRPPacketType());
assertDoesNotThrow(packet::toString);
assertEquals(0xFFFF02, getToken(packet, Identity.class, TokenType.IDENTITY).getID());
assertEquals(3600, getToken(packet, RequestParameter.class, TokenType.REQUEST_PARAMETER_42).getValue());
assertEquals(100, getToken(packet, RequestParameter.class, TokenType.REQUEST_PARAMETER_44).getValue());
}
}

View File

@@ -1,88 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the LRRP 7-bit variable length integer reader.
*/
public class VarIntTest
{
private static CorrectedBinaryMessage hex(String hex)
{
return CorrectedBinaryMessage.loadHex(hex);
}
@Test
public void singleByteValue()
{
CorrectedBinaryMessage message = hex("64");
assertEquals(1, VarInt.length(message, 0, 10));
assertEquals(100, VarInt.value(message, 0, 10));
}
@Test
public void twoByteValues()
{
//Values captured from Motorola triggered location start requests (interval 600 s, unknown parameter 3600)
assertEquals(2, VarInt.length(hex("8458"), 0, 10));
assertEquals(600, VarInt.value(hex("8458"), 0, 10));
assertEquals(2, VarInt.length(hex("9C10"), 0, 10));
assertEquals(3600, VarInt.value(hex("9C10"), 0, 10));
//Values from the TRBONet captures quoted in node-dmr-lib LRRP.js: interval 250 s and distance 500 m
assertEquals(250, VarInt.value(hex("817A"), 0, 10));
assertEquals(500, VarInt.value(hex("8374"), 0, 10));
}
@Test
public void valueAtOffset()
{
//Token identifier 0x31 followed by the value - read starting at bit 8
CorrectedBinaryMessage message = hex("318458");
assertEquals(2, VarInt.length(message, 8, 2));
assertEquals(600, VarInt.value(message, 8, 2));
}
@Test
public void limitBoundsTheRead()
{
CorrectedBinaryMessage message = hex("8458");
//Only one byte available: the continuation bit is set but the read stops at the limit
assertEquals(1, VarInt.length(message, 0, 1));
assertEquals(4, VarInt.value(message, 0, 1));
//No bytes available
assertEquals(0, VarInt.length(message, 0, 0));
assertEquals(0, VarInt.value(message, 0, 0));
}
@Test
public void maximumLength()
{
//A run of continuation bits longer than MAX_BYTES stops at MAX_BYTES
CorrectedBinaryMessage message = hex("FFFFFFFFFFFFFF");
assertEquals(VarInt.MAX_BYTES, VarInt.length(message, 0, 10));
}
}

View File

@@ -1,229 +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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.p25.phase1.message.pdu;
import io.github.dsheirer.bits.BinaryMessage;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.module.decode.ip.IPacket;
import io.github.dsheirer.module.decode.ip.ipv4.IPV4Packet;
import io.github.dsheirer.module.decode.ip.mototrbo.ars.ARSPacket;
import io.github.dsheirer.module.decode.ip.mototrbo.ars.RegistrationAcknowledgement;
import io.github.dsheirer.module.decode.ip.udp.UDPPacket;
import io.github.dsheirer.module.decode.p25.phase1.message.P25P1Message;
import io.github.dsheirer.module.decode.p25.phase1.message.pdu.block.DataBlock;
import io.github.dsheirer.module.decode.p25.phase1.message.pdu.packet.PacketHeader;
import io.github.dsheirer.module.decode.p25.phase1.message.pdu.packet.PacketMessage;
import io.github.dsheirer.module.decode.p25.reference.PDUFormat;
import io.github.dsheirer.module.decode.p25.reference.PDUType;
import io.github.dsheirer.module.decode.p25.reference.ServiceAccessPoint;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests for PDU header and message creation, in particular that an unconfirmed data header (format 0x15) that
* carries a packet data SAP is processed as an IP packet and not as unconfirmed multi-block trunking control.
*/
public class PDUMessageFactoryTest
{
private static final int NAC = 0x460;
/**
* Outbound unconfirmed data header: format 0x15, SAP 4 (PACKET DATA), MFID 0, LLID 4006527, 4 blocks to
* follow, 10 pad octets, data header offset 2. The header CRC octets are zero; the header factory derives
* the CRC result from the corrected bit count and does not evaluate them.
*/
private static final String UNCONFIRMED_SAP_4_HEADER = "3504003D227F840A00020000";
private static final String UNCONFIRMED_SAP_0_HEADER = "3500003D227F840A00020000";
private static final String UNCONFIRMED_SAP_6_HEADER = "3506003D227F840A00020000";
private static final String UNCONFIRMED_SAP_61_HEADER = "353D003D227F840A00020000";
private static final String UNCONFIRMED_SAP_63_HEADER = "353F003D227F840A00020000";
/** Same as UNCONFIRMED_SAP_4_HEADER with LLID 4001080 and 5 pad octets */
private static final String UNCONFIRMED_SAP_4_HEADER_PAD_5 = "3504003D0D38840500020000";
/** Outbound confirmed data header: format 0x16, SAP 0, LLID 4006527, 4 blocks, 10 pad octets, offset 2 */
private static final String CONFIRMED_SAP_0_HEADER = "7600003D227F840A00020000";
/**
* Unconfirmed (1/2 rate, 12 octet) data blocks captured from a Motorola P25 Phase 1 control channel, as logged
* by the PDUSequence MSG: field. SNDCP header 0x4100 (RF UNCONFIRMED DATA, NSAPI 1), IPv4/UDP from
* 10.51.4.116:49516 to 10.71.213.29:4005, ARS registration acknowledgement (refresh 8 x 30 minutes),
* 10 pad octets (0xAA) and the 32-bit packet CRC.
*/
private static final String[] ARS_ACK_BLOCKS = {"410045000020280200008011", "24C00A3304740A47D51DC16C",
"0FA5000C81AE0002BF08AAAA", "AAAAAAAAAAAAAAAAC0F19102"};
/**
* As above, to 10.71.212.139 with a 5 octet trailer (0x04 + 32-bit time) after the ARS extension octet and
* 5 pad octets.
*/
private static final String[] ARS_ACK_WITH_TRAILER_BLOCKS = {"4100450000255F7E00008011", "EDD00A3304740A47D48BC16C",
"0FA5001113E60007BF08046A", "D3E096AAAAAAAAAA8675BA00"};
@Test
public void unconfirmedDataHeaderWithPacketDataSapIsPacketHeader()
{
for(String hex : new String[]{UNCONFIRMED_SAP_0_HEADER, UNCONFIRMED_SAP_4_HEADER, UNCONFIRMED_SAP_6_HEADER})
{
PDUHeader header = PDUHeaderFactory.getPDUHeader(CorrectedBinaryMessage.loadHex(hex));
assertEquals(PDUFormat.UNCONFIRMED_MULTI_BLOCK_TRUNKING_CONTROL, header.getFormat(), hex);
assertInstanceOf(PacketHeader.class, header, hex);
assertFalse(header.isConfirmationRequired(), hex);
assertTrue(header.isOutbound(), hex);
}
PacketHeader header = (PacketHeader)PDUHeaderFactory.getPDUHeader(CorrectedBinaryMessage.loadHex(UNCONFIRMED_SAP_4_HEADER));
assertEquals(ServiceAccessPoint.PACKET_DATA, header.getServiceAccessPoint());
assertEquals(4, header.getBlocksToFollowCount());
assertEquals(10, header.getPadOctetCount());
assertEquals(2, header.getDataHeaderOffset());
}
@Test
public void unconfirmedDataHeaderWithTrunkingControlSapIsNotPacketHeader()
{
for(String hex : new String[]{UNCONFIRMED_SAP_61_HEADER, UNCONFIRMED_SAP_63_HEADER})
{
PDUHeader header = PDUHeaderFactory.getPDUHeader(CorrectedBinaryMessage.loadHex(hex));
assertEquals(PDUFormat.UNCONFIRMED_MULTI_BLOCK_TRUNKING_CONTROL, header.getFormat(), hex);
assertFalse(header instanceof PacketHeader, hex);
P25P1Message message = PDUMessageFactory.create(new PDUSequence(header, 0, NAC), NAC, 0);
assertInstanceOf(PDUSequenceMessage.class, message, hex);
}
}
@Test
public void confirmedPacketDataHeaderIsPacketHeader()
{
PDUHeader header = PDUHeaderFactory.getPDUHeader(CorrectedBinaryMessage.loadHex(CONFIRMED_SAP_0_HEADER));
assertEquals(PDUFormat.PACKET_DATA, header.getFormat());
assertInstanceOf(PacketHeader.class, header);
assertTrue(header.isConfirmationRequired());
}
@Test
public void unconfirmedPacketDataDecodesAsIpPacket()
{
PacketMessage packetMessage = createPacketMessage(UNCONFIRMED_SAP_4_HEADER, ARS_ACK_BLOCKS);
assertEquals(PDUType.OUTBOUND_SNDCP_RF_UNCONFIRMED_DATA, packetMessage.getSNDCPPacketHeader().getPDUType());
assertEquals(1, packetMessage.getSNDCPPacketHeader().getNSAPI());
//2 octets SNDCP header + 32 octet IP packet + 10 pad octets + 4 CRC octets = 48 octets = 4 x 12 octet blocks
assertEquals(32 * 8, packetMessage.getPacketMessage().size());
RegistrationAcknowledgement acknowledgement = getRegistrationAcknowledgement(packetMessage);
assertTrue(acknowledgement.isValid());
assertTrue(acknowledgement.isSuccessful());
assertEquals(8, acknowledgement.getPayloadValue());
String text = packetMessage.toString();
assertTrue(text.contains(" IPPKT "), text);
assertTrue(text.contains("LLID:4006527"), text);
assertTrue(text.contains("NSAPI:1"), text);
assertTrue(text.contains("IP FROM:10.51.4.116 TO:10.71.213.29"), text);
assertTrue(text.contains("UDP PORT FROM:49516 TO:4005"), text);
assertTrue(text.contains("ARS REGISTRATION SUCCESS REFRESH IN:240mins"), text);
}
@Test
public void unconfirmedPacketDataWithArsTrailerDecodesAsIpPacket()
{
PacketMessage packetMessage = createPacketMessage(UNCONFIRMED_SAP_4_HEADER_PAD_5, ARS_ACK_WITH_TRAILER_BLOCKS);
//2 octets SNDCP header + 37 octet IP packet + 5 pad octets + 4 CRC octets = 48 octets
assertEquals(37 * 8, packetMessage.getPacketMessage().size());
RegistrationAcknowledgement acknowledgement = getRegistrationAcknowledgement(packetMessage);
assertTrue(acknowledgement.isValid());
assertTrue(acknowledgement.isSuccessful());
assertEquals(8, acknowledgement.getPayloadValue());
String text = packetMessage.toString();
assertTrue(text.contains("LLID:4001080"), text);
assertTrue(text.contains("IP FROM:10.51.4.116 TO:10.71.212.139"), text);
assertTrue(text.contains("UDP PORT FROM:49516 TO:4005"), text);
assertTrue(text.contains("ARS REGISTRATION SUCCESS REFRESH IN:240mins"), text);
}
/**
* Creates a PDU sequence from the header and data blocks and processes it with the PDU message factory.
*/
private static PacketMessage createPacketMessage(String headerHex, String[] blocksHex)
{
PDUHeader header = PDUHeaderFactory.getPDUHeader(CorrectedBinaryMessage.loadHex(headerHex));
PDUSequence sequence = new PDUSequence(header, 0, NAC);
for(String blockHex : blocksHex)
{
sequence.addDataBlock(dataBlock(blockHex));
}
assertTrue(sequence.isComplete());
P25P1Message message = PDUMessageFactory.create(sequence, NAC, 0);
assertInstanceOf(PacketMessage.class, message);
return (PacketMessage)message;
}
private static RegistrationAcknowledgement getRegistrationAcknowledgement(PacketMessage packetMessage)
{
IPacket packet = packetMessage.getPacket();
assertInstanceOf(IPV4Packet.class, packet);
IPacket udp = ((IPV4Packet)packet).getPayload();
assertInstanceOf(UDPPacket.class, udp);
assertEquals(4005, ((UDPPacket)udp).getHeader().getDestinationPort().getValue());
IPacket ars = ((UDPPacket)udp).getPayload();
assertInstanceOf(ARSPacket.class, ars);
assertInstanceOf(RegistrationAcknowledgement.class, ((ARSPacket)ars).getHeader());
return (RegistrationAcknowledgement)((ARSPacket)ars).getHeader();
}
/**
* Data block that returns an already decoded (1/2 rate, 96-bit) payload.
*/
private static DataBlock dataBlock(String hex)
{
BinaryMessage message = BinaryMessage.loadHex(hex);
return new DataBlock()
{
@Override
public BinaryMessage getMessage()
{
return message;
}
@Override
public int getBitErrorsCount()
{
return 0;
}
@Override
public boolean isValid()
{
return true;
}
};
}
}

View File

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

View File

@@ -1,78 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.util;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class DuplicateLibraryDetectorTest
{
private static final String LIB = "/opt/sdr-trunk/bin/../lib/";
/**
* Jar names from a clean release image.
*/
private static final List<String> CLEAN = List.of("JLargeArrays-1.7.jar", "JTransforms-3.2.jar", "api-v2.1.1.jar",
"commons-lang3-3.21.0.jar", "guava-33.8.0-jre.jar", "jackson-annotations-2.23.jar",
"jackson-core-2.23.0.jar", "java-lame-v3.98.4.jar", "javafx-swt.jar", "jhackrf-2026.01.3.1.jar",
"jiconfont-1.0.0.jar", "jiconfont-font_awesome-4.7.0.1.jar", "jiconfont-javafx-1.0.0.jar",
"jiconfont-swing-1.0.1.jar", "jrt-fs.jar", "jrtl-1.4.0.3.jar", "libresample4j-master.jar",
"listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar", "logback-classic-1.6.6.jar",
"logback-core-1.6.6.jar", "sdr-trunk-nightly.jar");
private static List<String> paths(List<String> names)
{
return names.stream().map(n -> LIB + n).toList();
}
@Test
void cleanReleaseHasNoDuplicates()
{
assertTrue(DuplicateLibraryDetector.findDuplicates(paths(CLEAN)).isEmpty());
assertNull(DuplicateLibraryDetector.getFolder(Map.of()));
}
@Test
void newReleaseUnzippedOverOldReleaseIsDetected()
{
List<String> names = new java.util.ArrayList<>(CLEAN);
names.addAll(List.of("api-v2.1.0.jar", "guava-33.6.0-jre.jar", "jrtl-1.4.0.2.jar", "sdr-trunk-0.6.1.jar"));
Map<String,List<String>> duplicates = DuplicateLibraryDetector.findDuplicates(paths(names));
assertEquals(List.of("api", "guava", "jrtl", "sdr-trunk"), List.copyOf(duplicates.keySet()));
assertEquals(List.of(LIB + "jrtl-1.4.0.2.jar", LIB + "jrtl-1.4.0.3.jar"), duplicates.get("jrtl"));
assertTrue(DuplicateLibraryDetector.describe(duplicates).contains("jrtl: jrtl-1.4.0.2.jar, jrtl-1.4.0.3.jar"));
//Windows resolves a rooted path without a drive letter against the current drive, e.g. C:\opt\sdr-trunk\lib
assertEquals(Path.of("/opt/sdr-trunk/lib").toAbsolutePath(), DuplicateLibraryDetector.getFolder(duplicates));
}
@Test
void sameJarListedTwiceIsNotADuplicate()
{
assertTrue(DuplicateLibraryDetector.findDuplicates(List.of(LIB + "jrtl-1.4.0.3.jar", LIB + "jrtl-1.4.0.3.jar",
"", "/some/classes/dir")).isEmpty());
}
}