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