This is an automated email from the ASF dual-hosted git repository.

sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git


The following commit(s) were added to refs/heads/develop by this push:
     new edcddb0e88 fix(plc4j): frame Modbus RTU responses by function code 
with resync
edcddb0e88 is described below

commit edcddb0e88f2ba604f14c8b5bf3668051570df3d
Author: Sebastian Rühl <[email protected]>
AuthorDate: Mon Jul 6 21:43:46 2026 +0200

    fix(plc4j): frame Modbus RTU responses by function code with resync
    
    The RTU codec treated everything available as a single frame (RTU has no
    length field), discarding trailing frames in batched deliveries and
    losing partial frames. Responses are now sized by function code
    (exceptions 5, reads and file-record ops 5+byteCount, write echoes 8),
    validated from a peek by the CRC-checking parser before being consumed,
    and unparseable bytes are skipped one at a time until the stream
    resynchronizes. This makes multi-slave reuse-port serial setups work
    end to end: every frame in a broadcast batch reaches its connection.
---
 RELEASE_NOTES                                      |   4 +
 .../java/modbus/rtu/ModbusRtuMessageCodec.java     | 130 +++++++++++-
 .../java/modbus/rtu/ModbusRtuMessageCodecTest.java | 230 ++++++++++++++++++++-
 3 files changed, 347 insertions(+), 17 deletions(-)

diff --git a/RELEASE_NOTES b/RELEASE_NOTES
index 3ddb5ed69d..5c18715de6 100644
--- a/RELEASE_NOTES
+++ b/RELEASE_NOTES
@@ -28,6 +28,10 @@ New Features
   between connections, and "interframe-delay" write pacing works on both
   shared and dedicated ports (gap measured from the last write or
   received data).
+- The Java Modbus RTU codec now frames responses by function code with
+  CRC validation and byte-wise resynchronization: batched deliveries
+  (e.g. on shared serial ports) yield every frame instead of only the
+  first, and partial or corrupted frames no longer discard buffered data.
 
 Incompatible changes
 --------------------
diff --git 
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuMessageCodec.java
 
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuMessageCodec.java
index 2cafb3c631..142ff33249 100644
--- 
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuMessageCodec.java
+++ 
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuMessageCodec.java
@@ -24,7 +24,9 @@ import org.apache.plc4x.java.modbus.readwrite.ModbusRtuADU;
 import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException;
 import org.apache.plc4x.java.spi.buffers.bytebased.ReadBufferByteBased;
 import org.apache.plc4x.java.spi.drivers.MessageCodecBase;
+import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException;
 import org.apache.plc4x.java.spi.transports.api.TransportInstance;
+import org.apache.plc4x.java.spi.transports.api.exceptions.TransportException;
 
 import java.util.function.Consumer;
 
@@ -33,13 +35,32 @@ import java.util.function.Consumer;
  * Handles the encoding and decoding of Modbus RTU ADU (Application Data Unit) 
messages.
  *
  * RTU framing: 1 byte address + PDU + 2 bytes CRC.
- * Since RTU has no explicit length field, we attempt to parse the available 
data
- * and wait for more data if the parse fails.
+ * Since RTU has no explicit length field in the header, the response frame 
size is
+ * derived from the function code (see {@link #calculateTotalMessageSize}): 
exception
+ * responses (fc &gt;= 0x80) are 5 bytes, read responses (0x01-0x04, 0x14, 
0x15, 0x17,
+ * including the file-record operations) are 5 + the byte-count carried in the 
third
+ * header byte, and write echoes (0x05, 0x06, 0x0F, 0x10) are a fixed 8 bytes.
+ * {@link #processIncomingData()} peeks
+ * a candidate frame, only consumes it once the generated parser has validated 
its CRC,
+ * and otherwise resynchronizes byte-wise.
+ *
+ * Limitation: since the size table above is response-shaped, this codec 
assumes it is
+ * only ever used to decode responses (as constructed) - it cannot correctly 
frame a
+ * stream of raw Modbus RTU requests.
  */
 public class ModbusRtuMessageCodec extends MessageCodecBase<ModbusRtuADU> {
 
     // Minimum RTU message: address (1) + function code (1) + CRC (2) = 4 bytes
     private static final int MODBUS_RTU_MIN_SIZE = 4;
+    // address + fc + exception code + CRC
+    private static final int EXCEPTION_RESPONSE_SIZE = 5;
+    // address + fc + 2 bytes address + 2 bytes value/quantity + CRC
+    private static final int WRITE_ECHO_RESPONSE_SIZE = 8;
+    // address + fc + third byte (byteCount for reads) — all the sizing
+    // table needs to compute the expected response size
+    private static final int SIZING_HEADER_SIZE = 3;
+
+    private long resyncSkippedBytes;
 
     public ModbusRtuMessageCodec(TransportInstance<?> transportInstance, 
Consumer<ModbusRtuADU> messageHandler) {
         super("Modbus RTU", transportInstance, messageHandler);
@@ -50,12 +71,32 @@ public class ModbusRtuMessageCodec extends 
MessageCodecBase<ModbusRtuADU> {
         return MODBUS_RTU_MIN_SIZE;
     }
 
+    /**
+     * Computes the expected RESPONSE frame size from the peeked header
+     * (address, function code, third byte). RTU has no length field, so
+     * the size follows from the function code:
+     * exception (fc >= 0x80) = 5; reads (0x01-0x04, 0x14, 0x15, 0x17 —
+     * including the file-record read/write operations, whose responses
+     * carry byteCount as the third byte covering the whole item array
+     * just like the plain reads) = 5 + byteCount (the third byte);
+     * write echoes (0x05, 0x06, 0x0F, 0x10) = 8.
+     * Returns -1 for unknown function codes — the caller treats that as
+     * desynchronization, not as "wait for more data".
+     */
     @Override
     protected int calculateTotalMessageSize(byte[] header, int availableBytes) 
{
-        // RTU does not have an explicit length field in the header.
-        // We return the total available bytes and let parseMessage handle 
validation.
-        // If the parse fails due to incomplete data, the base class will wait 
for more data.
-        return availableBytes;
+        int functionCode = header[1] & 0xFF;
+        if (functionCode >= 0x80) {
+            return EXCEPTION_RESPONSE_SIZE;
+        }
+        switch (functionCode) {
+            case 0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17:
+                return MODBUS_RTU_MIN_SIZE + 1 + (header[2] & 0xFF);
+            case 0x05, 0x06, 0x0F, 0x10:
+                return WRITE_ECHO_RESPONSE_SIZE;
+            default:
+                return -1;
+        }
     }
 
     @Override
@@ -67,4 +108,81 @@ public class ModbusRtuMessageCodec extends 
MessageCodecBase<ModbusRtuADU> {
         return modbusADU;
     }
 
+    /**
+     * RTU-specific receive loop: peek before consume. The base class's
+     * loop reads a frame before validating it, which loses bytes whenever
+     * the stream carries more (or less) than exactly one frame — the norm
+     * on a shared serial line. Here a frame is only consumed after the
+     * generated parser (which validates the CRC checksum field) accepted
+     * it from a peek; anything unparseable advances by a single byte
+     * (byte-wise resynchronization, mirroring the plc4go modbus codec).
+     * <p>
+     * While resynchronizing (i.e. a prior candidate at this stream position
+     * already failed), a header that looks valid but demands more bytes than
+     * are available is treated as another failed candidate rather than as a
+     * reason to wait: a byte-shifted resync candidate can accidentally look
+     * like a legitimate function code with a large byte count, which would
+     * otherwise stall the codec forever waiting for bytes that were never
+     * going to arrive. Waiting is only correct at a clean (non-resyncing)
+     * stream position, where an undersized buffer really does mean "the next
+     * frame hasn't fully arrived yet". The corollary trade-off: a GENUINE
+     * partial frame sitting directly behind garbage is skipped rather than
+     * awaited — that response is lost and recovered by the master's own
+     * request timeout and retry, which beats stalling every frame queued
+     * behind an undersized garbage candidate that will never complete.
+     */
+    @Override
+    public void processIncomingData() throws MessageCodecException {
+        try {
+            while (true) {
+                int availableBytes = 
getTransportInstance().getNumBytesAvailable();
+                if (availableBytes < MODBUS_RTU_MIN_SIZE) {
+                    return;
+                }
+                byte[] header = 
getTransportInstance().peekReadableBytes(SIZING_HEADER_SIZE);
+                int expectedSize = calculateTotalMessageSize(header, 
availableBytes);
+                if (expectedSize < 0) {
+                    skipOneByte("unknown function code 0x" + 
Integer.toHexString(header[1] & 0xFF));
+                    continue;
+                }
+                if (availableBytes < expectedSize) {
+                    if (resyncSkippedBytes > 0) {
+                        skipOneByte("candidate frame during resync needs " + 
expectedSize
+                            + " bytes, only " + availableBytes + " available");
+                        continue;
+                    }
+                    return; // never consume a partial frame
+                }
+                byte[] frame = 
getTransportInstance().peekReadableBytes(expectedSize);
+                ModbusRtuADU message;
+                try {
+                    message = parseMessage(createReadBuffer(frame));
+                } catch (BufferException e) {
+                    skipOneByte("frame failed validation: " + e.getMessage());
+                    continue;
+                }
+                getTransportInstance().read(expectedSize); // consume the 
validated frame
+                noteResyncComplete();
+                messageHandler.accept(message);
+            }
+        } catch (TransportException e) {
+            throw new MessageCodecException("Failed to receive Modbus RTU 
message", e);
+        }
+    }
+
+    private void skipOneByte(String reason) throws TransportException {
+        if (resyncSkippedBytes == 0) {
+            logger.warn("Modbus RTU stream out of sync ({}), resynchronizing 
byte-wise", reason);
+        }
+        getTransportInstance().read(1);
+        resyncSkippedBytes++;
+    }
+
+    private void noteResyncComplete() {
+        if (resyncSkippedBytes > 0) {
+            logger.warn("Modbus RTU stream resynchronized after skipping {} 
bytes", resyncSkippedBytes);
+            resyncSkippedBytes = 0;
+        }
+    }
+
 }
diff --git 
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuMessageCodecTest.java
 
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuMessageCodecTest.java
index b49b66ae20..ddb322332f 100644
--- 
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuMessageCodecTest.java
+++ 
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuMessageCodecTest.java
@@ -18,11 +18,17 @@
  */
 package org.apache.plc4x.java.modbus.rtu;
 
-import org.apache.plc4x.java.modbus.readwrite.ModbusRtuADU;
+import org.apache.plc4x.java.modbus.readwrite.*;
+import org.apache.plc4x.java.spi.buffers.bytebased.WriteBufferByteBased;
 import org.apache.plc4x.java.spi.transports.api.TransportInstance;
+import org.apache.plc4x.java.spi.transports.api.config.TransportConfiguration;
+import org.apache.plc4x.java.spi.transports.api.exceptions.TransportException;
 import org.junit.jupiter.api.Test;
 
+import java.io.ByteArrayOutputStream;
 import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.function.Consumer;
 
 import static org.junit.jupiter.api.Assertions.*;
@@ -52,18 +58,220 @@ class ModbusRtuMessageCodecTest {
         assertEquals(4, method.invoke(codec));
     }
 
-    @SuppressWarnings("unchecked")
+    /**
+     * In-memory transport double: bytes are queued via deliver(...) and the
+     * codec consumes them through the real TransportInstance contract.
+     * Strict: peeking/reading more than available is a test bug and throws.
+     */
+    static final class ScriptedTransport implements 
TransportInstance<TransportConfiguration> {
+        private final ByteArrayOutputStream buffer = new 
ByteArrayOutputStream();
+        private int readPosition;
+        private boolean open = true;
+
+        void deliver(byte[] bytes) {
+            buffer.writeBytes(bytes);
+        }
+
+        private byte[] bufferedBytes() {
+            return buffer.toByteArray();
+        }
+
+        @Override
+        public TransportConfiguration getConfiguration() {
+            return null;
+        }
+
+        @Override
+        public boolean isOpen() {
+            return open;
+        }
+
+        @Override
+        public int getNumBytesAvailable() {
+            return buffer.size() - readPosition;
+        }
+
+        @Override
+        public byte[] peekReadableBytes(int numBytes) throws 
TransportException {
+            if (numBytes > getNumBytesAvailable()) {
+                throw new TransportException("peek beyond available: " + 
numBytes);
+            }
+            byte[] all = bufferedBytes();
+            byte[] result = new byte[numBytes];
+            System.arraycopy(all, readPosition, result, 0, numBytes);
+            return result;
+        }
+
+        @Override
+        public byte[] read(int numBytes) throws TransportException {
+            byte[] result = peekReadableBytes(numBytes);
+            readPosition += numBytes;
+            return result;
+        }
+
+        @Override
+        public void write(byte[] bytes) {
+            // not exercised by these tests
+        }
+
+        @Override
+        public void close() {
+            open = false;
+        }
+    }
+
+    private static byte[] wireBytes(ModbusRtuADU adu) throws Exception {
+        WriteBufferByteBased writeBuffer = new WriteBufferByteBased(new 
byte[adu.getLengthInBytes()]);
+        adu.serialize(writeBuffer);
+        return writeBuffer.getBytes();
+    }
+
+    private static byte[] readResponseFrame(int unitId, byte[] registers) 
throws Exception {
+        return wireBytes(new ModbusRtuADU((short) unitId, new 
ModbusPDUReadHoldingRegistersResponse(registers)));
+    }
+
+    private static byte[] readFileRecordResponseFrame(int unitId, byte[] data) 
throws Exception {
+        List<ModbusPDUReadFileRecordResponseItem> items = new ArrayList<>();
+        items.add(new ModbusPDUReadFileRecordResponseItem((short) 6, data));
+        return wireBytes(new ModbusRtuADU((short) unitId, new 
ModbusPDUReadFileRecordResponse(items)));
+    }
+
+    private static byte[] concat(byte[]... parts) {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        for (byte[] part : parts) {
+            out.writeBytes(part);
+        }
+        return out.toByteArray();
+    }
+
     @Test
-    void testCalculateTotalMessageSize_returnsAvailableBytes() throws 
Exception {
-        TransportInstance<?> transportInstance = mock(TransportInstance.class);
-        Consumer<ModbusRtuADU> handler = mock(Consumer.class);
-        ModbusRtuMessageCodec codec = new 
ModbusRtuMessageCodec(transportInstance, handler);
+    void singleCompleteResponseIsDispatched() throws Exception {
+        ScriptedTransport transport = new ScriptedTransport();
+        List<ModbusRtuADU> received = new ArrayList<>();
+        ModbusRtuMessageCodec codec = new ModbusRtuMessageCodec(transport, 
received::add);
 
-        Method method = 
codec.getClass().getDeclaredMethod("calculateTotalMessageSize", byte[].class, 
int.class);
-        method.setAccessible(true);
+        transport.deliver(readResponseFrame(1, new byte[]{0x11, 0x22}));
+        codec.processIncomingData();
+
+        assertEquals(1, received.size());
+        assertEquals(1, received.get(0).getAddress());
+    }
+
+    @Test
+    void twoResponsesInOneDeliveryAreBothDispatched() throws Exception {
+        // The shared-port batching case — the bug this change fixes.
+        ScriptedTransport transport = new ScriptedTransport();
+        List<ModbusRtuADU> received = new ArrayList<>();
+        ModbusRtuMessageCodec codec = new ModbusRtuMessageCodec(transport, 
received::add);
+
+        transport.deliver(concat(
+            readResponseFrame(1, new byte[]{0x11, 0x22}),
+            readResponseFrame(2, new byte[]{0x33, 0x44, 0x55, 0x66})));
+        codec.processIncomingData();
+
+        assertEquals(2, received.size());
+        assertEquals(1, received.get(0).getAddress());
+        assertEquals(2, received.get(1).getAddress());
+    }
+
+    @Test
+    void partialFrameWaitsAndCompletesAcrossDeliveries() throws Exception {
+        ScriptedTransport transport = new ScriptedTransport();
+        List<ModbusRtuADU> received = new ArrayList<>();
+        ModbusRtuMessageCodec codec = new ModbusRtuMessageCodec(transport, 
received::add);
+
+        byte[] frame = readResponseFrame(1, new byte[]{0x11, 0x22, 0x33, 
0x44});
+        transport.deliver(java.util.Arrays.copyOfRange(frame, 0, 5)); // 
header + partial data
+        codec.processIncomingData();
+        assertEquals(0, received.size(), "nothing may be consumed while the 
frame is incomplete");
+        assertEquals(5, transport.getNumBytesAvailable(), "partial bytes must 
remain buffered");
+
+        transport.deliver(java.util.Arrays.copyOfRange(frame, 5, 
frame.length));
+        codec.processIncomingData();
+        assertEquals(1, received.size());
+    }
+
+    @Test
+    void garbagePrefixIsSkippedToTheNextValidFrame() throws Exception {
+        ScriptedTransport transport = new ScriptedTransport();
+        List<ModbusRtuADU> received = new ArrayList<>();
+        ModbusRtuMessageCodec codec = new ModbusRtuMessageCodec(transport, 
received::add);
+
+        // 0x00 is not a known function code in byte 2 position for any
+        // alignment of this prefix — forces byte-wise resync.
+        transport.deliver(concat(new byte[]{0x00, 0x00, 0x00}, 
readResponseFrame(3, new byte[]{0x77, 0x77})));
+        codec.processIncomingData();
+
+        assertEquals(1, received.size());
+        assertEquals(3, received.get(0).getAddress());
+    }
+
+    @Test
+    void corruptCrcFrameIsSkippedAndNextFrameParses() throws Exception {
+        ScriptedTransport transport = new ScriptedTransport();
+        List<ModbusRtuADU> received = new ArrayList<>();
+        ModbusRtuMessageCodec codec = new ModbusRtuMessageCodec(transport, 
received::add);
+
+        byte[] corrupt = readResponseFrame(1, new byte[]{0x11, 0x22});
+        corrupt[corrupt.length - 1] ^= (byte) 0xFF; // break the CRC
+        transport.deliver(concat(corrupt, readResponseFrame(2, new 
byte[]{0x33, 0x44})));
+        codec.processIncomingData();
+
+        assertEquals(1, received.size(), "the corrupt frame is skipped, the 
valid one parses");
+        assertEquals(2, received.get(0).getAddress());
+    }
+
+    @Test
+    void writeEchoAndExceptionResponsesAreFramedCorrectly() throws Exception {
+        ScriptedTransport transport = new ScriptedTransport();
+        List<ModbusRtuADU> received = new ArrayList<>();
+        ModbusRtuMessageCodec codec = new ModbusRtuMessageCodec(transport, 
received::add);
+
+        byte[] writeEcho = wireBytes(new ModbusRtuADU((short) 4, new 
ModbusPDUWriteSingleRegisterResponse(0x0010, 0x1234)));
+        assertEquals(8, writeEcho.length, "sanity: write echo is the fixed 
8-byte frame");
+        byte[] exception = wireBytes(new ModbusRtuADU((short) 5, new 
ModbusPDUError(ModbusErrorCode.ILLEGAL_FUNCTION)));
+        assertEquals(5, exception.length, "sanity: exception response is the 
fixed 5-byte frame");
+
+        transport.deliver(concat(writeEcho, exception));
+        codec.processIncomingData();
+
+        assertEquals(2, received.size());
+        assertEquals(4, received.get(0).getAddress());
+        assertEquals(5, received.get(1).getAddress());
+    }
+
+    @Test
+    void fileRecordResponseIsFramedCorrectly() throws Exception {
+        // fc 0x14 (read file record) responses carry byteCount as the third
+        // byte just like the plain reads, but the sizing switch used to
+        // omit 0x14/0x15 entirely, so this frame would be discarded byte-wise
+        // and the following frame would be the only one dispatched.
+        ScriptedTransport transport = new ScriptedTransport();
+        List<ModbusRtuADU> received = new ArrayList<>();
+        ModbusRtuMessageCodec codec = new ModbusRtuMessageCodec(transport, 
received::add);
+
+        transport.deliver(concat(
+            readFileRecordResponseFrame(1, new byte[]{0x11, 0x22, 0x33, 0x44}),
+            readResponseFrame(2, new byte[]{0x33, 0x44})));
+        codec.processIncomingData();
+
+        assertEquals(2, received.size(), "fc 0x14 (read file record) response 
must be framed, not discarded");
+        assertEquals(1, received.get(0).getAddress());
+        assertEquals(2, received.get(1).getAddress());
+    }
+
+    @Test
+    void foreignUnitIdFrameIsStillDispatched() throws Exception {
+        // The codec frames and dispatches everything on the wire; unit-id
+        // filtering is the connection layer's job.
+        ScriptedTransport transport = new ScriptedTransport();
+        List<ModbusRtuADU> received = new ArrayList<>();
+        ModbusRtuMessageCodec codec = new ModbusRtuMessageCodec(transport, 
received::add);
+
+        transport.deliver(readResponseFrame(42, new byte[]{0x01, 0x02}));
+        codec.processIncomingData();
 
-        // RTU has no explicit length, so it returns the available bytes
-        byte[] header = new byte[]{0x01, 0x03, 0x00, 0x00};
-        assertEquals(20, method.invoke(codec, header, 20));
+        assertEquals(1, received.size());
+        assertEquals(42, received.get(0).getAddress());
     }
 }

Reply via email to