*/
public class LLPParser {
private static final Logger logger = LoggerFactory.getLogger(LLPParser.class);
@@ -35,14 +23,18 @@ public class LLPParser {
private static final byte MAGIC_1 = (byte) 0xAA;
private static final byte MAGIC_2 = (byte) 0x55;
private static final long DEFAULT_TIMEOUT_MS = 2000;
- private final byte[] headerBuf = new byte[7];
+
+ private final byte[] headerBuf = new byte[8];
private final byte[] payload;
private final long timeoutMs;
private final Queue frameQueue = new ConcurrentLinkedQueue<>();
private final Statistics statistics = new Statistics();
// Listeners
private final Queue listeners = new ConcurrentLinkedQueue<>();
+
private State state = State.WAIT_MAGIC1;
+ private boolean escapePending = false;
+
private int payloadLen = 0;
private int payloadIdx = 0;
private int crcReceived = 0;
@@ -85,10 +77,7 @@ public LLPParser(int maxPayload, long timeoutMs) {
}
/**
- * Processes a single byte from the input stream.
- *
- *
If a complete and valid frame is reconstructed, it is returned.
- * Otherwise, {@code null} is returned.
+ * Processes a single byte from the input stream, resolving byte stuffing transparently.
*
* @param b incoming byte
* @return a complete {@link LLPFrame} or {@code null} if not complete
@@ -100,12 +89,57 @@ public LLPFrame processByte(byte b) {
logger.warn("Frame timeout - resetting parser");
statistics.recordTimeout();
reset();
- notifyError((byte) 0x04); // LLP_ERR_TIMEOUT
+ notifyError(LLPErrorCode.TIMEOUT);
+
+ // Allow a magic byte to restart the sequence immediately
+ if (b == MAGIC_1) {
+ state = State.WAIT_MAGIC2;
+ }
return null;
}
}
lastByteTime = System.currentTimeMillis();
+ // ================= ESCAPE / STUFFING HANDLING =================
+ // Only evaluate escape sequences if we are inside a frame
+ if (state != State.WAIT_MAGIC1 && state != State.WAIT_MAGIC2) {
+ if (escapePending) {
+ escapePending = false;
+
+ if (b == MAGIC_2) {
+ // OVERLAPPED FRAME DETECTED! (0xAA 0x55 sequence found in data)
+ logger.warn("Overlapped frame detected, aborting current and syncing new frame");
+ statistics.recordError();
+ notifyError(LLPErrorCode.SYNC_ERROR);
+
+ crcCalculated = 0xFFFF;
+ crcCalculated = CRC16CCITT.updateCRC(crcCalculated, MAGIC_1);
+ crcCalculated = CRC16CCITT.updateCRC(crcCalculated, MAGIC_2);
+ headerBuf[0] = MAGIC_1;
+ headerBuf[1] = MAGIC_2;
+ state = State.READ_TYPE;
+ return null; // Consumed as MAGIC_2
+
+ } else if (b == 0x00) {
+ // Escaped data byte recovered. Restore to MAGIC_1.
+ b = MAGIC_1;
+ } else {
+ // Invalid sequence
+ logger.error("Invalid sync sequence: 0xAA followed by 0x{}", Integer.toHexString(b & 0xFF));
+ statistics.recordError();
+ reset();
+ notifyError(LLPErrorCode.SYNC_ERROR);
+ return null;
+ }
+ } else if (b == MAGIC_1) {
+ // Suspends processing to wait for the next byte to clarify the sequence
+ escapePending = true;
+ return null;
+ }
+ }
+ // ==============================================================
+
+ // Standard State Machine (Operates on unstuffed bytes)
switch (state) {
case WAIT_MAGIC1:
if (b == MAGIC_1) {
@@ -120,7 +154,7 @@ public LLPFrame processByte(byte b) {
crcCalculated = 0xFFFF;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, MAGIC_1);
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, MAGIC_2);
- state = State.READ_TYPE;
+ state = State.READ_VERSION;
} else if (b == MAGIC_1) {
// RF robustness: another MAGIC_1 received
state = State.WAIT_MAGIC2;
@@ -129,41 +163,53 @@ public LLPFrame processByte(byte b) {
}
break;
- case READ_TYPE:
+ case READ_VERSION:
headerBuf[2] = b;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
+
+ if (b != LLP.PROTOCOL_VERSION) {
+ logger.warn("Different protocol version: received={}, expected={}",
+ b, LLP.PROTOCOL_VERSION);
+ }
+
+ state = State.READ_TYPE;
+ break;
+
+ case READ_TYPE:
+ headerBuf[3] = b;
+ crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
state = State.READ_ID_L;
break;
case READ_ID_L:
- headerBuf[3] = b;
+ headerBuf[4] = b;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
state = State.READ_ID_H;
break;
case READ_ID_H:
- headerBuf[4] = b;
+ headerBuf[5] = b;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
state = State.READ_LEN_L;
break;
case READ_LEN_L:
- headerBuf[5] = b;
+ headerBuf[6] = b;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
state = State.READ_LEN_H;
break;
case READ_LEN_H:
- headerBuf[6] = b;
+ headerBuf[7] = b;
crcCalculated = CRC16CCITT.updateCRC(crcCalculated, b);
- payloadLen = (headerBuf[5] & 0xFF) | ((headerBuf[6] & 0xFF) << 8);
+ payloadLen = (headerBuf[6] & 0xFF) | ((headerBuf[7] & 0xFF) << 8);
if (payloadLen > payload.length) {
logger.error("Payload length {} exceeds maximum {}", payloadLen, payload.length);
statistics.recordError();
reset();
- notifyError((byte) 0x03); // LLP_ERR_PAYLOAD_LEN
+ notifyError(LLPErrorCode.PAYLOAD_LEN_INVALID);
return null;
}
@@ -200,7 +246,7 @@ public LLPFrame processByte(byte b) {
Integer.toHexString(crcCalculated));
statistics.recordError();
reset();
- notifyError((byte) 0x01); // LLP_ERR_CHECKSUM
+ notifyError(LLPErrorCode.CHECKSUM_INVALID);
return null;
}
@@ -226,12 +272,13 @@ public List processBytes(byte[] data) {
}
private LLPFrame createFrame() {
- byte type = headerBuf[2];
- int id = (headerBuf[3] & 0xFF) | ((headerBuf[4] & 0xFF) << 8);
+ byte version = headerBuf[2];
+ byte type = headerBuf[3];
+ int id = (headerBuf[4] & 0xFF) | ((headerBuf[5] & 0xFF) << 8);
byte[] payloadCopy = new byte[payloadLen];
System.arraycopy(payload, 0, payloadCopy, 0, payloadLen);
- return new LLPFrame(type, id, payloadCopy, crcCalculated);
+ return new LLPFrame(type, id, version, payloadCopy, crcCalculated);
}
/**
@@ -241,6 +288,7 @@ private void reset() {
state = State.WAIT_MAGIC1;
payloadIdx = 0;
crcCalculated = 0xFFFF;
+ escapePending = false; // Reset escape flag
}
/**
@@ -269,7 +317,7 @@ private void notifySuccess(LLPFrame frame) {
}
}
- private void notifyError(byte errorCode) {
+ private void notifyError(LLPErrorCode errorCode) {
for (LLPFrameListener listener : listeners) {
try {
listener.onFrameError(errorCode);
@@ -279,6 +327,8 @@ private void notifyError(byte errorCode) {
}
}
+ // ============= GETTERS =============
+
/**
* Returns parsed frames queue.
*/
@@ -293,10 +343,8 @@ public Statistics getStatistics() {
return statistics;
}
- // ============= GETTERS =============
-
private enum State {
- WAIT_MAGIC1, WAIT_MAGIC2, READ_TYPE, READ_ID_L, READ_ID_H,
+ WAIT_MAGIC1, WAIT_MAGIC2, READ_VERSION, READ_TYPE, READ_ID_L, READ_ID_H,
READ_LEN_L, READ_LEN_H, READ_PAYLOAD, READ_CRC_L, READ_CRC_H
}
@@ -313,6 +361,6 @@ public interface LLPFrameListener {
/**
* Called when a frame error occurs.
*/
- void onFrameError(byte errorCode);
+ void onFrameError(LLPErrorCode errorCode);
}
}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/LLPFrameBuilderTest.java b/src/test/java/com/flamingo/comm/llp/LLPFrameBuilderTest.java
index f013f84..20b90f9 100644
--- a/src/test/java/com/flamingo/comm/llp/LLPFrameBuilderTest.java
+++ b/src/test/java/com/flamingo/comm/llp/LLPFrameBuilderTest.java
@@ -5,6 +5,12 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import java.io.ByteArrayOutputStream;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Locale;
+import java.util.Random;
+
import static org.junit.jupiter.api.Assertions.*;
class LLPFrameBuilderTest {
@@ -16,7 +22,7 @@ void testBuildSimpleFrame() {
assertNotNull(frame);
assertEquals((byte) 0xAA, frame[0]);
assertEquals((byte) 0x55, frame[1]);
- assertEquals(frame[2], LLPMessageType.PING.value());
+ assertEquals(frame[3], LLPMessageType.PING.value());
}
@Test
@@ -29,19 +35,21 @@ void testBuildFrameStructure() {
assertEquals((byte) 0xAA, frame[0]);
assertEquals((byte) 0x55, frame[1]);
- assertEquals(LLPMessageType.DATA.value(), frame[2]);
+ assertEquals(LLP.PROTOCOL_VERSION, frame[2]);
+
+ assertEquals(LLPMessageType.DATA.value(), frame[3]);
// ID little endian
- assertEquals((byte) 0x34, frame[3]);
- assertEquals((byte) 0x12, frame[4]);
+ assertEquals((byte) 0x34, frame[4]);
+ assertEquals((byte) 0x12, frame[5]);
// Length
- assertEquals((byte) 0x02, frame[5]);
- assertEquals((byte) 0x00, frame[6]);
+ assertEquals((byte) 0x02, frame[6]);
+ assertEquals((byte) 0x00, frame[7]);
// Payload
- assertEquals(0x01, frame[7]);
- assertEquals(0x02, frame[8]);
+ assertEquals(0x01, frame[8]);
+ assertEquals(0x02, frame[9]);
}
@Test
@@ -51,7 +59,7 @@ void testBuildDataFrame() {
assertNotNull(frame);
assertTrue(frame.length > data.length); // Overhead included
- assertEquals(frame[2], LLPMessageType.DATA.value());
+ assertEquals(frame[3], LLPMessageType.DATA.value());
}
@Test
@@ -103,11 +111,11 @@ void testNullPayload() {
byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, null);
// Length = 0
- assertEquals(0, frame[5]);
assertEquals(0, frame[6]);
+ assertEquals(0, frame[7]);
- // Frame mínimo: 7 header + 2 CRC
- assertEquals(9, frame.length);
+ // Minimal Frame: 8 header + 2 CRC
+ assertEquals(10, frame.length);
}
@Test
@@ -143,4 +151,150 @@ void testRandomPayload() {
assertNotNull(frame);
}
+
+ @Test
+ void testBuildVersion() {
+ byte[] payload = new byte[]{
+ 0x11, (byte) 0xAA, 0x22, (byte) 0xAA, 0x33
+ };
+ byte[] frame = LLP.buildData(1, payload);
+
+ byte frameVersion = frame[2];
+ assertEquals(LLP.PROTOCOL_VERSION, frameVersion);
+ }
+
+ @Test
+ void testStuffingSingleAA() {
+ byte[] payload = {(byte) 0xAA};
+
+ byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
+
+ // Search for sequence AA 00 (stuffed)
+ boolean found = false;
+ for (int i = 2; i < frame.length - 1; i++) {
+ if ((frame[i] == (byte) 0xAA) && (frame[i + 1] == 0x00)) {
+ found = true;
+ break;
+ }
+ }
+
+ assertTrue(found, "Stuffed sequence AA 00 not found");
+ }
+
+ @Test
+ void testStuffingMultipleAA() {
+ byte[] payload = {(byte) 0xAA, (byte) 0xAA, (byte) 0xAA};
+
+ byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
+
+ int countAA = 0;
+ int countStuffed = 0;
+
+ for (int i = 2; i < frame.length; i++) {
+ if (frame[i] == (byte) 0xAA) {
+ countAA++;
+ if (i + 1 < frame.length && frame[i + 1] == 0x00) {
+ countStuffed++;
+ }
+ }
+ }
+
+ assertEquals(countAA, countStuffed, "Every AA must be stuffed");
+ }
+
+ @Test
+ void testNoFalseHeaderInsideFrame() {
+ byte[] payload = new byte[100];
+ new Random().nextBytes(payload);
+
+ byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
+
+ for (int i = 2; i < frame.length - 1; i++) {
+ if (frame[i] == (byte) 0xAA && frame[i + 1] == (byte) 0x55) {
+ fail("Found forbidden sequence AA 55 inside stuffed frame");
+ }
+ }
+ }
+
+ @Test
+ void testConsistency() {
+ for (int i = 0; i < 10000; i++) {
+ byte[] payload = new byte[100];
+ new Random().nextBytes(payload);
+
+ byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
+ List result = LLP.newParser().processBytes(frame);
+
+ if (result.isEmpty()) {
+ throw new RuntimeException("The generated frame could not be parsed again: " + HexFormat.of().formatHex(frame).toUpperCase(Locale.ROOT));
+ }
+
+ assertEquals(1, result.size(), "The number of parsed frames must be 1");
+ assertEquals(LLPMessageType.DATA.value(), result.getFirst().messageType().orElseThrow(() -> new IllegalArgumentException("The message type does not match the original frame: " + HexFormat.of().formatHex(frame).toUpperCase(Locale.ROOT))).value());
+
+ String originalHexPayload = HexFormat.of().formatHex(payload).toUpperCase(Locale.ROOT);
+ String parsedHexPayload = HexFormat.of().formatHex(result.getFirst().payload()).toUpperCase(Locale.ROOT);
+ assertEquals(originalHexPayload, parsedHexPayload);
+ }
+ }
+
+ @Test
+ void testBuildFrameWithStuffedCRCAndPayload() {
+ byte[] payload = HexFormat.of().parseHex("bf4008211f8191eca98c2ee3d01985d9858689fd571a2df4df41545eba69838d12da79b79de4f425a4596e5dd8f0de04fee43a0d71b4f0fdebce1274a66cac08459a1159f395b642afabe6bd3684193c5d5fbe1560428c6527aa21aa53233dba8932467f");
+ byte[] frame = LLP.buildFrame(LLPMessageType.DATA.value(), 1, payload);
+
+ // Check that there is stuffing in the frame
+ boolean foundStuff = false;
+ for (int i = 2; i < frame.length - 1; i++) {
+ if (frame[i] == (byte) 0xAA && frame[i + 1] == 0x00) {
+ foundStuff = true;
+ break;
+ }
+ }
+
+ assertTrue(foundStuff, "Expected stuffed bytes not found");
+
+ // Destuff
+ byte[] unstuffed = destuff(frame);
+
+ // Validate the CRC manually
+ int crcExpected = CRC16CCITT.calculate(unstuffed, 0, unstuffed.length - 2);
+
+ int crcFrame =
+ (unstuffed[unstuffed.length - 2] & 0xFF) |
+ ((unstuffed[unstuffed.length - 1] & 0xFF) << 8);
+
+ assertEquals(crcExpected, crcFrame);
+
+ // Verify that there are NO fake headers
+ for (int i = 2; i < frame.length - 1; i++) {
+ assertFalse(
+ frame[i] == (byte) 0xAA && frame[i + 1] == (byte) 0x55,
+ "Found forbidden AA55 sequence"
+ );
+ }
+ }
+
+ private byte[] destuff(byte[] frame) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream(frame.length);
+
+ // Copy MAGIC
+ out.write(frame, 0, 2);
+
+ for (int i = 2; i < frame.length; i++) {
+ byte b = frame[i];
+
+ if (b == (byte) 0xAA) {
+ if (i + 1 < frame.length && frame[i + 1] == 0x00) {
+ out.write(0xAA);
+ i++;
+ continue;
+ }
+ }
+
+ out.write(b);
+ }
+
+ return out.toByteArray();
+ }
}
\ No newline at end of file
diff --git a/src/test/java/com/flamingo/comm/llp/LLPParserTest.java b/src/test/java/com/flamingo/comm/llp/LLPParserTest.java
index a83f099..2f03825 100644
--- a/src/test/java/com/flamingo/comm/llp/LLPParserTest.java
+++ b/src/test/java/com/flamingo/comm/llp/LLPParserTest.java
@@ -3,6 +3,8 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.util.Random;
+
import static org.junit.jupiter.api.Assertions.*;
class LLPParserTest {
@@ -96,9 +98,9 @@ void testStatistics() {
}
}
- assertEquals(parser.getStatistics().getFramesOk(), 3);
- assertEquals(parser.getStatistics().getTotalFrames(), 3);
- assertEquals(parser.getStatistics().getSuccessRate(), 100.0);
+ assertEquals(3, parser.getStatistics().getFramesOk());
+ assertEquals(3, parser.getStatistics().getTotalFrames());
+ assertEquals(100.0, parser.getStatistics().getSuccessRate());
}
@Test
@@ -199,4 +201,121 @@ void testMaxPayload() {
assertNotNull(result);
assertEquals(payload.length, result.payloadLength());
}
+
+ @Test
+ void testParseStuffedPayload() {
+ byte[] payload = new byte[]{
+ 0x11, (byte) 0xAA, 0x22, (byte) 0xAA, 0x33
+ };
+
+ byte[] frame = LLP.buildData(1, payload);
+
+ LLPFrame result = null;
+
+ for (byte b : frame) {
+ LLPFrame f = parser.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+ assertArrayEquals(payload, result.payload());
+ }
+
+ @Test
+ void testStuffingAcrossEntireFrame() {
+ byte type = (byte) 0xAA; // force stuffing
+ int id = 0xAA55;
+ byte[] payload = new byte[]{(byte) 0xAA, (byte) 0xAA};
+
+ byte[] frame = LLP.buildFrame(type, id, payload);
+
+ LLPFrame result = null;
+
+ for (byte b : frame) {
+ LLPFrame f = parser.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+ assertEquals(type, result.type());
+ assertEquals(id, result.id());
+ assertArrayEquals(payload, result.payload());
+ }
+
+ @Test
+ void testDoubleAASequence() {
+ byte[] payload = new byte[]{
+ (byte) 0xAA, (byte) 0xAA, (byte) 0xAA
+ };
+
+ byte[] frame = LLP.buildData(1, payload);
+
+ LLPFrame result = null;
+
+ for (byte b : frame) {
+ LLPFrame f = parser.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+ assertArrayEquals(payload, result.payload());
+ }
+
+ @Test
+ void testFakeHeaderInsidePayload() {
+ byte[] payload = new byte[]{
+ 0x10,
+ (byte) 0xAA, 0x55, // It looks like a header, but it must be escaped
+ 0x20
+ };
+
+ byte[] frame = LLP.buildData(1, payload);
+
+ LLPFrame result = null;
+
+ for (byte b : frame) {
+ LLPFrame f = parser.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+ assertArrayEquals(payload, result.payload());
+ }
+
+ @Test
+ void testInvalidEscapeSequence() {
+ byte[] frame = LLP.buildPing(1);
+
+ // We injected an invalid sequence: AA 99
+ frame[5] = (byte) 0xAA;
+ frame[6] = (byte) 0x99;
+
+ for (byte b : frame) {
+ parser.processByte(b);
+ }
+
+ assertTrue(parser.getStatistics().getFramesError() > 0);
+ }
+
+ @Test
+ void testRandomFramesWithStuffing() {
+ Random random = new Random();
+
+ for (int i = 0; i < 1000; i++) {
+ byte[] payload = new byte[50];
+ random.nextBytes(payload);
+
+ byte[] frame = LLP.buildData(i, payload);
+
+ LLPFrame result = null;
+
+ for (byte b : frame) {
+ LLPFrame f = parser.processByte(b);
+ if (f != null) result = f;
+ }
+
+ assertNotNull(result);
+ assertArrayEquals(payload, result.payload());
+ }
+ }
}
\ No newline at end of file
From 94571e5e14bb875de3926f130918b83cdd5b7d2e Mon Sep 17 00:00:00 2001
From: Enzo Sanchez
Date: Fri, 3 Apr 2026 18:00:27 -0300
Subject: [PATCH 3/3] Actualizado pom con nueva version de libreria
---
pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pom.xml b/pom.xml
index 141291a..180c21d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
com.flamingollp-protocol
- 1.0.0
+ 2.0.0jarLLP Protocol - Java