diff --git a/README.md b/README.md index 811bd62..284e51b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Agregá la dependencia en tu `pom.xml`: com.flamingo llp-protocol - 1.0.0 + 2.0.0 ``` @@ -118,8 +118,8 @@ parser.addListener(new LLPParser.LLPFrameListener() { } @Override - public void onFrameError(byte errorCode) { - System.out.println("Error: 0x" + Integer.toHexString(errorCode)); + public void onFrameError(LLPErrorCode errorCode) { + System.out.println("Error: 0x" + Integer.toHexString(errorCode.code())); } }); @@ -133,7 +133,7 @@ while ((data = in.read()) != -1) { if (frame != null) { // Procesar frame completo } - } catch (LLPException e) { + } catch (Exception e) { System.err.println("Error: " + e.getMessage()); } } @@ -148,14 +148,15 @@ outputStream.write(frame); ## 📦 Estructura del Frame -| Campo | Tamaño | Descripción | -| ------- | ------- | ----------------------- | -| Magic | 2 bytes | 0xAA 0x55 | -| Type | 1 byte | Tipo de mensaje | -| ID | 2 bytes | ID de transacción (LE) | -| Length | 2 bytes | Tamaño del payload (LE) | -| Payload | N bytes | Datos | -| CRC16 | 2 bytes | CRC-CCITT (LE) | +| Campo | Tamaño | Descripción | +|---------|---------|---------------------------| +| Magic | 2 bytes | 0xAA 0x55 | +| Version | 1 bytes | Version del protocolo LLP | +| Type | 1 byte | Tipo de mensaje | +| ID | 2 bytes | ID de transacción (LE) | +| Length | 2 bytes | Tamaño del payload (LE) | +| Payload | N bytes | Datos | +| CRC16 | 2 bytes | CRC-CCITT (LE) | --- diff --git a/examples/llp-serial-test/pom.xml b/examples/llp-serial-test/pom.xml new file mode 100644 index 0000000..a71b389 --- /dev/null +++ b/examples/llp-serial-test/pom.xml @@ -0,0 +1,47 @@ + + 4.0.0 + com.flamingo.llp.test + llp-serial-test + jar + 1.0-SNAPSHOT + llp-serial-test + http://maven.apache.org + + 21 + 21 + + 21 + + + + + + com.flamingo + llp-protocol + 1.0.0 + + + + + com.fazecast + jSerialComm + 2.10.4 + + + + org.slf4j + slf4j-simple + 2.0.7 + + + + + + + github + GitHub Packages + https://maven.pkg.github.com/EnzoLeonel/llp-protocol-java + + + diff --git a/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/MonitorEventBus.java b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/MonitorEventBus.java new file mode 100644 index 0000000..6a05e68 --- /dev/null +++ b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/MonitorEventBus.java @@ -0,0 +1,23 @@ +package com.flamingo.llp.monitor; + +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +public class MonitorEventBus { + + private final CopyOnWriteArrayList> listeners = new CopyOnWriteArrayList<>(); + + public void publish(Object event) { + for (Consumer l : listeners) { + l.accept(event); + } + } + + public void subscribe(Consumer listener) { + listeners.add(listener); + } + + public void unsubscribe(Consumer listener) { + listeners.remove(listener); + } +} \ No newline at end of file diff --git a/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/MonitorServer.java b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/MonitorServer.java new file mode 100644 index 0000000..43cc979 --- /dev/null +++ b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/MonitorServer.java @@ -0,0 +1,56 @@ +package com.flamingo.llp.monitor; + +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.net.InetSocketAddress; + +public class MonitorServer { + + private final HttpServer server; + + public MonitorServer(int port) throws IOException { + server = HttpServer.create(new InetSocketAddress(port), 0); + + server.createContext("/", exchange -> { + + try (var is = getClass().getClassLoader() + .getResourceAsStream("monitor/monitor.html")) { + + if (is == null) { + String msg = "monitor.html not found"; + exchange.sendResponseHeaders(404, msg.length()); + exchange.getResponseBody().write(msg.getBytes()); + exchange.close(); + return; + } + + byte[] html = is.readAllBytes(); + + exchange.getResponseHeaders().add("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(200, html.length); + exchange.getResponseBody().write(html); + + } catch (Exception e) { + String msg = "Internal error: " + e.getMessage(); + exchange.sendResponseHeaders(500, msg.length()); + exchange.getResponseBody().write(msg.getBytes()); + } finally { + exchange.close(); + } + }); + } + + public void start() { + server.start(); + System.out.println("🌐 Monitor running at http://localhost:" + server.getAddress().getPort()); + } + + public HttpServer getServer() { + return server; + } + + public void stop() { + server.stop(0); + } +} \ No newline at end of file diff --git a/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/SSEHandler.java b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/SSEHandler.java new file mode 100644 index 0000000..a63f4c9 --- /dev/null +++ b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/SSEHandler.java @@ -0,0 +1,31 @@ +package com.flamingo.llp.monitor; + +import com.sun.net.httpserver.HttpExchange; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.concurrent.CopyOnWriteArrayList; + +public class SSEHandler { + + private final CopyOnWriteArrayList clients = new CopyOnWriteArrayList<>(); + + public void handle(HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, 0); + + OutputStream os = exchange.getResponseBody(); + clients.add(os); + } + + public void broadcast(String json) { + for (OutputStream client : clients) { + try { + client.write(("data: " + json + "\n\n").getBytes()); + client.flush(); + } catch (IOException e) { + clients.remove(client); + } + } + } +} diff --git a/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/model/Direction.java b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/model/Direction.java new file mode 100644 index 0000000..8ee5044 --- /dev/null +++ b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/model/Direction.java @@ -0,0 +1,6 @@ +package com.flamingo.llp.monitor.model; + +public enum Direction { + RX, + TX +} \ No newline at end of file diff --git a/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/model/FrameEvent.java b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/model/FrameEvent.java new file mode 100644 index 0000000..d9504a9 --- /dev/null +++ b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/model/FrameEvent.java @@ -0,0 +1,20 @@ +package com.flamingo.llp.monitor.model; + +import com.flamingo.comm.llp.LLPFrame; + +public class FrameEvent { + + private final Direction direction; + private final LLPFrame frame; + private final long timestamp; + + public FrameEvent(Direction direction, LLPFrame frame) { + this.direction = direction; + this.frame = frame; + this.timestamp = System.currentTimeMillis(); + } + + public Direction getDirection() { return direction; } + public LLPFrame getFrame() { return frame; } + public long getTimestamp() { return timestamp; } +} \ No newline at end of file diff --git a/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/model/RawDataEvent.java b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/model/RawDataEvent.java new file mode 100644 index 0000000..d16d1a4 --- /dev/null +++ b/examples/llp-serial-test/src/main/java/com/flamingo/llp/monitor/model/RawDataEvent.java @@ -0,0 +1,18 @@ +package com.flamingo.llp.monitor.model; + +public class RawDataEvent { + + private final Direction direction; + private final byte data; + private final long timestamp; + + public RawDataEvent(Direction direction, byte data) { + this.direction = direction; + this.data = data; + this.timestamp = System.currentTimeMillis(); + } + + public Direction getDirection() { return direction; } + public byte getData() { return data; } + public long getTimestamp() { return timestamp; } +} \ No newline at end of file diff --git a/examples/llp-serial-test/src/main/java/com/flamingo/llp/test/SerialLLPTest.java b/examples/llp-serial-test/src/main/java/com/flamingo/llp/test/SerialLLPTest.java new file mode 100644 index 0000000..cf70c93 --- /dev/null +++ b/examples/llp-serial-test/src/main/java/com/flamingo/llp/test/SerialLLPTest.java @@ -0,0 +1,124 @@ +package com.flamingo.llp.test; + +import com.fazecast.jSerialComm.SerialPort; +import com.flamingo.comm.llp.LLP; +import com.flamingo.comm.llp.LLPFrame; +import com.flamingo.comm.llp.LLPParser; + +import java.io.InputStream; +import java.io.OutputStream; + +public class SerialLLPTest { + + public static void main(String[] args) throws Exception { + + // ================= SERIAL CONFIG ================= + SerialPort port = SerialPort.getCommPort("/dev/ttyUSB0"); + port.setBaudRate(9600); + port.setNumDataBits(8); + port.setNumStopBits(SerialPort.ONE_STOP_BIT); + port.setParity(SerialPort.NO_PARITY); + port.setComPortTimeouts( + SerialPort.TIMEOUT_READ_BLOCKING, + 0, + 0 + ); + + if (!port.openPort()) { + System.err.println("❌ Cannot open serial port"); + return; + } + + System.out.println("✅ Connected to " + port.getSystemPortName()); + System.out.println("⏳ Waiting for Arduino reset..."); + Thread.sleep(2000); + + InputStream in = port.getInputStream(); + OutputStream out = port.getOutputStream(); + + // ================= LLP ================= + LLPParser parser = LLP.newParser(); + + parser.addListener(new LLPParser.LLPFrameListener() { + @Override + public void onFrameReceived(LLPFrame frame) { + System.out.println("[RX] " + frame); + + switch (frame.type()) { + case 0x01: // PING + System.out.println("→ PING recibido"); + break; + + case 0x02: // ACK + System.out.println("→ ACK recibido"); + break; + + case 0x10: // DATA + System.out.println("→ DATA: " + new String(frame.payload())); + break; + } + } + + @Override + public void onFrameError(byte errorCode) { + System.err.println("[ERR] 0x" + Integer.toHexString(errorCode)); + } + }); + + // ================= RX THREAD ================= + Thread reader = new Thread(() -> { + StringBuilder asciiBuffer = new StringBuilder(); + + while (true) { + try { + int data = in.read(); + + if (data >= 0) { + byte b = (byte) data; + + // ===== PRINT POR BLOQUES ===== + if (b == '\n') { + System.out.println("[RAW ASCII] " + asciiBuffer); + asciiBuffer.setLength(0); + } else { + asciiBuffer.append((char) b); + } + + // ===== PARSER ===== + parser.processByte(b); + } + + } catch (Exception e) { + System.err.println("[WARN] Read error: " + e.getMessage()); + } + } + }); + + reader.setDaemon(true); + reader.start(); + + // ================= TX LOOP ================= + int id = 1; + + while (true) { + + // Enviar PING + byte[] ping = LLP.buildPing(id++); + out.write(ping); + out.flush(); + + System.out.println("[TX] PING"); + + Thread.sleep(3000); + + // Enviar DATA + byte[] data = LLP.buildData(id++, "Hello from Java".getBytes()); + out.write(data); + out.flush(); + + System.out.println("[TX] DATA"); + + Thread.sleep(5000); + } + } +} \ No newline at end of file diff --git a/examples/llp-serial-test/src/main/java/com/flamingo/llp/test/SerialLLPWebMonitorTest.java b/examples/llp-serial-test/src/main/java/com/flamingo/llp/test/SerialLLPWebMonitorTest.java new file mode 100644 index 0000000..d75f57e --- /dev/null +++ b/examples/llp-serial-test/src/main/java/com/flamingo/llp/test/SerialLLPWebMonitorTest.java @@ -0,0 +1,175 @@ +package com.flamingo.llp.test; + +import com.fazecast.jSerialComm.SerialPort; +import com.flamingo.comm.llp.*; +import com.flamingo.llp.monitor.MonitorEventBus; +import com.flamingo.llp.monitor.MonitorServer; +import com.flamingo.llp.monitor.SSEHandler; +import com.flamingo.llp.monitor.model.Direction; +import com.flamingo.llp.monitor.model.FrameEvent; +import com.flamingo.llp.monitor.model.RawDataEvent; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.HexFormat; + +public class SerialLLPWebMonitorTest { + + public static void main(String[] args) throws Exception { + + // ================= MONITOR ================= + MonitorEventBus bus = new MonitorEventBus(); + SSEHandler sse = new SSEHandler(); + MonitorServer server = new MonitorServer(3000); + + // Endpoint SSE + server.getServer().createContext("/events", exchange -> { + sse.handle(exchange); + }); + + // Conectar bus → SSE + bus.subscribe(event -> { + try { + String json = serialize(event); + sse.broadcast(json); + } catch (Exception ignored) { + } + }); + + server.start(); + + // ================= SERIAL CONFIG ================= + SerialPort port = SerialPort.getCommPort("/dev/ttyUSB0"); + port.setBaudRate(9600); + port.setNumDataBits(8); + port.setNumStopBits(SerialPort.ONE_STOP_BIT); + port.setParity(SerialPort.NO_PARITY); + port.setComPortTimeouts( + SerialPort.TIMEOUT_READ_BLOCKING, + 0, + 0 + ); + + if (!port.openPort()) { + System.err.println("❌ Cannot open serial port"); + return; + } + + System.out.println("✅ Connected to " + port.getSystemPortName()); + System.out.println("🌐 Open http://localhost:3000"); + System.out.println("⏳ Waiting for Arduino reset..."); + Thread.sleep(2000); + + InputStream in = port.getInputStream(); + OutputStream out = port.getOutputStream(); + + // ================= LLP ================= + LLPParser parser = LLP.newParser(); + + parser.addListener(new LLPParser.LLPFrameListener() { + @Override + public void onFrameReceived(LLPFrame frame) { + bus.publish(new FrameEvent(Direction.RX, frame)); + } + + @Override + public void onFrameError(byte errorCode) { + // Podés agregar evento de error si querés + } + }); + + // ================= RX THREAD ================= + Thread reader = new Thread(() -> { + while (true) { + try { + int data = in.read(); + + if (data >= 0) { + byte b = (byte) data; + + // RAW RX + bus.publish(new RawDataEvent(Direction.RX, b)); + + // Parser + parser.processByte(b); + } + + } catch (Exception e) { + System.err.println("[ERROR] " + e.getMessage()); + } + } + }); + + reader.setDaemon(true); + reader.start(); + + // ================= TX LOOP ================= + int id = 1; + + while (true) { + + // ===== PING ===== + byte[] ping = LLP.buildPing(id++); + + // RAW TX + for (byte b : ping) { + bus.publish(new RawDataEvent(Direction.TX, b)); + } + + out.write(ping); + out.flush(); + + Thread.sleep(3000); + + // ===== DATA ===== + LLPFrame outFrame = LLP.frameBuilder() + .id(id++) + .payload("Hello from Java".getBytes()) + .type(LLPMessageType.DATA) + .buildFrame(); + + + byte[] data = LLP.buildFrame(outFrame.type(), outFrame.id(), outFrame.payload()); + + for (byte b : data) { + bus.publish(new RawDataEvent(Direction.TX, b)); + } + + out.write(data); + out.flush(); + + bus.publish(new FrameEvent(Direction.TX, outFrame)); + + Thread.sleep(5000); + } + } + + // ================= SIMPLE JSON SERIALIZER ================= + private static String serialize(Object event) { + + if (event instanceof RawDataEvent e) { + return String.format( + "{\"type\":\"raw\",\"dir\":\"%s\",\"hex\":\"%02X\",\"ts\":%d}", + e.getDirection(), + e.getData(), + e.getTimestamp() + ); + } + + if (event instanceof FrameEvent e) { + LLPFrame f = e.getFrame(); + + return String.format( + "{\"type\":\"frame\",\"dir\":\"%s\",\"frameType\":\"0x%02X\",\"id\":%d,\"len\":%d,\"ts\":%d,\"payload\":\"%s\"}", + e.getDirection(), + f.type(), + f.id(), + f.payloadLength(), + e.getTimestamp(), + HexFormat.of().formatHex(f.payload()) + ); + } + + return "{}"; + } +} \ No newline at end of file diff --git a/examples/llp-serial-test/src/main/java/com/flamingo/llp/test/SerialTest.java b/examples/llp-serial-test/src/main/java/com/flamingo/llp/test/SerialTest.java new file mode 100644 index 0000000..4516c12 --- /dev/null +++ b/examples/llp-serial-test/src/main/java/com/flamingo/llp/test/SerialTest.java @@ -0,0 +1,58 @@ +package com.flamingo.llp.test; + +import com.fazecast.jSerialComm.SerialPort; + +import java.io.InputStream; + +public class SerialTest { + + public static void main(String[] args) throws Exception { + + // ================= SERIAL CONFIG ================= + SerialPort port = SerialPort.getCommPort("/dev/ttyUSB0"); + port.setBaudRate(9600); + port.setNumDataBits(8); + port.setNumStopBits(SerialPort.ONE_STOP_BIT); + port.setParity(SerialPort.NO_PARITY); + port.setComPortTimeouts( + SerialPort.TIMEOUT_READ_BLOCKING, + 0, + 0 + ); + + if (!port.openPort()) { + System.err.println("❌ Cannot open serial port"); + return; + } + + System.out.println("✅ Connected to " + port.getSystemPortName()); + System.out.println("⏳ Waiting for Arduino reset..."); + Thread.sleep(2000); + + InputStream in = port.getInputStream(); + + // ================= RX THREAD ================= + StringBuilder asciiBuffer = new StringBuilder(); + + while (!Thread.currentThread().isInterrupted()) { + try { + int data = in.read(); + + if (data >= 0) { + byte b = (byte) data; + + // ===== PRINT POR BLOQUES ===== + if (b == '\n') { + System.out.println(asciiBuffer); + asciiBuffer.setLength(0); + } else { + asciiBuffer.append((char) b); + } + } + + } catch (Exception e) { + System.err.println("[WARN] Read error: " + e.getMessage()); + } + } + } +} diff --git a/examples/llp-serial-test/src/main/resources/monitor/monitor.html b/examples/llp-serial-test/src/main/resources/monitor/monitor.html new file mode 100644 index 0000000..c93b8dc --- /dev/null +++ b/examples/llp-serial-test/src/main/resources/monitor/monitor.html @@ -0,0 +1,321 @@ + + + + + + LLP Protocol Monitor + + + + +
+

LLP Monitor

+
Connecting...
+
+ +
+
+
+
+ RAW STREAM (HEX) + +
+
+
+ +
+
RAW STREAM (ASCII UTF-8)
+
+
+
+ +
+
PARSED FRAMES
+
+
+
+ + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 141291a..180c21d 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.flamingo llp-protocol - 1.0.0 + 2.0.0 jar LLP Protocol - Java diff --git a/src/main/java/com/flamingo/comm/llp/LLP.java b/src/main/java/com/flamingo/comm/llp/LLP.java index 5dfbe32..1150949 100644 --- a/src/main/java/com/flamingo/comm/llp/LLP.java +++ b/src/main/java/com/flamingo/comm/llp/LLP.java @@ -24,6 +24,7 @@ *

This class is a utility facade and should not be instantiated.

*/ public final class LLP { + public static final byte PROTOCOL_VERSION = 0x02; /** * Private constructor to prevent instantiation. diff --git a/src/main/java/com/flamingo/comm/llp/LLPErrorCode.java b/src/main/java/com/flamingo/comm/llp/LLPErrorCode.java index 31253a0..29e494f 100644 --- a/src/main/java/com/flamingo/comm/llp/LLPErrorCode.java +++ b/src/main/java/com/flamingo/comm/llp/LLPErrorCode.java @@ -12,7 +12,8 @@ public enum LLPErrorCode { PAYLOAD_LEN_INVALID((byte) 0x03, "Payload length exceeds maximum"), TIMEOUT((byte) 0x04, "Frame timeout - incomplete frame"), SYNC_ERROR((byte) 0x05, "Synchronization error"), - BUFFER_FULL((byte) 0x06, "Buffer overflow"); + BUFFER_FULL((byte) 0x06, "Buffer overflow"), + UNSUPPORTED_VERSION((byte) 0x07, "Unknown or unsupported version"); private final byte code; private final String description; diff --git a/src/main/java/com/flamingo/comm/llp/LLPFrame.java b/src/main/java/com/flamingo/comm/llp/LLPFrame.java index 1d9998b..6b33d4b 100644 --- a/src/main/java/com/flamingo/comm/llp/LLPFrame.java +++ b/src/main/java/com/flamingo/comm/llp/LLPFrame.java @@ -13,17 +13,19 @@ public class LLPFrame { private final byte type; private final int id; + private final byte version; private final byte[] payload; private final int crc; private final long timestamp; - public LLPFrame(byte type, int id, byte[] payload, int crc) { - this(type, id, payload, crc, System.currentTimeMillis()); + public LLPFrame(byte type, int id, byte version, byte[] payload, int crc) { + this(type, id, version, payload, crc, System.currentTimeMillis()); } - public LLPFrame(byte type, int id, byte[] payload, int crc, long timestamp) { + public LLPFrame(byte type, int id, byte version, byte[] payload, int crc, long timestamp) { this.type = type; this.id = id; + this.version = version; this.payload = payload != null ? payload.clone() : new byte[0]; this.crc = crc; this.timestamp = timestamp; @@ -41,6 +43,10 @@ public int id() { return id; } + public byte version() { + return version; + } + public byte[] payload() { return payload.clone(); } @@ -60,8 +66,8 @@ public long timestamp() { @Override public String toString() { return String.format( - "LLPFrame{type=0x%02X, id=%d, payloadLen=%d, crc=0x%04X, timestamp=%d}", - type, id, payload.length, crc, timestamp + "LLPFrame{type=0x%02X, id=%d, version=0x%02X, payloadLen=%d, crc=0x%04X, timestamp=%d}", + type, id, version, payload.length, crc, timestamp ); } @@ -73,12 +79,13 @@ public boolean equals(Object o) { LLPFrame frame = (LLPFrame) o; return type == frame.type && id == frame.id && + version == frame.version && crc == frame.crc && Arrays.equals(payload, frame.payload); } @Override public int hashCode() { - return java.util.Objects.hash(type, id, crc, Arrays.hashCode(payload)); + return java.util.Objects.hash(type, id, version, crc, Arrays.hashCode(payload)); } } diff --git a/src/main/java/com/flamingo/comm/llp/LLPFrameBuilder.java b/src/main/java/com/flamingo/comm/llp/LLPFrameBuilder.java index b6a01af..4d3100b 100644 --- a/src/main/java/com/flamingo/comm/llp/LLPFrameBuilder.java +++ b/src/main/java/com/flamingo/comm/llp/LLPFrameBuilder.java @@ -4,6 +4,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -15,7 +16,7 @@ * *

Frame format:

*
- * [MAGIC1][MAGIC2][TYPE][ID_L][ID_H][LEN_L][LEN_H][PAYLOAD...][CRC_L][CRC_H]
+ * [MAGIC1][MAGIC2][PROTOCOL_VERSION][TYPE][ID_L][ID_H][LEN_L][LEN_H][PAYLOAD...][CRC_L][CRC_H]
  * 
* *

The CRC16-CCITT is calculated over all bytes except the CRC itself.

@@ -65,13 +66,17 @@ public static byte[] build(byte type, int id, byte[] payload, int maxPayload) { ); } - byte[] frame = new byte[7 + payload.length + 2]; + final int MAX_FRAME_SIZE = 8 + payload.length + 2; + byte[] frame = new byte[MAX_FRAME_SIZE]; int idx = 0; // Magic frame[idx++] = MAGIC_1; frame[idx++] = MAGIC_2; + // Version + frame[idx++] = LLP.PROTOCOL_VERSION; + // Type frame[idx++] = type; @@ -98,7 +103,34 @@ public static byte[] build(byte type, int id, byte[] payload, int maxPayload) { logger.debug("Built frame: type=0x{}, id={}, payload_len={}, total_len={}", Integer.toHexString(type & 0xFF), id, payload.length, frame.length); - return frame; + return stuffFrame(frame); + } + + /** + * Stuff bytes into a completed frame, excluding the header. + * Add one byte to ensure the header is not included within the frame. + * + * @param frame original frame without any processing + * @return Stuffed frame + */ + private static byte[] stuffFrame(byte[] frame) { + // After the byte stuff, in the worst-case scenario, it will take up twice the size of the actual frame + ByteArrayOutputStream buffer = new ByteArrayOutputStream(frame.length * 2); + + // Copy MAGIC as-is + buffer.write(frame, 0, 2); + + // Stuff everything else + for (int i = 2; i < frame.length; i++) { + byte b = frame[i]; + buffer.write(b); + + if (b == MAGIC_1) { + buffer.write(0x00); + } + } + + return buffer.toByteArray(); } /** @@ -176,7 +208,7 @@ public LLPFrame buildFrame() { int crc = (data[data.length - 2] & 0xFF) | ((data[data.length - 1] & 0xFF) << 8); - return new LLPFrame(type, id, payload, crc); + return new LLPFrame(type, id, LLP.PROTOCOL_VERSION, payload, crc); } } } \ No newline at end of file diff --git a/src/main/java/com/flamingo/comm/llp/LLPParser.java b/src/main/java/com/flamingo/comm/llp/LLPParser.java index 5ca1235..e6891f3 100644 --- a/src/main/java/com/flamingo/comm/llp/LLPParser.java +++ b/src/main/java/com/flamingo/comm/llp/LLPParser.java @@ -15,19 +15,7 @@ * *

This parser processes incoming data byte-by-byte and reconstructs valid LLP frames. * It is designed to work with unreliable or noisy transport layers (e.g., RF, UART, TCP streams), - * providing resynchronization, timeout handling, and CRC validation.

- * - *

Typical usage:

- *
- *     LLPParser parser = new LLPParser();
- *     LLPFrame frame = parser.processByte(byte);
- *     if (frame != null) {
- *         // handle frame
- *     }
- * 
- * - *

This class is NOT fully thread-safe. It is expected to be used from a single - * reader thread. However, listeners and frame queue are safe for concurrent access.

+ * providing resynchronization, timeout handling, CRC validation, and Byte Stuffing.

*/ 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