mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-15 10:07:26 -05:00
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:
committed by
GitHub
parent
07b61be257
commit
fbc54a38dd
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user