2025-08-02T13:55:11Z
diff --git a/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpRequestData.java b/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpRequestData.java
index 4394dd10ff..83610cf97a 100644
--- a/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpRequestData.java
+++ b/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpRequestData.java
@@ -52,6 +52,8 @@ public static SlmpRequestData staticParse(ReadBuffer readBuffer, int command) th
builder = SlmpRandomReadRequest.staticParseSlmpRequestDataBuilder(readBuffer, command);
} else if (EvaluationHelper.equals(command, (int) (0x0406))) {
builder = SlmpMultiBlockReadRequest.staticParseSlmpRequestDataBuilder(readBuffer, command);
+ } else if (EvaluationHelper.equals(command, (int) (0x1401))) {
+ builder = SlmpWriteRequest.staticParseSlmpRequestDataBuilder(readBuffer, command);
}
if (builder == null) {
throw new BufferException("Unsupported case for discriminated type parameters parameters [command]");
diff --git a/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpWriteRequest.java b/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpWriteRequest.java
new file mode 100644
index 0000000000..bd466b3654
--- /dev/null
+++ b/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpWriteRequest.java
@@ -0,0 +1,172 @@
+// 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.slmp.readwrite;
+
+import org.apache.plc4x.java.spi.buffers.api.Message;
+import org.apache.plc4x.java.spi.buffers.api.ReadBuffer;
+import org.apache.plc4x.java.spi.buffers.api.WithOption;
+import org.apache.plc4x.java.spi.buffers.api.WriteBuffer;
+import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException;
+import org.apache.plc4x.java.spi.fields.data.reader.DataReaderFactory;
+import org.apache.plc4x.java.spi.fields.data.writer.DataWriterFactory;
+import org.apache.plc4x.java.spi.fields.fields.reader.FieldReaderFactory;
+import org.apache.plc4x.java.spi.fields.fields.writer.FieldWriterFactory;
+import org.apache.plc4x.java.spi.fields.utils.ThreadLocalHelper;
+
+/**
+ * Code generated by code-generation. DO NOT EDIT.
+ */
+public class SlmpWriteRequest extends SlmpRequestData implements Message {
+ protected final int headDeviceNumber;
+
+ protected final SlmpDeviceCode deviceCode;
+
+ protected final int numberOfPoints;
+
+ protected final byte[] writeData;
+
+ public SlmpWriteRequest(Integer headDeviceNumber, SlmpDeviceCode deviceCode,
+ Integer numberOfPoints, byte[] writeData) {
+ this.headDeviceNumber = headDeviceNumber;
+ this.deviceCode = deviceCode;
+ this.numberOfPoints = numberOfPoints;
+ this.writeData = writeData;
+ }
+
+ /**
+ * Discriminator field command
+ */
+ @Override
+ public int getCommand() {
+ return (int) 0x1401;
+ }
+
+ /**
+ * Property field headDeviceNumber
+ */
+ public int getHeadDeviceNumber() {
+ return headDeviceNumber;
+ }
+
+ /**
+ * Property field deviceCode
+ */
+ public SlmpDeviceCode getDeviceCode() {
+ return deviceCode;
+ }
+
+ /**
+ * Property field numberOfPoints
+ */
+ public int getNumberOfPoints() {
+ return numberOfPoints;
+ }
+
+ /**
+ * Property field writeData
+ */
+ public byte[] getWriteData() {
+ return writeData;
+ }
+
+ public static SlmpRequestDataBuilder staticParseSlmpRequestDataBuilder(ReadBuffer readBuffer,
+ int command) throws BufferException {
+ readBuffer.pushContext(WithOption.WithName("SlmpWriteRequest"));
+ int startPos = readBuffer.getPositionInBits();
+ boolean _lastItem = ThreadLocalHelper.lastItemThreadLocal.get();
+ // Simple Field: headDeviceNumber
+ int headDeviceNumber = FieldReaderFactory.readSimpleField(DataReaderFactory.readUnsignedInt(readBuffer, 24), WithOption.WithName("headDeviceNumber"));
+
+ // Simple Field (enum): deviceCode
+ SlmpDeviceCode deviceCode = FieldReaderFactory.readEnumField(DataReaderFactory.readEnum(SlmpDeviceCode::enumForValue, DataReaderFactory.readUnsignedShort(readBuffer, 8)), WithOption.WithName("deviceCode"));
+
+ // Simple Field: numberOfPoints
+ int numberOfPoints = FieldReaderFactory.readSimpleField(DataReaderFactory.readUnsignedInt(readBuffer, 16), WithOption.WithName("numberOfPoints"));
+
+ // Array Field: writeData
+ byte[] writeData = readBuffer.readBits(Math.toIntExact(((numberOfPoints) * (2)) * 8), WithOption.WithName("writeData"));
+
+ readBuffer.popContext();
+ return new SlmpRequestDataBuilderImpl(headDeviceNumber, deviceCode, numberOfPoints, writeData);
+ }
+
+ protected void serializeSlmpRequestDataChild(WriteBuffer writeBuffer) throws BufferException {
+ writeBuffer.pushContext(WithOption.WithName("SlmpWriteRequest"));
+ int startPos = writeBuffer.getPositionInBits();
+ boolean _lastItem = ThreadLocalHelper.lastItemThreadLocal.get();
+ // Simple Field: headDeviceNumber
+ FieldWriterFactory.writeSimpleField((int) headDeviceNumber, DataWriterFactory.writeUnsignedInt(writeBuffer, 24), WithOption.WithName("headDeviceNumber"));
+
+ // Simple Field (enum): deviceCode
+ FieldWriterFactory.writeSimpleEnumField((SlmpDeviceCode) deviceCode, DataWriterFactory.writeEnum(SlmpDeviceCode::getValue, SlmpDeviceCode::name, DataWriterFactory.writeUnsignedShort(writeBuffer, 8)), WithOption.WithName("deviceCode"));
+
+ // Simple Field: numberOfPoints
+ FieldWriterFactory.writeSimpleField((int) numberOfPoints, DataWriterFactory.writeUnsignedInt(writeBuffer, 16), WithOption.WithName("numberOfPoints"));
+
+ // Array Field: writeData
+ FieldWriterFactory.writeByteArrayField(writeData, DataWriterFactory.writeByteArray(writeBuffer, (int) ((writeData != null) ? writeData.length : 0)), WithOption.WithName("writeData"));
+
+ writeBuffer.popContext();
+ }
+
+ @Override
+ public int getLengthInBytes() {
+ return (int) Math.ceil((float) getLengthInBits() / 8.0);
+ }
+
+ @Override
+ public int getLengthInBits() {
+ int lengthInBits = super.getLengthInBits();
+ SlmpWriteRequest _value = this;
+ boolean _lastItem = ThreadLocalHelper.lastItemThreadLocal.get();
+ // Simple Field: headDeviceNumber
+ lengthInBits += 24;
+
+ // Simple Field: deviceCode
+ lengthInBits += 8;
+
+ // Simple Field: numberOfPoints
+ lengthInBits += 16;
+
+ // Array Field: writeData
+ lengthInBits += 8 * ((writeData != null) ? writeData.length : 0);
+
+ return lengthInBits;
+ }
+
+ public static class SlmpRequestDataBuilderImpl implements SlmpRequestData.SlmpRequestDataBuilder {
+ private final int headDeviceNumber;
+
+ private final SlmpDeviceCode deviceCode;
+
+ private final int numberOfPoints;
+
+ private final byte[] writeData;
+
+ public SlmpRequestDataBuilderImpl(int headDeviceNumber, SlmpDeviceCode deviceCode,
+ int numberOfPoints, byte[] writeData) {
+ this.headDeviceNumber = headDeviceNumber;
+ this.deviceCode = deviceCode;
+ this.numberOfPoints = numberOfPoints;
+ this.writeData = writeData;
+ }
+
+ public SlmpRequestData build() {
+ return new SlmpWriteRequest(headDeviceNumber, deviceCode, numberOfPoints, writeData);
+ }
+ }
+}
diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java
index 216c13fd55..ef0a65b298 100644
--- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java
@@ -22,6 +22,8 @@
import org.apache.plc4x.java.api.exceptions.PlcRuntimeException;
import org.apache.plc4x.java.api.messages.PlcReadRequest;
import org.apache.plc4x.java.api.messages.PlcReadResponse;
+import org.apache.plc4x.java.api.messages.PlcWriteRequest;
+import org.apache.plc4x.java.api.messages.PlcWriteResponse;
import org.apache.plc4x.java.api.types.ConnectionStateChangeType;
import org.apache.plc4x.java.api.types.PlcResponseCode;
import org.apache.plc4x.java.api.value.PlcValue;
@@ -30,12 +32,15 @@
import org.apache.plc4x.java.slmp.readwrite.SlmpReadRequest;
import org.apache.plc4x.java.slmp.readwrite.SlmpRequestFrame3E;
import org.apache.plc4x.java.slmp.readwrite.SlmpResponseFrame3E;
+import org.apache.plc4x.java.slmp.readwrite.SlmpWriteRequest;
import org.apache.plc4x.java.slmp.tag.SlmpTag;
import org.apache.plc4x.java.slmp.tag.SlmpTagHandler;
import org.apache.plc4x.java.spi.drivers.ConnectionBase;
import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException;
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.DefaultPlcWriteResponse;
import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcResponseItem;
import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem;
import org.apache.plc4x.java.spi.drivers.tags.PlcTagHandler;
@@ -168,8 +173,10 @@ private void failPending(Throwable cause) {
*
* Caveat: if a request times out, a late response for it may arrive on the receiver thread
* after the next request has installed its own slot, and would then be mis-attributed to that
- * next request. 3E cannot prevent this (no correlation key), so a timed-out read should be
- * treated as unreliable by the caller.
+ * next request. 3E cannot prevent this (no correlation key), so a timed-out request should be
+ * treated as unreliable by the caller. For a timed-out WRITE in particular the device may
+ * nevertheless have applied the write — the driver cannot tell — so blindly retrying a write
+ * that returned REMOTE_ERROR may apply it twice.
*
* All slot clean-up is identity-checked (compare-and-clear): the timeout callback runs on the
* delayer thread and may run after the throttle has already released the next request (OpenJDK
@@ -203,6 +210,14 @@ protected CompletableFuture onRead(PlcReadRequest readRequest)
LinkedHashMap>> tagFutures = new LinkedHashMap<>();
CompletableFuture chain = CompletableFuture.completedFuture(null);
for (String tagName : request.getTagNames()) {
+ PlcResponseCode buildCode = request.getTagResponseCode(tagName);
+ if (buildCode != null && buildCode != PlcResponseCode.OK) {
+ // The builder already rejected this tag (unparseable address): echo its code
+ // instead of dereferencing the null tag. Nothing is sent for this tag.
+ tagFutures.put(tagName,
+ CompletableFuture.completedFuture(new DefaultPlcResponseItem<>(buildCode, null)));
+ continue;
+ }
SlmpTag tag = (SlmpTag) request.getTag(tagName);
CompletableFuture> tagFuture =
chain.thenComposeAsync(v -> readSingleTag(tag));
@@ -244,4 +259,63 @@ private CompletableFuture> readSingleTag(SlmpTag tag)
sendRequest(frame).thenApply(response ->
SlmpResponseMapper.mapTag(tag, response.getEndCode(), response.getResponseData())));
}
+
+ @Override
+ protected CompletableFuture onWrite(PlcWriteRequest writeRequest) {
+ // Structural mirror of onRead; see there for the handle-vs-thenApply and per-tag partial-failure-isolation rationale.
+ // A tag that maps to REMOTE_ERROR after a timeout may still have been applied by the device; see sendRequest().
+ DefaultPlcWriteRequest request = (DefaultPlcWriteRequest) writeRequest;
+ LinkedHashMap> tagFutures = new LinkedHashMap<>();
+ CompletableFuture chain = CompletableFuture.completedFuture(null);
+ for (String tagName : request.getTagNames()) {
+ PlcResponseCode buildCode = request.getTagResponseCode(tagName);
+ if (buildCode != null && buildCode != PlcResponseCode.OK) {
+ // The builder already rejected this tag (bad address or un-coercible value):
+ // echo its code instead of dereferencing the null tag/value. Nothing is sent
+ // for this tag.
+ tagFutures.put(tagName, CompletableFuture.completedFuture(buildCode));
+ continue;
+ }
+ SlmpTag tag = (SlmpTag) request.getTag(tagName);
+ PlcValue value = request.getPlcValue(tagName);
+ CompletableFuture tagFuture =
+ chain.thenComposeAsync(v -> writeSingleTag(tag, value));
+ tagFutures.put(tagName, tagFuture);
+ chain = tagFuture.handle((r, e) -> null);
+ }
+ CompletableFuture allDone =
+ CompletableFuture.allOf(tagFutures.values().toArray(new CompletableFuture[0]));
+ return allDone.handle((v, anyTagFailure) -> {
+ Map responseCodes = new LinkedHashMap<>();
+ for (Map.Entry> e : tagFutures.entrySet()) {
+ try {
+ responseCodes.put(e.getKey(), e.getValue().join());
+ } catch (Exception ex) {
+ Throwable cause = (ex instanceof CompletionException && ex.getCause() != null)
+ ? ex.getCause() : ex;
+ PlcResponseCode code = (cause instanceof TimeoutException)
+ ? PlcResponseCode.REMOTE_ERROR : PlcResponseCode.INTERNAL_ERROR;
+ responseCodes.put(e.getKey(), code);
+ }
+ }
+ return (PlcWriteResponse) new DefaultPlcWriteResponse(request, responseCodes);
+ });
+ }
+
+ private CompletableFuture writeSingleTag(SlmpTag tag, PlcValue value) {
+ byte[] payload = tag.getDataType().encode(value, tag.getQuantity());
+ if (payload == null) {
+ return CompletableFuture.completedFuture(PlcResponseCode.INVALID_DATA);
+ }
+ // Invariant: encode() returns exactly quantity*wordsPerElement*2 == numberOfPoints*2 bytes,
+ // so the SlmpWriteRequest header word-count and payload length always agree (the generated
+ // serialize path derives length from writeData.length, so this must hold by construction).
+ SlmpWriteRequest data = new SlmpWriteRequest(
+ tag.getDeviceNumber(), tag.getDeviceCode(), tag.getNumberOfPoints(), payload);
+ SlmpRequestFrame3E frame = new SlmpRequestFrame3E(
+ getConfiguration().getMonitoringTimer(), 0x1401, 0x0000, data);
+ return executeThrottled(() ->
+ sendRequest(frame).thenApply(response ->
+ SlmpResponseMapper.mapWriteTag(tag, response.getEndCode(), response.getResponseData())));
+ }
}
diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java
index 18908a7216..386b4b313c 100644
--- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java
@@ -18,11 +18,14 @@
*/
package org.apache.plc4x.java.slmp;
+import org.apache.plc4x.java.api.exceptions.PlcIncompatibleDatatypeException;
+import org.apache.plc4x.java.api.exceptions.PlcRuntimeException;
import org.apache.plc4x.java.api.value.PlcValue;
import org.apache.plc4x.java.spi.buffers.api.WithOption;
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.buffers.bytebased.WithByteBasedOption;
+import org.apache.plc4x.java.spi.buffers.bytebased.WriteBufferByteBased;
import org.apache.plc4x.java.spi.values.PlcDINT;
import org.apache.plc4x.java.spi.values.PlcINT;
import org.apache.plc4x.java.spi.values.PlcList;
@@ -37,9 +40,10 @@
import java.util.List;
/**
- * Maps a supported PLC4X data type to its SLMP word footprint and the decode of
- * little-endian response words into a {@link PlcValue}. The SLMP wire layer leaves
- * {@code responseData} as raw bytes; this enum owns typed decoding (word units only).
+ * Maps a supported PLC4X data type to its SLMP word footprint and the conversion
+ * between little-endian SLMP words and a {@link PlcValue} in both directions. The SLMP
+ * wire layer leaves {@code responseData}/request data as raw bytes; this enum owns typed
+ * decoding and encoding of word-unit values (word units only).
*/
public enum SlmpDataType {
WORD(1),
@@ -90,6 +94,69 @@ public PlcValue decode(byte[] responseData, int quantity) {
}
}
+ /**
+ * Encode {@code quantity} elements of this type to little-endian SLMP request bytes
+ * ({@code 2 * wordsPerElement} per element). Returns {@code null} on a type/arity mismatch
+ * (caller maps to INVALID_DATA), symmetric with {@link #decode}.
+ */
+ public byte[] encode(PlcValue value, int quantity) {
+ int totalBytes = quantity * wordsPerElement * 2;
+ WriteBufferByteBased buffer = new WriteBufferByteBased(new byte[totalBytes],
+ WithByteBasedOption.WithByteOrder("LITTLE_ENDIAN"),
+ WithOption.WithUnsignedIntegerEncoding("unsigned-binary"),
+ WithOption.WithSignedIntegerEncoding("twos-complement"),
+ WithOption.WithFloatEncoding("IEEE754"));
+ try {
+ if (quantity == 1) {
+ if (value == null || value.isList()) {
+ return null;
+ }
+ writeOne(buffer, value);
+ } else {
+ if (value == null || !value.isList() || value.getList().size() != quantity) {
+ return null;
+ }
+ for (PlcValue element : value.getList()) {
+ writeOne(buffer, element);
+ }
+ }
+ return buffer.getBytes();
+ } catch (PlcIncompatibleDatatypeException e) {
+ // An un-coercible value is an expected caller error (mapped to INVALID_DATA), not an
+ // internal fault -- keep it out of the WARN log and skip the stack trace.
+ LOGGER.debug("Incompatible value for SLMP {} encode: {}", this, e.getMessage());
+ return null;
+ } catch (BufferException | PlcRuntimeException e) {
+ LOGGER.warn("Failed to encode SLMP {} value", this, e);
+ return null;
+ }
+ }
+
+ private void writeOne(WriteBufferByteBased buffer, PlcValue value) throws BufferException {
+ switch (this) {
+ case WORD:
+ case UINT:
+ // writeUnsignedInt(16, int) accepts the full 0..65535 range; writeUnsignedShort(16, short)
+ // takes a SIGNED short and would reject 65535 (0xFFFF) as negative, corrupting unsigned WORD/UINT.
+ buffer.writeUnsignedInt(16, value.getInt());
+ break;
+ case INT:
+ buffer.writeSignedShort(16, value.getShort());
+ break;
+ case DINT:
+ buffer.writeSignedInt(32, value.getInt());
+ break;
+ case UDINT:
+ buffer.writeUnsignedLong(32, value.getLong());
+ break;
+ case REAL:
+ buffer.writeFloat(32, value.getFloat());
+ break;
+ default:
+ throw new BufferException("Unsupported SLMP data type " + this);
+ }
+ }
+
private PlcValue readOne(ReadBufferByteBased buffer) throws BufferException {
switch (this) {
case WORD:
diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDriver.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDriver.java
index 0dc9b87e9f..4b5c4ec691 100644
--- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDriver.java
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDriver.java
@@ -93,4 +93,9 @@ protected ConnectionBase> getConnection(Configuration configuration,
protected boolean canRead() {
return true;
}
+
+ @Override
+ protected boolean canWrite() {
+ return true;
+ }
}
diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java
index 72f9547fe5..67d125db1e 100644
--- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java
@@ -47,4 +47,21 @@ static PlcResponseItem mapTag(SlmpTag tag, int endCode, byte[] respons
}
return new DefaultPlcResponseItem<>(PlcResponseCode.OK, value);
}
+
+ /** Maps a single Batch Write 3E response (endCode only; success carries no payload). */
+ static PlcResponseCode mapWriteTag(SlmpTag tag, int endCode, byte[] responseData) {
+ if (endCode != 0x0000) {
+ LOGGER.warn("SLMP device returned endCode {} for write {}", String.format("0x%04X", endCode), tag);
+ return PlcResponseCode.REMOTE_ERROR;
+ }
+ if (responseData != null && responseData.length > 0) {
+ // SH-080008: a Batch Write success response carries no data. A payload here is the
+ // signature of a mis-attributed response (e.g. a late read answer arriving after its
+ // own request timed out; 3E has no correlation id), so do not report a false OK.
+ LOGGER.warn("SLMP write success frame for {} unexpectedly carried {} payload byte(s); "
+ + "treating it as a possibly mis-attributed response", tag, responseData.length);
+ return PlcResponseCode.REMOTE_ERROR;
+ }
+ return PlcResponseCode.OK;
+ }
}
diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java
index 946930eb41..9fe90059f4 100644
--- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java
@@ -44,7 +44,7 @@ public class SlmpTag implements PlcTag, Serializable {
"^(?[A-Za-z]+)(?0[xX])?(?[0-9A-Fa-f]+)" +
"(:(?[A-Za-z_]+))?(\\[(?\\d+)])?$");
- /** Conservative single-frame word ceiling for 3E binary Batch Read (not the exact device max). */
+ /** Conservative single-frame word ceiling for 3E binary Batch Read/Write (not the exact device max). */
static final int MAX_POINTS = 960;
/** Device addresses are serialized as an unsigned 24-bit field in the 3E frame. */
@@ -135,7 +135,7 @@ public static SlmpTag of(String addressString) {
long numberOfPoints = (long) quantity * dataType.getWordsPerElement();
if (numberOfPoints > MAX_POINTS) {
throw new PlcInvalidTagException("requested " + numberOfPoints + " words exceeds the v0 single-frame "
- + "Batch Read ceiling of " + MAX_POINTS + " (no optimizer to split): " + addressString);
+ + "Batch Read/Write ceiling of " + MAX_POINTS + " (no optimizer to split): " + addressString);
}
return new SlmpTag(device, deviceNumber, dataType, quantity);
}
@@ -156,7 +156,7 @@ public int getQuantity() {
return quantity;
}
- /** Number of 16-bit words to request (Batch Read, word units). */
+ /** Number of 16-bit words to transfer (Batch Read/Write, word units). */
public int getNumberOfPoints() {
return quantity * dataType.getWordsPerElement();
}
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java
index 736e61da13..cecc6c0249 100644
--- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java
@@ -154,6 +154,22 @@ void connectRejectsNonPositiveRequestTimeout() {
"a non-positive request-timeout must be rejected at connect rather than failing every read");
}
+ @Test
+ void builderErrorItemIsEchoedForReads() throws Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ SlmpConnection connection = newConnectedConnection(transport, 5000);
+ CompletableFuture extends PlcReadResponse> future = connection.readRequestBuilder()
+ .addTagAddress("bad", "Z99") // unparseable device -> builder error item
+ .addTagAddress("good", "D350")
+ .build().execute();
+ awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS); // only the good tag reaches the wire
+ transport.deliver(responseFrame(0x0000, new byte[]{(byte) 0xAB, 0x56}));
+ transport.runDataListener();
+ PlcReadResponse response = future.get(5, TimeUnit.SECONDS);
+ assertEquals(PlcResponseCode.INVALID_ADDRESS, response.getResponseCode("bad"));
+ assertEquals(PlcResponseCode.OK, response.getResponseCode("good"));
+ }
+
private static SlmpConnection newDisconnectedConnection(SlmpConfiguration config) {
AuditLog auditLog = mock(AuditLog.class);
when(auditLog.isEnabled()).thenReturn(false);
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionWritePathTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionWritePathTest.java
new file mode 100644
index 0000000000..1443143997
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionWritePathTest.java
@@ -0,0 +1,225 @@
+/*
+ * 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.slmp;
+
+import org.apache.plc4x.java.api.messages.PlcWriteResponse;
+import org.apache.plc4x.java.api.types.PlcResponseCode;
+import org.apache.plc4x.java.slmp.config.SlmpConfiguration;
+import org.apache.plc4x.java.slmp.readwrite.SlmpResponseFrame3E;
+import org.apache.plc4x.java.spi.buffers.bytebased.WriteBufferByteBased;
+import org.apache.plc4x.java.spi.transports.api.AsyncTransportInstance;
+import org.apache.plc4x.java.spi.transports.api.config.TransportConfiguration;
+import org.apache.plc4x.java.spi.transports.api.exceptions.TransportException;
+import org.apache.plc4x.java.utils.auditlog.api.AuditLog;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Happy-path and error-mapping coverage for the SLMP Batch Write (0x1401) path
+ * in {@link SlmpConnection}: a successful device acknowledgement maps to OK, a
+ * non-zero endCode maps to REMOTE_ERROR, and a device that never answers times
+ * out to REMOTE_ERROR for that tag. Mirrors {@code SlmpConnectionFailurePathTest}.
+ */
+class SlmpConnectionWritePathTest {
+
+ @Test
+ void successfulWriteMapsToOk() throws Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ SlmpConnection connection = newConnectedConnection(transport, 5000);
+ CompletableFuture extends PlcWriteResponse> future = connection.writeRequestBuilder()
+ .addTagAddress("v", "D350", 0x1234)
+ .build().execute();
+ awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS);
+ transport.deliver(responseFrame(0x0000, new byte[0])); // Batch Write success: empty payload
+ transport.runDataListener();
+ PlcWriteResponse response = future.get(5, TimeUnit.SECONDS);
+ assertEquals(PlcResponseCode.OK, response.getResponseCode("v"));
+ }
+
+ @Test
+ void deviceErrorMapsToRemoteError() throws Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ SlmpConnection connection = newConnectedConnection(transport, 5000);
+ CompletableFuture extends PlcWriteResponse> future = connection.writeRequestBuilder()
+ .addTagAddress("v", "D350", 0x1234)
+ .build().execute();
+ awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS);
+ transport.deliver(responseFrame(0xC059, new byte[0]));
+ transport.runDataListener();
+ PlcWriteResponse response = future.get(5, TimeUnit.SECONDS);
+ assertEquals(PlcResponseCode.REMOTE_ERROR, response.getResponseCode("v"));
+ }
+
+ @Test
+ void timedOutWriteIsRemoteError() throws Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ SlmpConnection connection = newConnectedConnection(transport, 200);
+ CompletableFuture extends PlcWriteResponse> future = connection.writeRequestBuilder()
+ .addTagAddress("v", "D350", 0x1234)
+ .build().execute();
+ PlcWriteResponse response = future.get(5, TimeUnit.SECONDS);
+ assertEquals(PlcResponseCode.REMOTE_ERROR, response.getResponseCode("v"));
+ }
+
+ @Test
+ void builderErrorItemsAreEchoedNotMaskedAsInternalError() throws Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ SlmpConnection connection = newConnectedConnection(transport, 5000);
+
+ CompletableFuture extends PlcWriteResponse> future = connection.writeRequestBuilder()
+ .addTagAddress("badValue", "D350", 70000) // out of WORD range -> builder error item
+ .addTagAddress("badAddress", "Z99", 1) // unparseable device -> builder error item
+ .addTagAddress("good", "D351", 0x1234)
+ .build().execute();
+
+ awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS); // only the good tag reaches the wire
+ transport.deliver(responseFrame(0x0000, new byte[0]));
+ transport.runDataListener();
+
+ PlcWriteResponse response = future.get(5, TimeUnit.SECONDS);
+ assertEquals(PlcResponseCode.INVALID_DATA, response.getResponseCode("badValue"));
+ assertEquals(PlcResponseCode.INVALID_ADDRESS, response.getResponseCode("badAddress"));
+ assertEquals(PlcResponseCode.OK, response.getResponseCode("good"));
+ }
+
+ private static SlmpConnection newConnectedConnection(ScriptedAsyncTransport transport, int requestTimeoutMs)
+ throws Exception {
+ SlmpConfiguration config = new SlmpConfiguration();
+ config.setRequestTimeout(requestTimeoutMs);
+ config.setMonitoringTimer(0x0000);
+
+ AuditLog auditLog = mock(AuditLog.class);
+ when(auditLog.isEnabled()).thenReturn(false);
+
+ SlmpConnection connection = new SlmpConnection(config, transport, auditLog);
+ connection.connect();
+ return connection;
+ }
+
+ private static byte[] responseFrame(int endCode, byte[] data) throws Exception {
+ SlmpResponseFrame3E frame = new SlmpResponseFrame3E(endCode, data);
+ WriteBufferByteBased writeBuffer = new WriteBufferByteBased(new byte[frame.getLengthInBytes()]);
+ frame.serialize(writeBuffer);
+ return writeBuffer.getBytes();
+ }
+
+ private static void awaitTrue(BooleanSupplier condition, long timeout, TimeUnit unit) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + unit.toMillis(timeout);
+ while (!condition.getAsBoolean()) {
+ if (System.currentTimeMillis() > deadline) {
+ fail("condition not met within timeout");
+ }
+ Thread.sleep(10);
+ }
+ }
+
+ /**
+ * In-memory transport double implementing the async listener-registration
+ * contract {@code ConnectionBase.startReceiving} relies on. Bytes queued
+ * via {@link #deliver(byte[])} are consumed through the real
+ * TransportInstance read contract by the codec.
+ */
+ static final class ScriptedAsyncTransport implements AsyncTransportInstance {
+ private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ private int readPosition;
+ private boolean open = true;
+ private final AtomicInteger writeCount = new AtomicInteger();
+ private final AtomicReference dataListener = new AtomicReference<>();
+
+ void deliver(byte[] bytes) {
+ buffer.writeBytes(bytes);
+ }
+
+ int writeCount() {
+ return writeCount.get();
+ }
+
+ void runDataListener() {
+ Runnable listener = dataListener.get();
+ if (listener == null) {
+ throw new IllegalStateException("No data listener registered");
+ }
+ listener.run();
+ }
+
+ @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 = buffer.toByteArray();
+ 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) throws TransportException {
+ writeCount.incrementAndGet();
+ }
+
+ @Override
+ public void close() {
+ open = false;
+ }
+
+ @Override
+ public void registerDataListener(Runnable listener) {
+ dataListener.set(listener);
+ }
+
+ @Override
+ public void removeDataListener() {
+ dataListener.set(null);
+ }
+ }
+}
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDataTypeEncodeTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDataTypeEncodeTest.java
new file mode 100644
index 0000000000..641380f029
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDataTypeEncodeTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.slmp;
+
+import org.apache.plc4x.java.spi.values.PlcDATE;
+import org.apache.plc4x.java.spi.values.PlcDINT;
+import org.apache.plc4x.java.spi.values.PlcINT;
+import org.apache.plc4x.java.spi.values.PlcList;
+import org.apache.plc4x.java.spi.values.PlcREAL;
+import org.apache.plc4x.java.spi.values.PlcUDINT;
+import org.apache.plc4x.java.spi.values.PlcUINT;
+import org.apache.plc4x.java.spi.values.PlcWORD;
+import org.apache.plc4x.java.api.value.PlcValue;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SlmpDataTypeEncodeTest {
+
+ private static String toHex(byte[] b) {
+ StringBuilder sb = new StringBuilder();
+ for (byte x : b) {
+ sb.append(String.format("%02x", x));
+ }
+ return sb.toString();
+ }
+
+ @Test
+ void encodeSingleWordLittleEndian() {
+ assertEquals("3412", toHex(SlmpDataType.WORD.encode(new PlcWORD(0x1234), 1)));
+ }
+
+ @Test
+ void encodeWordHighBitUnsigned() {
+ assertEquals("ffff", toHex(SlmpDataType.WORD.encode(new PlcWORD(65535), 1)));
+ }
+
+ @Test
+ void encodeSignedIntTwosComplement() {
+ assertEquals("ffff", toHex(SlmpDataType.INT.encode(new PlcINT((short) -1), 1)));
+ }
+
+ @Test
+ void encodeDintLowWordFirst() {
+ // 0x00010002 -> word0 0x0002 (02 00), word1 0x0001 (01 00)
+ assertEquals("02000100", toHex(SlmpDataType.DINT.encode(new PlcDINT(0x00010002), 1)));
+ }
+
+ @Test
+ void encodeRealIeee754() {
+ // 1.0f = 0x3F800000, little-endian 00 00 80 3f
+ assertEquals("0000803f", toHex(SlmpDataType.REAL.encode(new PlcREAL(1.0f), 1)));
+ }
+
+ @Test
+ void encodeListWhenQuantityGreaterThanOne() {
+ PlcValue list = new PlcList(List.of(new PlcWORD(0x56AB), new PlcWORD(0x170F)));
+ assertEquals("ab560f17", toHex(SlmpDataType.WORD.encode(list, 2)));
+ }
+
+ @Test
+ void encodeReturnsNullOnArityMismatch() {
+ assertNull(SlmpDataType.WORD.encode(new PlcWORD(0x1234), 2));
+ assertNull(SlmpDataType.WORD.encode(new PlcList(List.of(new PlcWORD(1))), 2));
+ }
+
+ @Test
+ void encodeReturnsNullOnListInputWhenQuantityIsOne() {
+ assertNull(SlmpDataType.WORD.encode(new PlcList(List.of(new PlcWORD(1))), 1));
+ }
+
+ @Test
+ void encodeReturnsNullOnIncompatibleValue() {
+ // PlcDATE.getInt() throws PlcIncompatibleDatatypeException (no numeric coercion);
+ // the narrowed catch must map this to null rather than propagating.
+ assertNull(SlmpDataType.WORD.encode(new PlcDATE(LocalDate.of(2020, 1, 1)), 1));
+ }
+
+ @Test
+ void encodeDecodeRoundTrips() {
+ byte[] bytes = SlmpDataType.UDINT.encode(new PlcUDINT(0xDEADBEEFL), 1);
+ assertEquals(0xDEADBEEFL, SlmpDataType.UDINT.decode(bytes, 1).getLong());
+ }
+
+ @Test
+ void encodeUintRoundTrips() {
+ byte[] bytes = SlmpDataType.UINT.encode(new PlcUINT(40000), 1);
+ assertEquals(40000, SlmpDataType.UINT.decode(bytes, 1).getInt());
+ }
+}
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDriverTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDriverTest.java
new file mode 100644
index 0000000000..429bb70627
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDriverTest.java
@@ -0,0 +1,35 @@
+/*
+ * 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.slmp;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SlmpDriverTest {
+
+ private final SlmpDriver driver = new SlmpDriver();
+
+ @Test
+ void capabilities() {
+ // Same package as SlmpDriver, so the protected can* methods are directly accessible.
+ assertTrue(driver.canRead(), "canRead should return true");
+ assertTrue(driver.canWrite(), "canWrite should return true");
+ }
+}
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java
index 78b9de5c78..04a36a49d6 100644
--- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java
@@ -57,4 +57,24 @@ void shortResponseMapsToInvalidData() {
var item = SlmpResponseMapper.mapTag(tag, 0x0000, hex("ab56")); // only 1
assertEquals(PlcResponseCode.INVALID_DATA, item.getResponseCode());
}
+
+ @Test
+ void mapWriteTagOkOnZeroEndCode() {
+ assertEquals(PlcResponseCode.OK, SlmpResponseMapper.mapWriteTag(SlmpTag.of("D350"), 0x0000, new byte[0]));
+ }
+
+ @Test
+ void mapWriteTagRemoteErrorOnNonZeroEndCode() {
+ // Extra error information alongside a non-zero endCode is still just REMOTE_ERROR.
+ assertEquals(PlcResponseCode.REMOTE_ERROR,
+ SlmpResponseMapper.mapWriteTag(SlmpTag.of("D350"), 0xC059, new byte[]{0x03, 0x04}));
+ }
+
+ @Test
+ void mapWriteTagRejectsUnexpectedPayloadOnSuccess() {
+ // SH-080008: a Batch Write success response carries NO data. A payload on endCode 0
+ // is the signature of a mis-attributed (late read) response and must not map to OK.
+ assertEquals(PlcResponseCode.REMOTE_ERROR,
+ SlmpResponseMapper.mapWriteTag(SlmpTag.of("D350"), 0x0000, new byte[]{0x01, 0x02}));
+ }
}
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java
index 1795d9e798..a38c367d87 100644
--- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java
@@ -20,6 +20,7 @@
import org.apache.plc4x.java.slmp.readwrite.SlmpReadRequest;
import org.apache.plc4x.java.slmp.readwrite.SlmpRequestFrame3E;
+import org.apache.plc4x.java.slmp.readwrite.SlmpWriteRequest;
import org.apache.plc4x.java.slmp.tag.SlmpTag;
import org.apache.plc4x.java.spi.buffers.bytebased.WriteBufferByteBased;
import org.junit.jupiter.api.Test;
@@ -38,7 +39,7 @@ private static String toHex(byte[] b) {
@Test
void buildsBatchReadFrameMatchingSh080008Example() throws Exception {
- // SH-080008 section 8.1: read D350, 2 words.
+ // SH-080008 section 8.2 (Batch Read and Write): read D350, 2 words.
SlmpTag tag = SlmpTag.of("D350:WORD[2]");
SlmpReadRequest data = new SlmpReadRequest(
tag.getDeviceNumber(), tag.getDeviceCode(), tag.getNumberOfPoints());
@@ -49,4 +50,20 @@ void buildsBatchReadFrameMatchingSh080008Example() throws Exception {
assertEquals("500000ffff03000c000000010400005e0100a80200", toHex(buffer.getBytes()));
}
+
+ @Test
+ void buildsBatchWriteFrameForSingleWord() throws Exception {
+ // Batch Write D350 = one WORD 0x1234 (little-endian payload 34 12).
+ SlmpTag tag = SlmpTag.of("D350");
+ SlmpWriteRequest data = new SlmpWriteRequest(
+ tag.getDeviceNumber(), tag.getDeviceCode(), tag.getNumberOfPoints(),
+ new byte[]{0x34, 0x12});
+ SlmpRequestFrame3E frame = new SlmpRequestFrame3E(0x0000, 0x1401, 0x0000, data);
+
+ WriteBufferByteBased buffer = new WriteBufferByteBased(new byte[frame.getLengthInBytes()]);
+ frame.serialize(buffer);
+
+ // requestDataLength = 6 + (3+1+2+2) = 14 (0x000E); command 0x1401 -> 01 14 LE.
+ assertEquals("500000ffff03000e000000011400005e0100a801003412", toHex(buffer.getBytes()));
+ }
}
diff --git a/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml b/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml
index 03d224c60e..ae09036420 100644
--- a/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml
+++ b/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml
@@ -18,7 +18,7 @@
under the License.
-->
- SLMP 3E Batch Read
+ SLMP 3E Batch Read/Write
slmp
read-write
slmp
@@ -67,4 +67,33 @@
+
+ Batch Write D350 = 0x1234
+
+
+
+
+
+ v
+ D350
+
+ 4660
+
+
+
+
+
+ 500000ffff03000e000000011400005e0100a801003412
+ d00000ffff030002000000
+
+
+
+
+ OK
+
+
+
+
+
+
diff --git a/protocols/slmp/src/main/resources/protocols/slmp/slmp.mspec b/protocols/slmp/src/main/resources/protocols/slmp/slmp.mspec
index 20170095ae..9b6e149ac7 100644
--- a/protocols/slmp/src/main/resources/protocols/slmp/slmp.mspec
+++ b/protocols/slmp/src/main/resources/protocols/slmp/slmp.mspec
@@ -25,18 +25,19 @@
// - Subheader (50 00 / D0 00) ...... section 5.3
// - Access route (3E fixed value) .. chapter 6
// - Request/response data length ... section 5.3 (2-byte, little-endian)
-// - Commands (Batch/Random/block Read) chapter 7 / 8.1 / 8.3 / 8.4
-// - Device code list ............... section 8.1 (MELSEC-Q/L, 1-byte binary)
-// - Batch Read data layout ......... section 8.1 (binary, word units)
+// - Command list ................... chapter 7 (section 7.1)
+// - Device data to be specified .... section 8.1 (devices, device code list, points)
+// - Batch Read/Write data layout ... section 8.2 (binary, word units; Read 0x0401 / Write 0x1401)
// - Random Read data layout ........ section 8.3 (binary, word units)
// - Multi-block Read data layout ... section 8.4 (binary, word units)
//
-// Scope of this initial version: 3E binary frame, read-only, Batch Read
+// Scope of this initial version: 3E binary frame, Batch Read
// (command 0x0401), Random Read (command 0x0403) and Batch Read Multiple Blocks
-// (command 0x0406) in word units (subcommand 0x0000). This is the wire layer
-// only; typed value decoding
-// (INT/WORD/DINT/REAL) and the device-addressing tag layer are intentionally
-// NOT modelled here yet and will follow once the driver logic is built.
+// (command 0x0406) in word units (subcommand 0x0000), plus Batch Write
+// (command 0x1401) in word units (subcommand 0x0000). This is the wire layer
+// only; typed value encoding/decoding (INT/WORD/DINT/REAL) and the
+// device-addressing tag layer are owned by the driver layer and intentionally
+// not modelled in the wire spec.
// Validated hardware-free via the ParserSerializer test suite.
//
// All multi-byte numeric fields are little-endian (transmitted least-significant byte first).
@@ -51,7 +52,7 @@
// Device codes for MELSEC-Q/L series commands (subcommand 0x0000 / 0x0001),
// 1-byte binary, from the device code list in SH-080008 section 8.1.
-// Only the word/bit devices needed by the read-only road-map are listed.
+// Only the word/bit devices needed by the initial road-map are listed.
//
// These 1-byte binary device codes are confirmed identical in the Mitsubishi
// MELSEC iQ-F FX5 SLMP manual (JY997D56001) -- D=A8 W=B4 R=AF M=90 X=9C Y=9D
@@ -108,8 +109,9 @@
]
]
-// Request data, dispatched by command. Batch Read (0x0401) and Random Read
-// (0x0403) are modelled here; both are read-only, word units (subcommand 0x0000).
+// Request data, dispatched by command: Batch Read (0x0401), Random Read (0x0403),
+// Batch Read Multiple Blocks (0x0406) and Batch Write (0x1401) are modelled here,
+// all in word units (subcommand 0x0000).
[discriminatedType SlmpRequestData(uint 16 command)
[typeSwitch command
['0x0401' SlmpReadRequest
@@ -143,6 +145,18 @@
[array SlmpDeviceBlock wordDeviceBlocks count 'numberOfWordDeviceBlocks']
[array SlmpDeviceBlock bitDeviceBlocks count 'numberOfBitDeviceBlocks']
]
+ ['0x1401' SlmpWriteRequest
+ // Batch Write in word units (SH-080008 section 8.2 "Batch Read and Write"): the same
+ // device addressing as SlmpReadRequest (0x0401), followed by the data to write.
+ // numberOfPoints is the number of 16-bit words; writeData is exactly that many
+ // little-endian words carried as raw bytes (2 * numberOfPoints). Typed encoding
+ // (INT/WORD/DINT/REAL) is owned by the driver layer, symmetric with how the read
+ // response leaves responseData as raw bytes for the driver to decode.
+ [simple uint 24 headDeviceNumber]
+ [simple SlmpDeviceCode deviceCode]
+ [simple uint 16 numberOfPoints]
+ [array byte writeData count 'numberOfPoints * 2']
+ ]
]
]
diff --git a/protocols/slmp/src/test/resources/protocols/slmp/ParserSerializerTestsuite.xml b/protocols/slmp/src/test/resources/protocols/slmp/ParserSerializerTestsuite.xml
index 6054c1963b..db4d12cd00 100644
--- a/protocols/slmp/src/test/resources/protocols/slmp/ParserSerializerTestsuite.xml
+++ b/protocols/slmp/src/test/resources/protocols/slmp/ParserSerializerTestsuite.xml
@@ -23,7 +23,7 @@
is assembled per section 5.2 / chapter 6.
Batch Read (0x0401) word-unit example reading the values stored in D350 and
- D351 (section 8.1):
+ D351 (section 8.2 "Batch Read and Write"):
request data : 5E 01 00 A8 02 00 (head=D350, code=D, points=2)
response data: AB 56 0F 17 (D350=0x56AB, D351=0x170F)