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

chrisdutz 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 ec721f4ad4 fix(plc4j/modbus): Fixed an issue with optimized modbus 
reads that the unit-id was dropped in the process.
ec721f4ad4 is described below

commit ec721f4ad4a83bcb8e4da40e429816538bce8f7b
Author: Christofer Dutz <[email protected]>
AuthorDate: Wed Aug 12 15:42:52 2026 +0200

    fix(plc4j/modbus): Fixed an issue with optimized modbus reads that the 
unit-id was dropped in the process.
    
    Fixes: #2686
---
 .../modbus/base/optimizer/ModbusReadOptimizer.java | 100 ++++++++++++++-------
 ...mizerTest.java => ModbusReadOptimizerTest.java} |  78 +++++++++++++++-
 .../rtu/ModbusRtuConnectionRequestChainTest.java   |  45 ++++++++++
 3 files changed, 190 insertions(+), 33 deletions(-)

diff --git 
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/optimizer/ModbusReadOptimizer.java
 
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/optimizer/ModbusReadOptimizer.java
index ab31210aa0..67f02d5966 100644
--- 
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/optimizer/ModbusReadOptimizer.java
+++ 
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/base/optimizer/ModbusReadOptimizer.java
@@ -55,33 +55,47 @@ public class ModbusReadOptimizer {
     }
 
     /**
-     * Groups tags by type and merges adjacent ones into optimized block reads.
+     * Groups tags by type and unit-id and merges adjacent ones into optimized 
block reads.
      * Returns a list of OptimizedRead objects, each containing a merged tag 
to read
      * and the original tag names it covers.
+     * <p>
+     * A block read is addressed to exactly one unit, so tags carrying 
different unit-ids must
+     * never end up in the same request, and the unit-id of a group has to be 
passed on to the
+     * merged tag (otherwise the connection would fall back to its default 
unit-id).
      */
     public List<OptimizedRead> optimizeReads(Map<String, ModbusTag> 
tagsByName) {
-        // Sort tags by type
-        TreeMap<String, ModbusTag> coils = new TreeMap<>();
-        TreeMap<String, ModbusTag> holdingRegisters = new TreeMap<>();
-        TreeMap<String, ModbusTag> inputRegisters = new TreeMap<>();
-        TreeMap<String, ModbusTag> extendedRegisters = new TreeMap<>();
-        TreeMap<String, ModbusTag> discreteInputs = new TreeMap<>();
+        // Sort tags by type and unit-id
+        Map<Short, TreeMap<String, ModbusTag>> coils = newUnitIdGroups();
+        Map<Short, TreeMap<String, ModbusTag>> holdingRegisters = 
newUnitIdGroups();
+        Map<Short, TreeMap<String, ModbusTag>> inputRegisters = 
newUnitIdGroups();
+        Map<Short, TreeMap<String, ModbusTag>> extendedRegisters = 
newUnitIdGroups();
+        Map<Short, TreeMap<String, ModbusTag>> discreteInputs = 
newUnitIdGroups();
 
         for (Map.Entry<String, ModbusTag> entry : tagsByName.entrySet()) {
             ModbusTag tag = entry.getValue();
-            if (tag instanceof ModbusTagCoil) coils.put(entry.getKey(), tag);
-            else if (tag instanceof ModbusTagHoldingRegister) 
holdingRegisters.put(entry.getKey(), tag);
-            else if (tag instanceof ModbusTagInputRegister) 
inputRegisters.put(entry.getKey(), tag);
-            else if (tag instanceof ModbusTagExtendedRegister) 
extendedRegisters.put(entry.getKey(), tag);
-            else if (tag instanceof ModbusTagDiscreteInput) 
discreteInputs.put(entry.getKey(), tag);
+            if (tag instanceof ModbusTagCoil) addToUnitIdGroup(coils, entry);
+            else if (tag instanceof ModbusTagHoldingRegister) 
addToUnitIdGroup(holdingRegisters, entry);
+            else if (tag instanceof ModbusTagInputRegister) 
addToUnitIdGroup(inputRegisters, entry);
+            else if (tag instanceof ModbusTagExtendedRegister) 
addToUnitIdGroup(extendedRegisters, entry);
+            else if (tag instanceof ModbusTagDiscreteInput) 
addToUnitIdGroup(discreteInputs, entry);
         }
 
         List<OptimizedRead> result = new ArrayList<>();
-        if (!coils.isEmpty()) result.addAll(optimizeCoils(coils));
-        if (!holdingRegisters.isEmpty()) 
result.addAll(optimizeRegisters(holdingRegisters, 
ModbusReadOptimizer::createHoldingRegister));
-        if (!inputRegisters.isEmpty()) 
result.addAll(optimizeRegisters(inputRegisters, 
ModbusReadOptimizer::createInputRegister));
-        if (!extendedRegisters.isEmpty()) 
result.addAll(optimizeRegisters(extendedRegisters, 
ModbusReadOptimizer::createExtendedRegister));
-        if (!discreteInputs.isEmpty()) 
result.addAll(optimizeCoils(discreteInputs));
+        for (Map.Entry<Short, TreeMap<String, ModbusTag>> group : 
coils.entrySet()) {
+            result.addAll(optimizeCoils(group.getKey(), group.getValue()));
+        }
+        for (Map.Entry<Short, TreeMap<String, ModbusTag>> group : 
holdingRegisters.entrySet()) {
+            result.addAll(optimizeRegisters(group.getKey(), group.getValue(), 
ModbusReadOptimizer::createHoldingRegister));
+        }
+        for (Map.Entry<Short, TreeMap<String, ModbusTag>> group : 
inputRegisters.entrySet()) {
+            result.addAll(optimizeRegisters(group.getKey(), group.getValue(), 
ModbusReadOptimizer::createInputRegister));
+        }
+        for (Map.Entry<Short, TreeMap<String, ModbusTag>> group : 
extendedRegisters.entrySet()) {
+            result.addAll(optimizeRegisters(group.getKey(), group.getValue(), 
ModbusReadOptimizer::createExtendedRegister));
+        }
+        for (Map.Entry<Short, TreeMap<String, ModbusTag>> group : 
discreteInputs.entrySet()) {
+            result.addAll(optimizeCoils(group.getKey(), group.getValue()));
+        }
         return result;
     }
 
@@ -156,7 +170,29 @@ public class ModbusReadOptimizer {
     // Internal
     
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
 
-    private List<OptimizedRead> optimizeCoils(Map<String, ModbusTag> 
tagsByName) {
+    /**
+     * Creates a map for grouping tags by their unit-id. Tags without an 
explicit unit-id (which
+     * will be read using the connection's default unit-id) are grouped under 
the {@code null} key.
+     */
+    private static Map<Short, TreeMap<String, ModbusTag>> newUnitIdGroups() {
+        return new TreeMap<>(Comparator.nullsFirst(Comparator.naturalOrder()));
+    }
+
+    private static void addToUnitIdGroup(Map<Short, TreeMap<String, 
ModbusTag>> groups,
+                                         Map.Entry<String, ModbusTag> entry) {
+        groups.computeIfAbsent(entry.getValue().getUnitId(), unitId -> new 
TreeMap<>())
+            .put(entry.getKey(), entry.getValue());
+    }
+
+    /**
+     * Builds the config of a merged tag, so that the unit-id of the tags it 
was built from is
+     * used when sending the request.
+     */
+    private static Map<String, String> mergedTagConfig(Short unitId) {
+        return unitId == null ? Collections.emptyMap() : 
Collections.singletonMap("unit-id", unitId.toString());
+    }
+
+    private List<OptimizedRead> optimizeCoils(Short unitId, Map<String, 
ModbusTag> tagsByName) {
         // Sort by address
         List<Map.Entry<String, ModbusTag>> sorted = new 
ArrayList<>(tagsByName.entrySet());
         sorted.sort(Comparator.comparingInt(e -> e.getValue().getAddress()));
@@ -184,8 +220,8 @@ public class ModbusReadOptimizer {
                 // Finish current group
                 boolean isDiscreteInput = 
currentGroup.values().iterator().next() instanceof ModbusTagDiscreteInput;
                 ModbusTag mergedTag = isDiscreteInput
-                    ? new ModbusTagDiscreteInput(firstAddress, lastAddress - 
firstAddress, ModbusDataType.BYTE, Collections.emptyMap())
-                    : new ModbusTagCoil(firstAddress, lastAddress - 
firstAddress, ModbusDataType.BYTE, Collections.emptyMap());
+                    ? new ModbusTagDiscreteInput(firstAddress, lastAddress - 
firstAddress, ModbusDataType.BYTE, mergedTagConfig(unitId))
+                    : new ModbusTagCoil(firstAddress, lastAddress - 
firstAddress, ModbusDataType.BYTE, mergedTagConfig(unitId));
                 result.add(new OptimizedRead(mergedTag, currentGroup));
 
                 // Start new group
@@ -203,14 +239,14 @@ public class ModbusReadOptimizer {
         if (!currentGroup.isEmpty()) {
             boolean isDiscreteInput = currentGroup.values().iterator().next() 
instanceof ModbusTagDiscreteInput;
             ModbusTag mergedTag = isDiscreteInput
-                ? new ModbusTagDiscreteInput(firstAddress, lastAddress - 
firstAddress, ModbusDataType.BYTE, Collections.emptyMap())
-                : new ModbusTagCoil(firstAddress, lastAddress - firstAddress, 
ModbusDataType.BYTE, Collections.emptyMap());
+                ? new ModbusTagDiscreteInput(firstAddress, lastAddress - 
firstAddress, ModbusDataType.BYTE, mergedTagConfig(unitId))
+                : new ModbusTagCoil(firstAddress, lastAddress - firstAddress, 
ModbusDataType.BYTE, mergedTagConfig(unitId));
             result.add(new OptimizedRead(mergedTag, currentGroup));
         }
         return result;
     }
 
-    private List<OptimizedRead> optimizeRegisters(Map<String, ModbusTag> 
tagsByName, TagFactory tagFactory) {
+    private List<OptimizedRead> optimizeRegisters(Short unitId, Map<String, 
ModbusTag> tagsByName, TagFactory tagFactory) {
         List<Map.Entry<String, ModbusTag>> sorted = new 
ArrayList<>(tagsByName.entrySet());
         sorted.sort(Comparator.comparingInt(e -> e.getValue().getAddress()));
 
@@ -236,7 +272,7 @@ public class ModbusReadOptimizer {
             if (tagEnd > maxRegister) {
                 // Finish current group
                 result.add(new OptimizedRead(
-                    tagFactory.createTag(firstRegister, lastRegister - 
firstRegister, ModbusDataType.WORD),
+                    tagFactory.createTag(firstRegister, lastRegister - 
firstRegister, ModbusDataType.WORD, mergedTagConfig(unitId)),
                     currentGroup));
 
                 // Start new group
@@ -252,22 +288,22 @@ public class ModbusReadOptimizer {
 
         if (!currentGroup.isEmpty()) {
             result.add(new OptimizedRead(
-                tagFactory.createTag(firstRegister, lastRegister - 
firstRegister, ModbusDataType.WORD),
+                tagFactory.createTag(firstRegister, lastRegister - 
firstRegister, ModbusDataType.WORD, mergedTagConfig(unitId)),
                 currentGroup));
         }
         return result;
     }
 
-    private static ModbusTag createHoldingRegister(int address, int count, 
ModbusDataType dataType) {
-        return new ModbusTagHoldingRegister(address, count, dataType, 
Collections.emptyMap());
+    private static ModbusTag createHoldingRegister(int address, int count, 
ModbusDataType dataType, Map<String, String> config) {
+        return new ModbusTagHoldingRegister(address, count, dataType, config);
     }
 
-    private static ModbusTag createInputRegister(int address, int count, 
ModbusDataType dataType) {
-        return new ModbusTagInputRegister(address, count, dataType, 
Collections.emptyMap());
+    private static ModbusTag createInputRegister(int address, int count, 
ModbusDataType dataType, Map<String, String> config) {
+        return new ModbusTagInputRegister(address, count, dataType, config);
     }
 
-    private static ModbusTag createExtendedRegister(int address, int count, 
ModbusDataType dataType) {
-        return new ModbusTagExtendedRegister(address, count, dataType, 
Collections.emptyMap());
+    private static ModbusTag createExtendedRegister(int address, int count, 
ModbusDataType dataType, Map<String, String> config) {
+        return new ModbusTagExtendedRegister(address, count, dataType, config);
     }
 
     private static byte[] byteSwap(byte[] in) {
@@ -297,7 +333,7 @@ public class ModbusReadOptimizer {
 
     @FunctionalInterface
     private interface TagFactory {
-        ModbusTag createTag(int address, int count, ModbusDataType dataType);
+        ModbusTag createTag(int address, int count, ModbusDataType dataType, 
Map<String, String> config);
     }
 
 }
diff --git 
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/base/optimizer/ModbusOptimizerTest.java
 
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/base/optimizer/ModbusReadOptimizerTest.java
similarity index 70%
rename from 
plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/base/optimizer/ModbusOptimizerTest.java
rename to 
plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/base/optimizer/ModbusReadOptimizerTest.java
index 2e923a69e4..1a17cdb00d 100644
--- 
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/base/optimizer/ModbusOptimizerTest.java
+++ 
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/base/optimizer/ModbusReadOptimizerTest.java
@@ -23,6 +23,7 @@ import org.apache.plc4x.java.modbus.base.tag.ModbusTagCoil;
 import org.apache.plc4x.java.modbus.base.tag.ModbusTagHoldingRegister;
 import org.apache.plc4x.java.modbus.readwrite.ModbusDataType;
 import org.apache.plc4x.java.modbus.types.ModbusByteOrder;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
@@ -35,7 +36,7 @@ import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.*;
 
-class ModbusOptimizerTest {
+class ModbusReadOptimizerTest {
 
     private static Stream<Arguments> coilInputData() {
         return Stream.of(
@@ -167,6 +168,81 @@ class ModbusOptimizerTest {
         processReadRequest(tags, check);
     }
 
+    /**
+     * Tags addressing different units may never be merged into a single block 
read, and the
+     * unit-id of a group has to be carried over to the merged tag - otherwise 
the connection
+     * would silently fall back to its default unit-id (see GitHub issue 
#2686).
+     */
+    @Test
+    void registersWithDifferentUnitIdsAreNotMerged() {
+        processReadRequest(new ModbusTag[]{
+                new ModbusTagHoldingRegister(0, 1, ModbusDataType.INT, 
Collections.singletonMap("unit-id", "2")),
+                new ModbusTagHoldingRegister(1, 1, ModbusDataType.INT, 
Collections.singletonMap("unit-id", "3"))
+            },
+            optimizedReads -> {
+                assertEquals(2, optimizedReads.size());
+                // The addresses would have been adjacent, so without the 
unit-id they'd be one block.
+                ModbusTag first = optimizedReads.getFirst().mergedTag;
+                assertEquals((short) 2, first.getUnitId());
+                assertEquals(0, first.getAddress());
+                assertEquals(1, first.getNumberOfElements());
+                ModbusTag second = optimizedReads.get(1).mergedTag;
+                assertEquals((short) 3, second.getUnitId());
+                assertEquals(1, second.getAddress());
+                assertEquals(1, second.getNumberOfElements());
+            });
+    }
+
+    @Test
+    void coilsWithDifferentUnitIdsAreNotMerged() {
+        processReadRequest(new ModbusTag[]{
+                new ModbusTagCoil(0, 1, ModbusDataType.BOOL, 
Collections.singletonMap("unit-id", "2")),
+                new ModbusTagCoil(1, 1, ModbusDataType.BOOL, 
Collections.singletonMap("unit-id", "3"))
+            },
+            optimizedReads -> {
+                assertEquals(2, optimizedReads.size());
+                assertEquals((short) 2, 
optimizedReads.getFirst().mergedTag.getUnitId());
+                assertEquals((short) 3, 
optimizedReads.get(1).mergedTag.getUnitId());
+            });
+    }
+
+    /**
+     * Tags of the same unit are still merged, and the merged tag keeps the 
unit-id.
+     */
+    @Test
+    void registersWithSameUnitIdAreMergedKeepingTheUnitId() {
+        processReadRequest(new ModbusTag[]{
+                new ModbusTagHoldingRegister(0, 1, ModbusDataType.INT, 
Collections.singletonMap("unit-id", "7")),
+                new ModbusTagHoldingRegister(1, 1, ModbusDataType.INT, 
Collections.singletonMap("unit-id", "7"))
+            },
+            optimizedReads -> {
+                assertEquals(1, optimizedReads.size());
+                ModbusTag mergedTag = optimizedReads.getFirst().mergedTag;
+                assertEquals((short) 7, mergedTag.getUnitId());
+                assertEquals(0, mergedTag.getAddress());
+                assertEquals(2, mergedTag.getNumberOfElements());
+            });
+    }
+
+    /**
+     * Tags with an explicit unit-id must not be mixed with tags that use the 
connection default.
+     */
+    @Test
+    void tagsWithoutUnitIdAreNotMergedWithTagsHavingOne() {
+        processReadRequest(new ModbusTag[]{
+                new ModbusTagHoldingRegister(0, 1, ModbusDataType.INT, 
Collections.emptyMap()),
+                new ModbusTagHoldingRegister(1, 1, ModbusDataType.INT, 
Collections.singletonMap("unit-id", "2"))
+            },
+            optimizedReads -> {
+                assertEquals(2, optimizedReads.size());
+                // Tags without a unit-id are grouped first and keep using the 
connection default.
+                assertNull(optimizedReads.getFirst().mergedTag.getUnitId());
+                assertEquals(0, 
optimizedReads.getFirst().mergedTag.getAddress());
+                assertEquals((short) 2, 
optimizedReads.get(1).mergedTag.getUnitId());
+                assertEquals(1, optimizedReads.get(1).mergedTag.getAddress());
+            });
+    }
+
     void processReadRequest(ModbusTag[] tags, CheckResult check) {
         ModbusReadOptimizer optimizer = new ModbusReadOptimizer(2000, 125, 
ModbusByteOrder.BIG_ENDIAN);
         LinkedHashMap<String, ModbusTag> tagMap = new LinkedHashMap<>();
diff --git 
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionRequestChainTest.java
 
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionRequestChainTest.java
index 3a93031132..3767db619e 100644
--- 
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionRequestChainTest.java
+++ 
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionRequestChainTest.java
@@ -39,6 +39,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -215,6 +216,43 @@ class ModbusRtuConnectionRequestChainTest {
             "a request that died in the queue must never reach the wire");
     }
 
+    /**
+     * A tag-level unit-id must end up on the wire instead of the connection 
default,
+     * for reads as well as writes (GitHub issue #2686). The connection 
default here is 1,
+     * so a frame addressed to unit 7 can only come from the tag.
+     */
+    @Test
+    void writeRequestUsesTheTagUnitIdOnTheWire() throws Exception {
+        ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+        ModbusRtuConnection connection = newConnectedConnection(transport);
+
+        connection.writeRequestBuilder()
+            .addTagAddress("value", "4x00001:INT{unit-id: 7}", 42)
+            .build()
+            .execute();
+
+        awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS);
+        assertEquals(7, transport.lastWrittenFrame()[0] & 0xFF,
+            "write must be addressed to the tag's unit id, not the connection 
default");
+    }
+
+    @Test
+    void readRequestUsesTheTagUnitIdOnTheWire() throws Exception {
+        ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+        ModbusRtuConnection connection = newConnectedConnection(transport);
+
+        connection.readRequestBuilder()
+            .addTagAddress("value", "4x00001:INT{unit-id: 7}")
+            .build()
+            .execute();
+
+        // Pre-fix the optimizer dropped the unit-id when building the 
block-read tag,
+        // so this frame went out addressed to the connection default (1).
+        awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS);
+        assertEquals(7, transport.lastWrittenFrame()[0] & 0xFF,
+            "read must be addressed to the tag's unit id, not the connection 
default");
+    }
+
     /**
      * Builds a connection wired to the given fake transport, and drives it
      * through the same construction/connect path as {@code 
ModbusRtuConnectionTest}.
@@ -281,6 +319,7 @@ class ModbusRtuConnectionRequestChainTest {
         private int readPosition;
         private boolean open = true;
         private final AtomicInteger writeCount = new AtomicInteger();
+        private final List<byte[]> writtenFrames = new 
CopyOnWriteArrayList<>();
         private final AtomicBoolean failNextWrite = new AtomicBoolean(false);
         private final AtomicBoolean failAllWrites = new AtomicBoolean(false);
         private final AtomicReference<Runnable> dataListener = new 
AtomicReference<>();
@@ -293,6 +332,11 @@ class ModbusRtuConnectionRequestChainTest {
             return writeCount.get();
         }
 
+        /** The bytes of the last frame handed to the transport (an RTU ADU 
starts with the unit id). */
+        byte[] lastWrittenFrame() {
+            return writtenFrames.isEmpty() ? null : 
writtenFrames.get(writtenFrames.size() - 1);
+        }
+
         void failNextWrite() {
             failNextWrite.set(true);
         }
@@ -349,6 +393,7 @@ class ModbusRtuConnectionRequestChainTest {
         @Override
         public void write(byte[] bytes) throws TransportException {
             writeCount.incrementAndGet();
+            writtenFrames.add(bytes.clone());
             if (failAllWrites.get() || failNextWrite.compareAndSet(true, 
false)) {
                 throw new TransportException("scripted write failure");
             }

Reply via email to