diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/LRRPPacket.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/LRRPPacket.java
index b3334b26..00c9a43d 100644
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/LRRPPacket.java
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/LRRPPacket.java
@@ -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);
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Identity.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Identity.java
index b9bdcd6d..9365c8b7 100644
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Identity.java
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Identity.java
@@ -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();
}
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/RequestParameter.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/RequestParameter.java
new file mode 100644
index 00000000..fbb744f4
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/RequestParameter.java
@@ -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
+ * ****************************************************************************
+ */
+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();
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/RequestedTokens.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/RequestedTokens.java
index 50dc6d10..2f024257 100644
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/RequestedTokens.java
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/RequestedTokens.java
@@ -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 getRequestedTokens()
+ {
+ return mRequestedTokens;
+ }
+
+ /**
+ * Unrecognized requested token identifiers (hexadecimal), sorted.
+ */
+ public List getUnrecognizedRequestedTokens()
+ {
+ return mUnrecognizedRequestedTokens;
+ }
+
/**
* Indicates if this object has requested tokens.
*/
public boolean hasRequestedTokens()
{
- return !mRequestedTokens.isEmpty();
+ return !mRequestedTokens.isEmpty() || !mUnrecognizedRequestedTokens.isEmpty();
}
@Override
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Timestamp.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Timestamp.java
index 0a0558c3..c95a896f 100644
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Timestamp.java
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Timestamp.java
@@ -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
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Token.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Token.java
index fe42263d..3c5b4190 100644
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Token.java
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/Token.java
@@ -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
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TokenFactory.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TokenFactory.java
index 6548d120..ad20538c 100644
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TokenFactory.java
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TokenFactory.java
@@ -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:
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TokenType.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TokenType.java
index f0cffb09..76f90415 100644
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TokenType.java
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TokenType.java
@@ -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 REQUEST_PARAMETER_TOKENS = EnumSet.of(IDENTITY, TRIGGER_DISTANCE, TRIGGER_GPIO,
- TRIGGER_ON_MOVE, TRIGGER_PERIODIC, REQUEST_61, REQUEST_73);
+ private static EnumSet 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 LOOKUP_MAP = new HashMap<>();
+ private static final Map 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()
{
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerCondition.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerCondition.java
new file mode 100644
index 00000000..ce33d54f
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerCondition.java
@@ -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
+ * ****************************************************************************
+ */
+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;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerDistance.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerDistance.java
index 3a73dfe8..08e27918 100644
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerDistance.java
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerDistance.java
@@ -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";
}
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerGpio.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerInterval.java
similarity index 55%
rename from src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerGpio.java
rename to src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerInterval.java
index 3ac40942..2a15d754 100644
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerGpio.java
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerInterval.java
@@ -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
* ****************************************************************************
*/
-
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";
}
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerOnMove.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerOnMove.java
deleted file mode 100644
index e8a452a7..00000000
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerOnMove.java
+++ /dev/null
@@ -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
- * ****************************************************************************
- */
-
-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";
- }
-}
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerPeriodic.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerPeriodic.java
index 7a8c6d3b..2ed41159 100644
--- a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerPeriodic.java
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/TriggerPeriodic.java
@@ -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";
}
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/VarInt.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/VarInt.java
new file mode 100644
index 00000000..0dd22060
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/VarInt.java
@@ -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
+ * ****************************************************************************
+ */
+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;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/VarIntToken.java b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/VarIntToken.java
new file mode 100644
index 00000000..02d0deac
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/VarIntToken.java
@@ -0,0 +1,88 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.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();
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/LRRPPacketTest.java b/src/test/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/LRRPPacketTest.java
new file mode 100644
index 00000000..1e0ceae1
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/LRRPPacketTest.java
@@ -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
+ * ****************************************************************************
+ */
+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 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 getToken(LRRPPacket packet, Class 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());
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/VarIntTest.java b/src/test/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/VarIntTest.java
new file mode 100644
index 00000000..e42a240e
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/ip/mototrbo/lrrp/token/VarIntTest.java
@@ -0,0 +1,88 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+package io.github.dsheirer.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));
+ }
+}