Compare commits

..

8 Commits

Author SHA1 Message Date
Denny Sheirer
73d3238644 #2634 Gradle build now uses git tag for versioning (#2635)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-13 03:32:08 -05:00
farewell-ladmin
4626d56730 #2392 P25P1: trust HDU/LDU2 algorithm ID over LC service options encryption flag (#2477)
Some radios set the encryption flag in the service options of the LDU1 Group
Voice Channel User link control while transmitting clear voice; the HDU and
LDU2 encryption sync parameters report ALGID 0x80 (unencrypted) for the same
call. Example (CoMIRS Boston, TG 2217, valid LCs from several radios):

  HDU   TALKGROUP:2217 UNENCRYPTED
  LDU1  GROUP VOICE CHANNEL USER FM:64008 TO:2217 SERVICE OPTIONS:PRI4 ENCRYPTED CIRCUIT
  LDU2  UNENCRYPTED

P25P1DecoderState broadcast State.ENCRYPTED for each such LDU1 and State.CALL
for each LDU2, so the channel state flapped between CALL and ENCRYPTED (and on
upstream, where ENCRYPTED cannot change back to CALL, the call stays muted).

Track the per-call encryption state from the HDU header and LDU2 algorithm ID
(reset at TDU/TDULC). When it is known to be unencrypted, clear the encryption
flag on the LC service options before using them for the channel state and
traffic channel call event. Genuinely encrypted calls are unaffected, and when
the algorithm ID is not yet known (late entry before the first LDU2) the LC
service options are used as before.

Adds VoiceServiceOptions.createUnencrypted(ServiceOptions) to copy service
options with only the encryption flag cleared.

Verified by driving P25P1DecoderState with HDU/LDU1/LDU2/TDU sequences
reproducing the field logs: state events go from C C C E C E C to all CALL;
encrypted calls (ALGID 0x84) still report ENCRYPTED throughout.


Claude-Session: https://claude.ai/code/session_01FRdfTvgcdBvwkipdfP8qxy
(cherry picked from commit 2ef3c9d02176871577d38f8afd0fa3c32fb92a58)

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-13 02:56:00 -05:00
Christopher Smith
1a90eaf6c6 P25P1: decode unconfirmed data PDUs with a packet data SAP as IP packets (#2506)
PDU header format 0x15 is the generic unconfirmed data header; unconfirmed
multi-block trunking control (UMBTC) is only its trunking control SAP
(61/63) use.  PDUHeaderFactory built a plain PDUHeader for every 0x15
header and PDUMessageFactory sent every such sequence to createUMBTC(), so
outbound unconfirmed IP data (SNDCP RF UNCONFIRMED DATA with SAP 0, 4 or
6) was logged raw as

  PDU HEADER FORMAT:UNCONFIRMED MBTC UNCONFIRMED VENDOR:STANDARD TO LLID:n
  DATA BLOCKS:4 MSG:4100450000...

while the confirmed equivalent (format 0x16) was decoded as IPPKT with
its LRRP / ARS payload.

- PDUHeaderFactory: build a PacketHeader for a 0x15 header whose SAP is
  UNENCRYPTED_USER_DATA, PACKET_DATA or SNDCP_PACKET_DATA_CONTROL.  Other
  SAPs, including trunking control 61/63, keep the plain PDUHeader.
- PDUMessageFactory: route a 0x15 sequence whose header is a PacketHeader
  through createPacketData(); everything else stays on the UMBTC path.

No payload changes are needed: PacketMessage already sizes unconfirmed
data blocks at 12 octets, and the header fields it uses (SAP, pad octet
count, data header offset) sit at the same bit positions in the confirmed
and unconfirmed headers.  The ARS RegistrationAcknowledgement parser only
reads the extension octet, so the 5-octet trailer (0x04 + 32-bit time)
some systems append does not affect it.

Adds PDUMessageFactoryTest, built from two unconfirmed PDUs captured on a
Motorola P25 Phase 1 system (ARS registration acknowledgements to UDP
port 4005, one with the trailer), plus header routing checks for SAP
0/4/6, SAP 61/63 and the confirmed 0x16 header.

Co-authored-by: csmith_ai <carrottop@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-13 02:42:53 -05:00
Christopher Smith
fbc54a38dd LRRP: decode request token values as 7-bit varints and correct request token names (#2507)
Motorola LRRP request tokens that carry an integer (trigger interval
0x31, trigger distance 0x78 and the unknown parameters 0x42 / 0x44)
encode it as a 7-bit variable length integer: each byte holds seven
value bits, most significant group first, and the high bit of a byte
flags a continuation byte.  The parser read a single byte, so a real
triggered location start request

  09 11 22 03 FF FF EE 52 44 64 42 9C 10 62 57 34 31 84 58

was logged as

  ID:15663103 TRIGGER ON GPIO TRIGGER EVERY:132 SECS REQUESTED TOKENS
  [TIME,REQUEST-64,REQUEST-62,HORIZONTAL DIRECTION,TIMESTAMP,
  UNRECOGNIZED(10,44,58,9C)]

and now decodes to

  ID:FFFFEE PERIODIC INTERVAL:600 SECS PARAM-42:3600 PARAM-44:100
  REQUESTED TOKENS [TIME,HORIZONTAL SPEED,HORIZONTAL DIRECTION]

The encoding and the token names follow node-dmr-lib (LRRP.js
parseLRRPInt/toLRRPInt and its quoted TRBONet captures, DMRConst.js)
and Moto.Net (LRRPPacket.cs "using VLQ for most of the fields").

- Add VarInt, a shared 7-bit varint reader, and VarIntToken, a token
  base class whose byte length is measured from the value and bounded
  by the remaining payload.  Use them for 0x31, 0x78, 0x42 and 0x44.
- Add TokenType.fromRequestValue(): 0x34 is the periodic trigger flag
  in a request and the timestamp in a response.  TokenFactory accepts
  the resolved TokenType so the request path is not re-resolved.
- Rename request tokens: 0x31 TRIGGER_INTERVAL (TriggerInterval),
  0x4A TRIGGER_CONDITION (TriggerCondition: 02 emergency, 42 pin
  status change, 50 PTT, 51 PTT emergency), 0x78 TRIGGER_DISTANCE
  (TriggerDistance), 0x62 HORIZONTAL_SPEED.  0x42 and 0x44 become
  REQUEST_PARAMETER_42/44 (RequestParameter) with an optional varint
  value; their meaning is unknown (observed 3600 and 100).  Remove
  TriggerGpio and TriggerOnMove.
- Identity: assemble the request id big-endian (it was accumulated
  LSB-first), render it in hex, and clamp the read to the message.
- RequestedTokens: keep a list that holds only unrecognized ids; add
  getters for the recognized and unrecognized lists.
- Remove leftover debug locals from LRRPPacket.parseTokens().
- Add LRRPPacketTest (both captured bodies plus two copies with a
  corrupted type byte) and VarIntTest.

Built with the project's Gradle build (JDK 27 toolchain, in Docker);
compileJava, compileTestJava and the 9 new tests pass.

Co-authored-by: csmith_ai <carrottop@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-13 02:33:52 -05:00
Denny Sheirer
07b61be257 #2632 Detect on startup if multiple versions of any library are on the classpath and warn user to cleanup and reinstall (#2633)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-13 01:59:38 -05:00
Steve Pinkham
74dc32538f P25 Phase 1: fix typo in HDU Golay word starts (278 -> 378) (#2630)
GOLAY_WORD_STARTS listed 278 where it should list 378. The word at 378 was
never corrected, and the decoder ran a correction over bits 278-295 that
straddle the already corrected words at 270 and 288.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:31:08 -05:00
Steve Pinkham
7fcf035337 edac: correct Golay(24,12) words that are not at the start of the message (#2631)
Golay24.checkAndCorrect indexed the word from bit 0 (getInt(0, 22), xor(12, ...),
flip(index)) and took the parity over the whole message, so it was only right for a
message that is exactly one 24-bit word. P25 TDULC words 2-12 and the DMR voice
superframe words at 24 and 48 were "corrected" in the wrong bits. Decode any other
word in a 24-bit copy and write it back.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:29:50 -05:00
Denny Sheirer
a53f6e9d99 #2628 Remove ThumbDV code support. (#2629)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-12 05:18:52 -05:00
36 changed files with 1665 additions and 177 deletions

View File

@@ -14,6 +14,9 @@ jobs:
steps:
- 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
uses: actions/setup-java@v6
@@ -34,7 +37,7 @@ jobs:
- name: Create Build Info file
run: |
echo -e "ref: $GITHUB_REF \ncommit: $GITHUB_SHA\nbuild: $(date +"%Y-%m-%dT%H:%M:%SZ")" > build/image/build_info.txt
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
- name: Add build artifacts to nightly release
uses: softprops/action-gh-release@v3

View File

@@ -1,6 +1,6 @@
# Publishes a release when a version tag is pushed. The tag has to match
# projectVersion in gradle.properties with a leading 'v': v0.6.2 for a final
# release, or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release.
# Publishes a release when a version tag is pushed: v0.6.2 for a final release,
# or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release. The build derives the
# version from the tag (see build.gradle).
name: Release
on:
@@ -22,15 +22,6 @@ jobs:
# Full history and tags, to find the previous release tag for the release notes
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
# starts from the previous final release, so it covers every alpha and beta in between.
- id: previous
@@ -54,6 +45,17 @@ jobs:
- name: Set up Gradle
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
run: ./gradlew clean build --console=plain

View File

@@ -54,17 +54,19 @@ 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
whenever a version tag is pushed to the repository.
1. Update `projectVersion` in `gradle.properties` to the new version, following the naming convention described in
that file (e.g. `0.6.2-alpha-1`, `0.6.2-beta-1` or `0.6.2`).
2. Commit and push the change to `master`.
3. Create and push a tag that matches the version with a leading `v`:
The version is taken from the tag, so there is no version number to update in the code. Create and push a tag
with a leading `v`, following the naming convention described in `gradle.properties` (e.g. `v0.6.2-alpha-1`,
`v0.6.2-beta-1` or `v0.6.2`):
```
git tag 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:
* Verifies that the tag matches `projectVersion` in `gradle.properties` and fails if it does not.
* Verifies that the version the build derives from git matches the tag 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).
* 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

View File

@@ -52,8 +52,44 @@ plugins {
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 = project.findProperty('projectVersion')
/**
* 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
* 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 {
@@ -202,6 +238,7 @@ jar {
attributes (
'Implementation-Title' : 'sdrtrunk project',
'Implementation-Version' : archiveVersion,
'Git-Describe' : gitDescribe ?: 'unknown',
'Created-By' : "Gradle ${gradle.gradleVersion}",
'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']}",

View File

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

View File

@@ -68,6 +68,29 @@ public class Golay24
*/
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;
int syndrome = getSyndrome(message, startIndex);

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -60,6 +60,7 @@ import io.github.dsheirer.source.tuner.ui.TunerSpectralDisplayManager;
import io.github.dsheirer.spectrum.DisableSpectrumWaterfallMenuItem;
import io.github.dsheirer.spectrum.ShowTunerMenuItem;
import io.github.dsheirer.spectrum.SpectralDisplayPanel;
import io.github.dsheirer.util.DuplicateLibraryDetector;
import io.github.dsheirer.util.ThreadPool;
import io.github.dsheirer.util.TimeStamp;
import io.github.dsheirer.vector.calibrate.CalibrationManager;
@@ -81,6 +82,7 @@ import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.prefs.Preferences;
import javafx.application.Platform;
@@ -153,6 +155,17 @@ public class SDRTrunk implements Listener<TunerEvent>
mApplicationLog = new ApplicationLog(mUserPreferences);
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);
ThreadPool.logSettings();
@@ -253,6 +266,11 @@ public class SDRTrunk implements Listener<TunerEvent>
{
updateTitle(tuner.getPreferredName());
}
if(!duplicateLibraries.isEmpty())
{
showDuplicateLibrariesDialog(duplicateLibraries);
}
}
if(calibrating && !GraphicsEnvironment.isHeadless())
@@ -909,6 +927,28 @@ 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.
*/

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
* LRRP Identity Token
*
* Start Token: 0x22
* Total Length: 6 bytes
* Total Length: 2 bytes plus a 1-4 byte request identifier
*/
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};
/**
* Constructs an instance of a heading token.
* Constructs an instance of an identity token.
*
* @param message containing the heading
* @param message containing the identity
* @param offset to the start of the token
*/
public Identity(CorrectedBinaryMessage message, int offset)
@@ -50,47 +50,47 @@ public class Identity extends Token
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()
{
return getMessage().getSubMessage(getOffset(), getOffset() + 48);
int start = getOffset() + IDENTIFIER_START;
int available = Math.max(0, getMessage().size() - start);
int length = Math.min(getVariableLengthPayloadSize() * 8, available);
return getMessage().getSubMessage(start, start + length);
}
/**
* Extracts the payload contents.
* Request identifier assembled big-endian from the 1-4 payload bytes. Moto.Net (LRRPPacket.cs) and node-dmr-lib
* (LRRP.js) both read the identifier this way.
*
* 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.
* @return identifier, or zero when the payload length is not 1-4 bytes.
*/
public int getID()
{
int length = getVariableLengthPayloadSize();
int payloadOffset = 16;
if(length < 1 || length > 4)
{
return 0;
}
int value = 0;
while(0 < length && length <= 4)
for(int x = 0; x < length; x++)
{
int byteValue = getMessage().getInt(BYTE_VALUE, getOffset() + payloadOffset);
byteValue = Integer.rotateLeft(byteValue, payloadOffset - 16);
value += byteValue;
payloadOffset += 8;
length--;
value = (value << 8) | getMessage().getInt(BYTE_VALUE, getOffset() + IDENTIFIER_START + (x * 8));
}
return value;
}
@Override
public String toString()
{
int id = getID();
if(id != 0)
{
return "ID:" + id;
}
return "ID:" + getTokenContents().toHexString();
//Hexadecimal, as transmitted (e.g. ID:FFFFEE)
return "ID:" + getIdentifierBytes().toHexString();
}
}

View File

@@ -0,0 +1,67 @@
/*
* *****************************************************************************
* 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,12 +63,28 @@ public class RequestedTokens extends Token
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.
*/
public boolean hasRequestedTokens()
{
return !mRequestedTokens.isEmpty();
return !mRequestedTokens.isEmpty() || !mUnrecognizedRequestedTokens.isEmpty();
}
@Override

View File

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

View File

@@ -88,6 +88,11 @@ public abstract class Token
{
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
{
return getTokenType().getLength() + 1; //Fixed Length Payload + Id

View File

@@ -26,10 +26,32 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
*/
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)
{
TokenType tokenType = TokenType.fromValue(id);
return createToken(TokenType.fromValue(id), message, offset, remainingCharacterCount);
}
/**
* 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)
{
case HEADING:
@@ -52,14 +74,17 @@ public class TokenFactory
return new Success(message, offset);
case TIMESTAMP:
return new Timestamp(message, offset);
case TRIGGER_CONDITION:
return new TriggerCondition(message, offset);
case TRIGGER_DISTANCE:
return new TriggerDistance(message, offset);
case TRIGGER_GPIO:
return new TriggerGpio(message, offset);
case TRIGGER_ON_MOVE:
return new TriggerOnMove(message, offset);
return new TriggerDistance(message, offset, remainingCharacterCount);
case TRIGGER_INTERVAL:
return new TriggerInterval(message, offset, remainingCharacterCount);
case TRIGGER_PERIODIC:
return new TriggerPeriodic(message, offset);
case REQUEST_PARAMETER_42:
case REQUEST_PARAMETER_44:
return new RequestParameter(tokenType, message, offset, remainingCharacterCount);
case VERSION:
return new Version(message, offset);
case UNKNOWN_23:

View File

@@ -26,21 +26,37 @@ import java.util.Map;
/**
* LRRP Token Types
*
* Variable length tokens consist of an identifier, a length indicator, and then a sequence of bytes equal in length
* to the length indicator.
* Fixed length tokens consist of an identifier followed by a fixed number of value bytes. Variable length tokens
* consist of an identifier, a length indicator, and then a sequence of bytes equal in length 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
{
//Tokens common to request and response packets
IDENTITY("22", -1, "IDENTITY"),
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"),
VERSION("36", 1, "VERSION"),
RESPONSE("37", -1, "RESPONSE"),
SUCCESS( "38", 0, "SUCCESS"),
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"),
CIRCLE_2D("51", 10, "CIRCLE 2D"),
TIME("52", 0,"TIME"),
@@ -48,37 +64,55 @@ public enum TokenType
CIRCLE_3D("55", 15, "CIRCLE 3D"),
HEADING("56", 1, "HEADING"),
HORIZONTAL_DIRECTION("57", 0,"HORIZONTAL DIRECTION"),
REQUEST_61("61", 1, "REQUEST-61"),
REQUEST_62("62", 0,"REQUEST-62"),
HORIZONTAL_SPEED("62", 0,"HORIZONTAL SPEED"),
REQUEST_64("64", 0,"REQUEST-64"),
POINT_2D("66", 8, "POINT 2D"),
POINT_3D("69", 11, "POINT 3D"),
SPEED("6C", 2, "SPEED"),
REQUEST_73("73", 1, "REQUEST-73"),
TRIGGER_ON_MOVE("78", 1, "TRIGGER ON MOVE"),
REQUESTED_TOKENS("000", 0, "REQUESTED TOKENS"),
UNKNOWN("0",0, "UNKNOWN");
private static final int VARIABLE_LENGTH = -1;
private static final int VARINT_LENGTH = -2;
private String mValue;
private int mLength;
private String mLabel;
private boolean mRequestOnly;
/**
* Tokens that have parameters in request packet messages
*/
private static EnumSet<TokenType> REQUEST_PARAMETER_TOKENS = EnumSet.of(IDENTITY, TRIGGER_DISTANCE, TRIGGER_GPIO,
TRIGGER_ON_MOVE, TRIGGER_PERIODIC, REQUEST_61, REQUEST_73);
private static EnumSet<TokenType> REQUEST_PARAMETER_TOKENS = EnumSet.of(IDENTITY, TRIGGER_PERIODIC,
TRIGGER_INTERVAL, TRIGGER_CONDITION, TRIGGER_DISTANCE, REQUEST_PARAMETER_42, REQUEST_PARAMETER_44,
REQUEST_61, REQUEST_73);
/**
* Constructs an instance
* @param value of the token
* @param length of the token, exclusive of the identifier, where -1 indicates a variable length 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
*/
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;
mLength = length;
mLabel = label;
mRequestOnly = requestOnly;
}
/**
@@ -94,7 +128,23 @@ public enum TokenType
*/
public boolean isVariableLength()
{
return mLength == -1;
return mLength == VARIABLE_LENGTH;
}
/**
* 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;
}
/**
@@ -116,17 +166,26 @@ public enum TokenType
private static final Map<String,TokenType> LOOKUP_MAP = new HashMap<>();
private static final Map<String,TokenType> REQUEST_LOOKUP_MAP = new HashMap<>();
static
{
for(TokenType tokenType: TokenType.values())
{
LOOKUP_MAP.put(tokenType.getValue(), tokenType);
if(tokenType.isRequestOnly())
{
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
* Lookup a token from a 2-character string value, using the response packet meaning of the value.
* @param value that represents the token
* @return matching token or UNKNOWN.
*/
@@ -140,6 +199,21 @@ public enum TokenType
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
public String toString()
{

View File

@@ -0,0 +1,135 @@
/*
* *****************************************************************************
* 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,21 +24,23 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* LRRP Trigger Distance Token
*
* Total Length: 2 bytes
* Start Token: 0x78
* 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 Token
public class TriggerDistance extends VarIntToken
{
private static final int[] DISTANCE = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
/**
* Constructs an instance of a heading token.
* Constructs an instance of a trigger distance token.
*
* @param message containing the heading
* @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 TriggerDistance(CorrectedBinaryMessage message, int offset)
public TriggerDistance(CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
{
super(message, offset);
super(message, offset, remainingCharacterCount);
}
@Override
@@ -52,12 +54,12 @@ public class TriggerDistance extends Token
*/
public int getDistance()
{
return getMessage().getInt(DISTANCE, getOffset());
return getValue();
}
@Override
public String toString()
{
return "TRIGGER DISTANCE:" + getDistance();
return "TRIGGER DISTANCE:" + getDistance() + " METERS";
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,38 +16,49 @@
* 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 GPIO Token
* LRRP Trigger Interval Token
*
* Total Length: 1 byte
* Start Token: 0x31
* 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 TriggerGpio extends Token
public class TriggerInterval extends VarIntToken
{
/**
* Constructs an instance of a heading token.
* Constructs an instance of a trigger interval token.
*
* @param message containing the heading
* @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 TriggerGpio(CorrectedBinaryMessage message, int offset)
public TriggerInterval(CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
{
super(message, offset);
super(message, offset, remainingCharacterCount);
}
@Override
public TokenType getTokenType()
{
return TokenType.TRIGGER_GPIO;
return TokenType.TRIGGER_INTERVAL;
}
/**
* Interval in seconds
*/
public int getSeconds()
{
return getValue();
}
@Override
public String toString()
{
return "TRIGGER ON GPIO";
return "INTERVAL:" + getSeconds() + " SECS";
}
}

View File

@@ -1,53 +0,0 @@
/*
* *****************************************************************************
* 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,18 +22,20 @@ package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* LRRP Trigger Periodic Token
* LRRP Periodic Trigger Token (request packets)
*
* Total Length: 2 bytes
* Start Token: 0x34 (in a response packet 0x34 is the Timestamp token)
* 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
{
private static final int[] SECONDS = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
/**
* Constructs an instance of a heading token.
* Constructs an instance of a periodic trigger token.
*
* @param message containing the heading
* @param message containing the token
* @param offset to the start of the token
*/
public TriggerPeriodic(CorrectedBinaryMessage message, int offset)
@@ -44,20 +46,12 @@ public class TriggerPeriodic extends Token
@Override
public TokenType getTokenType()
{
return TokenType.TRIGGER_DISTANCE;
}
/**
* Seconds for trigger
*/
public int getSeconds()
{
return getMessage().getInt(SECONDS, getOffset());
return TokenType.TRIGGER_PERIODIC;
}
@Override
public String toString()
{
return "TRIGGER EVERY:" + getSeconds() + " SECS";
return "PERIODIC";
}
}

View File

@@ -0,0 +1,105 @@
/*
* *****************************************************************************
* 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

@@ -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.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,6 +192,14 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
private final P25TrafficChannelManager mTrafficChannelManager;
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.
* @param channel with configuration details
@@ -419,6 +427,12 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
if(lcw instanceof IServiceOptionsProvider sop)
{
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())
{
@@ -838,6 +852,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
Identifier<?> radio = getIdentifierCollection().getFromIdentifier();
mCallEncryptedByAlgorithm = headerData.isEncryptedAudio();
mTrafficChannelManager.processP1TrafficCallStart(getCurrentFrequency(), talkgroup, radio,
headerData.getEncryptionKey(), mCurrentServiceOptions, getCurrentChannel(), message.getTimestamp());
@@ -880,6 +895,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
if(esp != null && esp.isValid())
{
getIdentifierCollection().update(esp.getIdentifiers());
mCallEncryptedByAlgorithm = esp.isEncryptedAudio();
if(esp.isEncryptedAudio())
{
@@ -903,6 +919,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
*/
private void processTDU(P25P1Message message)
{
mCallEncryptedByAlgorithm = null;
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDU:" + message);
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
}
@@ -921,6 +938,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
if(lcw != null && lcw.isValid())
{
mCallEncryptedByAlgorithm = null;
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDULC:" + message);
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
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 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, 278, 396, 414, 432, 450, 468, 486, 504, 522, 540, 558, 576, 594, 612, 630};
270, 288, 306, 324, 342, 360, 378, 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_1 = {18, 19, 20, 21, 22, 23};

View File

@@ -24,6 +24,7 @@ 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.response.ResponseHeader;
import io.github.dsheirer.module.decode.p25.reference.PDUFormat;
import io.github.dsheirer.module.decode.p25.reference.ServiceAccessPoint;
public class PDUHeaderFactory
{
@@ -41,8 +42,41 @@ public class PDUHeaderFactory
case RESPONSE_PACKET_HEADER_FORMAT:
return new ResponseHeader(correctedBinaryMessage, passesCRC);
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:
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,6 +173,13 @@ public class PDUMessageFactory
case RESPONSE_PACKET_HEADER_FORMAT:
return new ResponseMessage(pduSequence, nac, timestamp);
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);
default:
return new PDUSequenceMessage(pduSequence, nac, timestamp);
@@ -229,7 +236,7 @@ public class PDUMessageFactory
}
/**
* Creates a packet data message
* Creates a packet data message from a confirmed or unconfirmed packet data sequence.
*
* @param pduSequence containing a packet data header and zero or more data blocks
* @param nac network access code

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
/*
* *****************************************************************************
* 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

@@ -0,0 +1,85 @@
/*
* *****************************************************************************
* 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

@@ -0,0 +1,216 @@
/*
* *****************************************************************************
* 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

@@ -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.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

@@ -0,0 +1,229 @@
/*
* *****************************************************************************
* 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,78 @@
/*
* *****************************************************************************
* 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());
}
}