> chunks = AdsTcpConnection.chunk(items(2), Integer.MAX_VALUE);
+ assertEquals(1, chunks.size());
+ assertEquals(List.of(0, 1), chunks.get(0));
+ }
+
+ @Test
+ void nonPositiveCapIsRejected() {
+ assertThrows(IllegalArgumentException.class, () -> AdsTcpConnection.chunk(items(1), 0));
+ assertThrows(IllegalArgumentException.class, () -> AdsTcpConnection.chunk(items(1), -1));
+ }
+}
diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/AdsSumMergeTest.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/AdsSumMergeTest.java
new file mode 100644
index 00000000000..7033fc26b2c
--- /dev/null
+++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/AdsSumMergeTest.java
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.ads;
+
+import org.apache.plc4x.java.ads.tag.SymbolicAdsTag;
+import org.apache.plc4x.java.api.messages.PlcReadResponse;
+import org.apache.plc4x.java.api.messages.PlcWriteResponse;
+import org.apache.plc4x.java.api.model.PlcTag;
+import org.apache.plc4x.java.api.types.PlcResponseCode;
+import org.apache.plc4x.java.api.value.PlcValue;
+import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcReadRequest;
+import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcReadResponse;
+import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcWriteRequest;
+import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcResponseItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcTagItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcTagValueItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.PlcTagItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.PlcTagValueItem;
+import org.apache.plc4x.java.spi.values.PlcDINT;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * How the per-group results of a split request are merged back into one response.
+ *
+ * {@link AdsSumChunkTest} covers the partitioning arithmetic, and the driver testsuite
+ * ({@code AdsDriverIT} over {@code protocols/ads/DriverTestsuite.xml}) covers the connection
+ * issuing one sum command per group, on the read and the write path. This covers the merge
+ * itself: each tag keeps its own group's result, a tag that failed before anything was sent
+ * keeps that failure code, and a tag no group answered for is reported rather than dropped.
+ */
+public class AdsSumMergeTest {
+
+ private static List names(int count) {
+ List names = new ArrayList<>(count);
+ for (int i = 0; i < count; i++) {
+ names.add("tag" + i);
+ }
+ return names;
+ }
+
+ /** A real request rather than a mock: only the tag names matter here. */
+ private static DefaultPlcReadRequest requestFor(List tagNames) {
+ LinkedHashMap> tags = new LinkedHashMap<>();
+ for (String name : tagNames) {
+ tags.put(name, new DefaultPlcTagItem<>(SymbolicAdsTag.of("Main." + name)));
+ }
+ return new DefaultPlcReadRequest(null, tags);
+ }
+
+ /** The write counterpart of {@link #requestFor}: again only the tag names matter. */
+ private static DefaultPlcWriteRequest writeRequestFor(List tagNames) {
+ LinkedHashMap> tags = new LinkedHashMap<>();
+ for (String name : tagNames) {
+ tags.put(name, new DefaultPlcTagValueItem<>(SymbolicAdsTag.of("Main." + name), new PlcDINT(0)));
+ }
+ return new DefaultPlcWriteRequest(null, tags);
+ }
+
+ private static PlcResponseItem ok(int value) {
+ return new DefaultPlcResponseItem<>(PlcResponseCode.OK, new PlcDINT(value));
+ }
+
+ /**
+ * Asserting on {@code response.getTagNames()} would prove nothing: {@link DefaultPlcReadResponse}
+ * answers that from the request, so it reads back in request order whatever the merge did. What
+ * can actually break is the pairing of each name with its own group's value.
+ */
+ @Test
+ void eachTagKeepsItsOwnValueWhateverOrderTheGroupsCameBackIn() {
+ List tagNames = names(3);
+ // The second group came back first and put its result in ahead of the first group's.
+ Map> accumulated = new LinkedHashMap<>();
+ accumulated.put("tag2", ok(22));
+ accumulated.put("tag0", ok(0));
+ accumulated.put("tag1", ok(11));
+
+ PlcReadResponse response =
+ AdsTcpConnection.assembleReadResponse(requestFor(tagNames), Map.of(), accumulated);
+
+ assertEquals(0, response.getInteger("tag0"));
+ assertEquals(11, response.getInteger("tag1"));
+ assertEquals(22, response.getInteger("tag2"));
+ }
+
+ @Test
+ void aTagThatFailedBeforeSendingKeepsItsOwnCode() {
+ List tagNames = names(2);
+ Map initialFailures = Map.of("tag0", PlcResponseCode.INVALID_ADDRESS);
+ Map> accumulated = new LinkedHashMap<>();
+ accumulated.put("tag1", ok(1));
+
+ PlcReadResponse response =
+ AdsTcpConnection.assembleReadResponse(requestFor(tagNames), initialFailures, accumulated);
+
+ assertEquals(PlcResponseCode.INVALID_ADDRESS, response.getResponseCode("tag0"));
+ assertEquals(PlcResponseCode.OK, response.getResponseCode("tag1"));
+ }
+
+ @Test
+ void aTagNoGroupAnsweredForIsReportedRatherThanDropped() {
+ List tagNames = names(2);
+ Map> accumulated = new LinkedHashMap<>();
+ accumulated.put("tag0", ok(0));
+
+ PlcReadResponse response =
+ AdsTcpConnection.assembleReadResponse(requestFor(tagNames), Map.of(), accumulated);
+
+ assertEquals(PlcResponseCode.INTERNAL_ERROR, response.getResponseCode("tag1"));
+ }
+
+ /** The write side pairs names with codes the same way; see the read case above on why. */
+ @Test
+ void eachWrittenTagKeepsItsOwnCodeWhateverOrderTheGroupsCameBackIn() {
+ List tagNames = names(3);
+ Map accumulated = new LinkedHashMap<>();
+ accumulated.put("tag2", PlcResponseCode.OK);
+ accumulated.put("tag0", PlcResponseCode.INVALID_DATA);
+ accumulated.put("tag1", PlcResponseCode.ACCESS_DENIED);
+
+ PlcWriteResponse response =
+ AdsTcpConnection.assembleWriteResponse(writeRequestFor(tagNames), Map.of(), accumulated);
+
+ assertEquals(PlcResponseCode.INVALID_DATA, response.getResponseCode("tag0"));
+ assertEquals(PlcResponseCode.ACCESS_DENIED, response.getResponseCode("tag1"));
+ assertEquals(PlcResponseCode.OK, response.getResponseCode("tag2"));
+ }
+
+ /** A tag that never made it onto the wire keeps its own code; one no group answered for is reported. */
+ @Test
+ void writeKeepsPreSendFailuresAndReportsUnansweredTags() {
+ List tagNames = names(3);
+ Map initialFailures = Map.of("tag0", PlcResponseCode.INVALID_DATA);
+ Map accumulated = new LinkedHashMap<>();
+ accumulated.put("tag1", PlcResponseCode.OK);
+
+ PlcWriteResponse response = AdsTcpConnection.assembleWriteResponse(
+ writeRequestFor(tagNames), initialFailures, accumulated);
+
+ assertEquals(PlcResponseCode.INVALID_DATA, response.getResponseCode("tag0"));
+ assertEquals(PlcResponseCode.OK, response.getResponseCode("tag1"));
+ assertEquals(PlcResponseCode.INTERNAL_ERROR, response.getResponseCode("tag2"));
+ }
+}
diff --git a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/configuration/AdsConfigurationTest.java b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/configuration/AdsConfigurationTest.java
index 7fba5d1c70c..ab9e1b0a70b 100644
--- a/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/configuration/AdsConfigurationTest.java
+++ b/plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/configuration/AdsConfigurationTest.java
@@ -39,6 +39,7 @@ void gettersAndSetters() {
cfg.setTimeoutRequest(1234);
cfg.setLoadSymbolAndDataTypeTables(false);
cfg.setMaxDataTypeTableDepth(64);
+ cfg.setMaxSumCommandItems(2);
assertEquals(target, cfg.getTargetAmsNetId());
assertEquals(851, cfg.getTargetAmsPort());
@@ -47,6 +48,7 @@ void gettersAndSetters() {
assertEquals(1234, cfg.getTimeoutRequest());
assertFalse(cfg.isLoadSymbolAndDataTypeTables());
assertEquals(64, cfg.getMaxDataTypeTableDepth());
+ assertEquals(2, cfg.getMaxSumCommandItems());
assertTrue(cfg.toString().contains("targetAmsPort=851"));
}
@@ -69,6 +71,33 @@ void maxDataTypeTableDepthDefaultsToTwenty() throws Exception {
"an unconfigured connection must get a usable budget, never 0");
}
+ /**
+ * The cap is Beckhoff's recommended ceiling for sub-ADS calls in one sum command. A default
+ * that failed to resolve would arrive as 0, and chunk() rejects a non-positive chunk size, so
+ * every sum read and write would fail outright - the default is pinned here rather than
+ * assumed.
+ */
+ @Test
+ void maxSumCommandItemsDefaultsToFiveHundred() throws Exception {
+ AdsConfiguration defaults = new ConfigurationFactory()
+ .createConfiguration(AdsConfiguration.class, REQUIRED_PARAMS);
+
+ assertEquals(500, defaults.getMaxSumCommandItems(),
+ "Beckhoff recommends at most 500 sub-ADS calls per sum command");
+ }
+
+ /**
+ * Lowering the cap is what makes the split testable end-to-end: the driver testsuite drives
+ * three tags with a cap of two rather than putting 501 tags in XML.
+ */
+ @Test
+ void maxSumCommandItemsCanBeLowered() throws Exception {
+ AdsConfiguration lowered = new ConfigurationFactory().createConfiguration(
+ AdsConfiguration.class, REQUIRED_PARAMS + "&max-sum-command-items=2");
+
+ assertEquals(2, lowered.getMaxSumCommandItems());
+ }
+
@Test
void maxDataTypeTableDepthCanBeRaised() throws Exception {
AdsConfiguration raised = new ConfigurationFactory().createConfiguration(
diff --git a/protocols/ads/src/test/resources/protocols/ads/DriverTestsuite.xml b/protocols/ads/src/test/resources/protocols/ads/DriverTestsuite.xml
index b708cff6d55..256aec2bac1 100644
--- a/protocols/ads/src/test/resources/protocols/ads/DriverTestsuite.xml
+++ b/protocols/ads/src/test/resources/protocols/ads/DriverTestsuite.xml
@@ -43,6 +43,13 @@
target-ams-port
851
+
+
+ max-sum-command-items
+ 2
+
@@ -2242,4 +2249,997 @@
+
+ Multi-element direct read request split across two sum commands
+
+ Three direct addresses with max-sum-command-items lowered to two. The driver has to send
+ two ADSIGRP_MULTIPLE_READ sum commands, the first carrying two sub-commands and the second
+ the remaining one, and merge the two responses back into one response covering all three
+ tags in request order.
+
+
+
+
+
+
+ hurz1
+ 00004040/00000001:BOOL
+
+
+ hurz2
+ 00004040/00000002:BOOL
+
+
+ hurz3
+ 00004040/00000003:BOOL
+
+
+
+
+
+
+ 0
+ 72
+
+
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ false
+ false
+ 0
+ 40
+
+ 0
+
+ 9
+
+ 61568
+ 2
+ 10
+ 24
+
+
+
+ 4040
+ 1
+ 1
+
+
+
+
+ 4040
+ 2
+ 1
+
+
+
+ 0x
+
+
+
+
+
+
+
+ 0
+ 50
+
+
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ true
+ false
+ 0
+ 18
+
+ 0
+
+ 9
+
+
+ 0
+
+ 10
+ 0x00000000000000000100
+
+
+
+
+
+
+
+ 0
+ 60
+
+
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ false
+ false
+ 0
+ 28
+
+ 0
+
+ 10
+
+ 61568
+ 1
+ 5
+ 12
+
+
+
+ 4040
+ 3
+ 1
+
+
+
+ 0x
+
+
+
+
+
+
+
+ 0
+ 45
+
+
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ true
+ false
+ 0
+ 13
+
+ 0
+
+ 10
+
+
+ 0
+
+ 5
+ 0x0000000001
+
+
+
+
+
+
+
+
+
+
+
+
+ 4040
+ 1
+ 1
+ BOOL
+
+
+
+
+ 4040
+ 2
+ 1
+ BOOL
+
+
+
+
+ 4040
+ 3
+ 1
+ BOOL
+
+
+
+
+
+
+
+
+ OK
+
+ true
+
+
+
+
+
+ OK
+
+ false
+
+
+
+
+
+ OK
+
+ true
+
+
+
+
+
+
+ 1000
+
+
+
+
+ Multi-element direct write request split across two sum commands
+
+ The write counterpart of the split read above: three direct addresses with
+ max-sum-command-items lowered to two, so the driver has to send two
+ ADSIGRP_MULTIPLE_WRITE sum commands, the first carrying two sub-commands and their two
+ payload bytes and the second the remaining one. Merging the per-group return codes is
+ covered at unit level by AdsSumMergeTest; what this adds is that the connection really
+ does issue one sum command per group on the write path too.
+
+
+
+
+
+
+ hurz1
+ 00004040/00000001:BOOL
+
+ true
+
+
+
+ hurz2
+ 00004040/00000002:BOOL
+
+ false
+
+
+
+ hurz3
+ 00004040/00000003:BOOL
+
+ true
+
+
+
+
+
+
+
+ 0
+ 74
+
+
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ false
+ false
+ 0
+ 42
+
+ 0
+
+ 9
+
+ 61569
+ 2
+ 8
+ 26
+
+
+
+ 4040
+ 1
+ 1
+
+
+
+
+ 4040
+ 2
+ 1
+
+
+
+ 0x0100
+
+
+
+
+
+
+
+ 0
+ 48
+
+
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ true
+ false
+ 0
+ 16
+
+ 0
+
+ 9
+
+
+ 0
+
+ 8
+ 0x0000000000000000
+
+
+
+
+
+
+
+ 0
+ 61
+
+
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ false
+ false
+ 0
+ 29
+
+ 0
+
+ 10
+
+ 61569
+ 1
+ 4
+ 13
+
+
+
+ 4040
+ 3
+ 1
+
+
+
+ 0x01
+
+
+
+
+
+
+
+ 0
+ 44
+
+
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ true
+ false
+ 0
+ 12
+
+ 0
+
+ 10
+
+
+ 0
+
+ 4
+ 0x00000000
+
+
+
+
+
+
+
+
+
+ OK
+
+
+ OK
+
+
+ OK
+
+
+
+
+
+
+
+
+ One failing group does not fail the tags of the other group
+
+ Same three direct addresses and the same cap of two, but the device answers the second sum
+ command with ADSERR_DEVICE_SYMBOLNOTFOUND. The tags of the first group keep their real
+ values, and only the tag of the failing group carries the mapped error code, rather than the
+ whole request failing or every tag degrading to INTERNAL_ERROR.
+
+
+
+
+
+
+ hurz1
+ 00004040/00000001:BOOL
+
+
+ hurz2
+ 00004040/00000002:BOOL
+
+
+ hurz3
+ 00004040/00000003:BOOL
+
+
+
+
+
+
+ 0
+ 72
+
+
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ false
+ false
+ 0
+ 40
+
+ 0
+
+ 9
+
+ 61568
+ 2
+ 10
+ 24
+
+
+
+ 4040
+ 1
+ 1
+
+
+
+
+ 4040
+ 2
+ 1
+
+
+
+ 0x
+
+
+
+
+
+
+
+ 0
+ 50
+
+
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ true
+ false
+ 0
+ 18
+
+ 0
+
+ 9
+
+
+ 0
+
+ 10
+ 0x00000000000000000100
+
+
+
+
+
+
+
+ 0
+ 60
+
+
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ false
+ false
+ 0
+ 28
+
+ 0
+
+ 10
+
+ 61568
+ 1
+ 5
+ 12
+
+
+
+ 4040
+ 3
+ 1
+
+
+
+ 0x
+
+
+
+
+
+
+
+ 0
+ 40
+
+
+
+
+ 192
+ 168
+ 23
+ 220
+ 1
+ 1
+
+
+ 65534
+
+
+ 192
+ 168
+ 23
+ 20
+ 1
+ 1
+
+
+ 851
+
+ 9
+
+ false
+ false
+ false
+ false
+ false
+ true
+ false
+ true
+ false
+ 0
+ 8
+
+ 0
+
+ 10
+
+
+ 1808
+
+ 0
+ 0x
+
+
+
+
+
+
+
+
+
+
+
+
+ 4040
+ 1
+ 1
+ BOOL
+
+
+
+
+ 4040
+ 2
+ 1
+ BOOL
+
+
+
+
+ 4040
+ 3
+ 1
+ BOOL
+
+
+
+
+
+
+
+
+ OK
+
+ true
+
+
+
+
+
+ OK
+
+ false
+
+
+
+
+
+ INVALID_ADDRESS
+
+
+
+
+
+
+
+
diff --git a/website/asciidoc/modules/users/partials/ads.adoc b/website/asciidoc/modules/users/partials/ads.adoc
index 536f0c65325..a455bb7a1c1 100644
--- a/website/asciidoc/modules/users/partials/ads.adoc
+++ b/website/asciidoc/modules/users/partials/ads.adoc
@@ -42,6 +42,7 @@
|`source-ams-port` |INT | |required |AMS port of the source.
|`request-timeout-ms` |INT |4000| |Default timeout for all types of requests.
|`max-data-type-table-depth` |INT |20| |Maximum nesting depth accepted when parsing the data-type table uploaded from the device. An entry may contain further entries, so without a limit the depth of the tree is dictated by the device rather than by the driver, and a table of well under a megabyte can nest deeply enough to exhaust the parser's stack. Real type hierarchies are only a handful of levels deep, so the default is already generous; raise it for a device that is known to need more. Note that the JVM's own stack imposes a practical ceiling of a few thousand levels regardless of what is configured here.
+|`max-sum-command-items` |INT |500| |Maximum number of sub-commands packed into a single ADS sum command (ADSIGRP_MULTIPLE_READ / ADSIGRP_MULTIPLE_WRITE). Requests referencing more tags are split into consecutive groups of at most this size and sent sequentially. The ceiling and the reason for it are Beckhoff's: the PLC works off a whole ADS request before starting its next PLC cycle, so a request carrying very many sub-commands makes it collect everything into one response first, and "this large number of ads-sub-commands will jitter the PLC execution". See "Number of Sub-ADS calls: Highly recommended to max. 500!" in the TwinCAT 3 ADS-DLL C++ manual version 1.1.3, section "ADS-sum command: Read or Write a list of variables with one single ADS-command", https://download.beckhoff.com/download/document/automation/twincat3/TwinCAT_ADS-DLL_C_EN.pdf . This bounds the item count only; the same section separately requires the caller to bound the response byte length, which this driver does not do yet. Must be at least 1.
|`load-symbol-and-data-type-tables` |BOOLEAN |true| |Configures, if when connecting the data-type- and symbol-table should be read. This is an optimization that can help in cases, where the PLC program is pretty large and downloading the full tables is causing problems. When disabled, reading and writing is limited to direct addresses (`\{IndexGroup}/\{IndexOffset}:\{TYPE}`): symbolic addresses cannot be resolved without the tables and are rejected with a corresponding error. Browsing is unavailable for the same reason. Subscriptions are unaffected, as they resolve symbol handles on the device.
5+|Transport config options:
5+|