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>
This commit is contained in:
Christopher Smith
2026-11-13 01:33:52 -06:00
committed by GitHub
parent 07b61be257
commit fbc54a38dd
17 changed files with 914 additions and 143 deletions

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

@@ -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));
}
}