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 9f298a1bcc feat(plc4j/slmp): add Batch Write (0x1401) support (#2651)
9f298a1bcc is described below
commit 9f298a1bcc175f255066e109a9b69805298ad398
Author: LivingLikeKrillin <[email protected]>
AuthorDate: Tue Jul 21 19:41:43 2026 +0900
feat(plc4j/slmp): add Batch Write (0x1401) support (#2651)
* feat(plc4j/slmp): model Batch Write (0x1401) request in the mspec
Signed-off-by: Jooyoung Jung <[email protected]>
* test(plc4j/slmp): pin the Batch Write 0x1401 frame bytes
Signed-off-by: Jooyoung Jung <[email protected]>
* docs(plc4j/slmp): sync mspec header section index and SlmpRequestData
docblock for Batch Write
Signed-off-by: Jooyoung Jung <[email protected]>
* feat(plc4j/slmp): add SlmpDataType.encode (mirror of decode)
Signed-off-by: Jooyoung Jung <[email protected]>
* feat(plc4j/slmp): map Batch Write endCode to a per-tag response code
Signed-off-by: Jooyoung Jung <[email protected]>
* feat(plc4j/slmp): implement Batch Write onWrite path
Signed-off-by: Jooyoung Jung <[email protected]>
* refactor(plc4j/slmp): generalize single-frame ceiling wording for batch
Read/Write
Signed-off-by: Jooyoung Jung <[email protected]>
* test(plc4j/slmp): end-to-end Batch Write driver-testsuite case
Signed-off-by: Jooyoung Jung <[email protected]>
* fix(plc4j/slmp): declare write capability in SlmpDriver
SlmpDriver overrode canRead() but not canWrite(), so it inherited
DriverBase.canWrite() -> false, unlike every other writeable driver
(S7, Modbus, ADS, EIP). Now that Batch Write (command 0x1401) is
supported, override canWrite() to return true. Adds SlmpDriverTest
asserting the driver reports both canRead()==true and canWrite()==true,
mirroring the reflection idiom in S7DriverTest/AdsPlcDriverTest.
Signed-off-by: Jooyoung Jung <[email protected]>
* docs(plc4j/slmp): drop stale read-only wording now that Batch Write is
supported
The mspec 'Scope of this initial version' comment still described the
protocol as read-only even though it lists Batch Write (command 0x1401).
Remove the contradictory 'read-only' from the scope sentence and soften
the 'read-only road-map' phrase in the device-code comment to 'initial
road-map'. Comment-only edits; generated sources are unchanged.
Signed-off-by: Jooyoung Jung <[email protected]>
* docs(plc4j/slmp): drop stale read-only wording from the driver POM
description
Signed-off-by: Jooyoung Jung <[email protected]>
* docs(plc4j/slmp): correct SH-080008 section references against the manual
Verified the citations against SH(NA)-080008 (ed. AB): device access is
chapter 8, where section 8.1 is "Data to be Specified" (devices, device code
list, points), section 8.2 is "Batch Read and Write" (batch read 0x0401 AND
batch write 0x1401), 8.3 Random, 8.4 Multiple Blocks. The header's "Batch
Read
data layout ... section 8.1" was inaccurate (batch read/write is 8.2, not
8.1);
merge it with Batch Write under 8.2 and point the command list at chapter 7.
Also fix the same 8.1 mislabel on the batch-read worked example in
ParserSerializerTestsuite.xml and SlmpRequestBuildTest, and quote the full
8.2 section title ("Batch Read and Write") in the SlmpWriteRequest comment.
Signed-off-by: Jooyoung Jung <[email protected]>
* refactor(plc4j/slmp): address review feedback
- encode(): an un-coercible PlcValue (PlcIncompatibleDatatypeException) is
an
expected caller error that maps to INVALID_DATA -- log it at DEBUG
without a
stack trace instead of WARN; genuine buffer/encoding failures keep the
WARN.
- SlmpDriverTest: drop the reflection -- the test shares the driver's
package,
so the protected canRead()/canWrite() are directly accessible.
- SlmpTag: normalize the ceiling wording to "Batch Read/Write".
Signed-off-by: Jooyoung Jung <[email protected]>
* fix(plc4j/slmp): echo builder error items instead of NPE-masking them as
INTERNAL_ERROR
The request builders store a DefaultPlcTagErrorItem (INVALID_ADDRESS for an
unparseable address, INVALID_DATA for an un-coercible value) instead of
throwing; for such a tag getTag()/getPlcValue() return null. onWrite cast
the
null and NPEd in writeSingleTag, so the per-tag aggregation reported
INTERNAL_ERROR and masked the correct code. onRead had the same pre-existing
flaw for INVALID_ADDRESS and is fixed symmetrically.
Both loops now check request.getTagResponseCode(tagName) first (the pattern
SimulatedConnection.onWrite established) and echo a non-OK code straight
into
the response without sending anything for that tag.
Signed-off-by: Jooyoung Jung <[email protected]>
* fix(plc4j/slmp): reject unexpected payload on write success; document
timed-out-write semantics
Per SH-080008 a Batch Write success response carries no data, so a zero
endCode with a non-empty payload 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). mapWriteTag now takes the response payload, warns
and maps that combination to REMOTE_ERROR instead of a false OK.
Also generalizes the sendRequest javadoc caveat from reads to all requests
and spells out that a timed-out write may still have been applied by the
device (so retrying on REMOTE_ERROR may double-apply), renames the driver
testsuite to 'SLMP 3E Batch Read/Write' now that it covers writes, and
updates the stale mspec scope comment: the typed value codec and tag layer
are owned by the driver layer by design, not 'not built yet' (comment-only,
no generated-source changes).
Signed-off-by: Jooyoung Jung <[email protected]>
---------
Signed-off-by: Jooyoung Jung <[email protected]>
---
plc4j/drivers/slmp/pom.xml | 2 +-
.../plc4x/java/slmp/readwrite/SlmpRequestData.java | 2 +
.../java/slmp/readwrite/SlmpWriteRequest.java | 172 +++++++++++++++++++++
.../org/apache/plc4x/java/slmp/SlmpConnection.java | 78 +++++++++-
.../org/apache/plc4x/java/slmp/SlmpDataType.java | 73 ++++++++-
.../org/apache/plc4x/java/slmp/SlmpDriver.java | 5 +
.../apache/plc4x/java/slmp/SlmpResponseMapper.java | 17 ++
.../org/apache/plc4x/java/slmp/tag/SlmpTag.java | 6 +-
.../java/slmp/SlmpConnectionFailurePathTest.java | 16 ++
...hTest.java => SlmpConnectionWritePathTest.java} | 135 +++++-----------
.../plc4x/java/slmp/SlmpDataTypeEncodeTest.java | 109 +++++++++++++
.../org/apache/plc4x/java/slmp/SlmpDriverTest.java | 35 +++++
.../plc4x/java/slmp/SlmpErrorMappingTest.java | 20 +++
.../plc4x/java/slmp/SlmpRequestBuildTest.java | 19 ++-
.../test/resources/slmp/slmp-driver-testsuite.xml | 31 +++-
.../src/main/resources/protocols/slmp/slmp.mspec | 36 +++--
.../protocols/slmp/ParserSerializerTestsuite.xml | 2 +-
17 files changed, 638 insertions(+), 120 deletions(-)
diff --git a/plc4j/drivers/slmp/pom.xml b/plc4j/drivers/slmp/pom.xml
index d8ca50f3bf..d07abc4f3b 100644
--- a/plc4j/drivers/slmp/pom.xml
+++ b/plc4j/drivers/slmp/pom.xml
@@ -28,7 +28,7 @@
<artifactId>plc4j-driver-slmp</artifactId>
<name>PLC4J: Driver: SLMP</name>
- <description>Implementation of a PLC4X read-only driver for the SLMP /
MELSEC Communication 3E protocol.</description>
+ <description>Implementation of a PLC4X driver for the SLMP / MELSEC
Communication 3E protocol.</description>
<properties>
<project.build.outputTimestamp>2025-08-02T13:55:11Z</project.build.outputTimestamp>
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 abstract class SlmpRequestData implements Message {
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.PlcConnectionException;
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.SlmpMessage;
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 @@ public class SlmpConnection extends
ConnectionBase<SlmpConfiguration> {
* <p>
* 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.
* <p>
* 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 @@ public class SlmpConnection extends
ConnectionBase<SlmpConfiguration> {
LinkedHashMap<String, CompletableFuture<PlcResponseItem<PlcValue>>>
tagFutures = new LinkedHashMap<>();
CompletableFuture<Void> 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<PlcResponseItem<PlcValue>> tagFuture =
chain.thenComposeAsync(v -> readSingleTag(tag));
@@ -244,4 +259,63 @@ public class SlmpConnection extends
ConnectionBase<SlmpConfiguration> {
sendRequest(frame).thenApply(response ->
SlmpResponseMapper.mapTag(tag, response.getEndCode(),
response.getResponseData())));
}
+
+ @Override
+ protected CompletableFuture<PlcWriteResponse> 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<String, CompletableFuture<PlcResponseCode>> tagFutures =
new LinkedHashMap<>();
+ CompletableFuture<Void> 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<PlcResponseCode> tagFuture =
+ chain.thenComposeAsync(v -> writeSingleTag(tag, value));
+ tagFutures.put(tagName, tagFuture);
+ chain = tagFuture.handle((r, e) -> null);
+ }
+ CompletableFuture<Void> allDone =
+ CompletableFuture.allOf(tagFutures.values().toArray(new
CompletableFuture[0]));
+ return allDone.handle((v, anyTagFailure) -> {
+ Map<String, PlcResponseCode> responseCodes = new LinkedHashMap<>();
+ for (Map.Entry<String, CompletableFuture<PlcResponseCode>> 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<PlcResponseCode> 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.ArrayList;
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 enum SlmpDataType {
}
}
+ /**
+ * 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 @@ public class SlmpDriver extends DriverBase {
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 @@ final class SlmpResponseMapper {
}
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 {
"^(?<device>[A-Za-z]+)(?<hexPrefix>0[xX])?(?<address>[0-9A-Fa-f]+)" +
"(:(?<datatype>[A-Za-z_]+))?(\\[(?<quantity>\\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 class SlmpTag implements PlcTag, Serializable {
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 class SlmpTag implements PlcTag, Serializable {
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 @@ class SlmpConnectionFailurePathTest {
"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/SlmpConnectionFailurePathTest.java
b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionWritePathTest.java
similarity index 58%
copy from
plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java
copy to
plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionWritePathTest.java
index 736e61da13..1443143997 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/SlmpConnectionWritePathTest.java
@@ -18,7 +18,7 @@
*/
package org.apache.plc4x.java.slmp;
-import org.apache.plc4x.java.api.messages.PlcReadResponse;
+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;
@@ -26,138 +26,87 @@ 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.api.exceptions.PlcConnectionException;
-import org.apache.plc4x.java.api.exceptions.PlcRuntimeException;
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.AtomicBoolean;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
- * Failure-path coverage for the single-slot request/response correlation in
- * {@link SlmpConnection}: 3E frames carry no correlation id, so a timeout, a
- * lost transport or an unsolicited frame must degrade exactly one tag (or
- * nothing) instead of poisoning the connection. Mirrors the style of
- * {@code ModbusRtuConnectionRequestChainTest}.
+ * 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 SlmpConnectionFailurePathTest {
+class SlmpConnectionWritePathTest {
@Test
- void timedOutTagIsIsolatedToRemoteErrorWhileOthersSucceed() throws
Exception {
+ void successfulWriteMapsToOk() throws Exception {
ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
- SlmpConnection connection = newConnectedConnection(transport, 200);
-
- CompletableFuture<? extends PlcReadResponse> responseFuture =
connection.readRequestBuilder()
- .addTagAddress("answered", "D350")
- .addTagAddress("silent", "D351")
+ SlmpConnection connection = newConnectedConnection(transport, 5000);
+ CompletableFuture<? extends PlcWriteResponse> future =
connection.writeRequestBuilder()
+ .addTagAddress("v", "D350", 0x1234)
.build().execute();
-
- // Sequential per-tag chain: only the first read may be on the wire.
awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS);
- transport.deliver(responseFrame(0x0000, new byte[]{(byte) 0xAB,
0x56}));
+ transport.deliver(responseFrame(0x0000, new byte[0])); // Batch Write
success: empty payload
transport.runDataListener();
-
- // The second read dispatches once the first completes — and is never
answered.
- awaitTrue(() -> transport.writeCount() == 2, 2, TimeUnit.SECONDS);
-
- PlcReadResponse response = responseFuture.get(5, TimeUnit.SECONDS);
- assertEquals(PlcResponseCode.OK, response.getResponseCode("answered"));
- assertEquals(0x56AB, response.getPlcValue("answered").getInteger());
- assertEquals(PlcResponseCode.REMOTE_ERROR,
response.getResponseCode("silent"),
- "a device that does not answer in time must surface as
REMOTE_ERROR for that tag only");
+ PlcWriteResponse response = future.get(5, TimeUnit.SECONDS);
+ assertEquals(PlcResponseCode.OK, response.getResponseCode("v"));
}
@Test
- void transportDisconnectFailsThePendingTagAsInternalError() throws
Exception {
+ void deviceErrorMapsToRemoteError() throws Exception {
ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
SlmpConnection connection = newConnectedConnection(transport, 5000);
-
- CompletableFuture<? extends PlcReadResponse> responseFuture =
connection.readRequestBuilder()
- .addTagAddress("pending", "D350")
+ CompletableFuture<? extends PlcWriteResponse> future =
connection.writeRequestBuilder()
+ .addTagAddress("v", "D350", 0x1234)
.build().execute();
awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS);
-
- connection.onTransportDisconnected(new PlcRuntimeException("link
down"));
-
- PlcReadResponse response = responseFuture.get(5, TimeUnit.SECONDS);
- assertEquals(PlcResponseCode.INTERNAL_ERROR,
response.getResponseCode("pending"),
- "losing the transport must fail the in-flight tag instead of
stranding its future");
+ 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 failedSendFailsOnlyThatTag() throws Exception {
+ void timedOutWriteIsRemoteError() throws Exception {
ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
- SlmpConnection connection = newConnectedConnection(transport, 5000);
-
- transport.failNextWrite();
- CompletableFuture<? extends PlcReadResponse> responseFuture =
connection.readRequestBuilder()
- .addTagAddress("unsendable", "D350")
+ SlmpConnection connection = newConnectedConnection(transport, 200);
+ CompletableFuture<? extends PlcWriteResponse> future =
connection.writeRequestBuilder()
+ .addTagAddress("v", "D350", 0x1234)
.build().execute();
-
- PlcReadResponse response = responseFuture.get(5, TimeUnit.SECONDS);
- assertEquals(PlcResponseCode.INTERNAL_ERROR,
response.getResponseCode("unsendable"));
+ PlcWriteResponse response = future.get(5, TimeUnit.SECONDS);
+ assertEquals(PlcResponseCode.REMOTE_ERROR,
response.getResponseCode("v"));
}
@Test
- void unsolicitedFrameIsIgnoredAndTheConnectionStaysUsable() throws
Exception {
+ void builderErrorItemsAreEchoedNotMaskedAsInternalError() throws Exception
{
ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
SlmpConnection connection = newConnectedConnection(transport, 5000);
- // A frame nobody asked for must be dropped (logged), not blow up the
receiver.
- transport.deliver(responseFrame(0x0000, new byte[]{0x01, 0x02}));
- transport.runDataListener();
-
- // A regular read afterwards still works — the slot was not poisoned.
- CompletableFuture<? extends PlcReadResponse> responseFuture =
connection.readRequestBuilder()
- .addTagAddress("after", "D350")
+ 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);
- transport.deliver(responseFrame(0x0000, new byte[]{(byte) 0xAB,
0x56}));
- transport.runDataListener();
- PlcReadResponse response = responseFuture.get(5, TimeUnit.SECONDS);
- assertEquals(PlcResponseCode.OK, response.getResponseCode("after"));
- assertEquals(0x56AB, response.getPlcValue("after").getInteger());
- }
-
- @Test
- void connectRejectsMonitoringTimerOutsideUnsigned16Range() {
- SlmpConfiguration config = new SlmpConfiguration();
- config.setRequestTimeout(5000);
- config.setMonitoringTimer(0x1_0000); // one past the uint16 ceiling
serialized into the 3E frame
-
- SlmpConnection connection = newDisconnectedConnection(config);
- assertThrows(PlcConnectionException.class, connection::connect,
- "an out-of-range monitoring-timer must be rejected at connect,
before it can be truncated on the wire");
- }
-
- @Test
- void connectRejectsNonPositiveRequestTimeout() {
- SlmpConfiguration config = new SlmpConfiguration();
- config.setRequestTimeout(0); // orTimeout(0) would time out every
request immediately
- config.setMonitoringTimer(0x0000);
+ awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS);
// only the good tag reaches the wire
+ transport.deliver(responseFrame(0x0000, new byte[0]));
+ transport.runDataListener();
- SlmpConnection connection = newDisconnectedConnection(config);
- assertThrows(PlcConnectionException.class, connection::connect,
- "a non-positive request-timeout must be rejected at connect rather
than failing every read");
- }
-
- private static SlmpConnection newDisconnectedConnection(SlmpConfiguration
config) {
- AuditLog auditLog = mock(AuditLog.class);
- when(auditLog.isEnabled()).thenReturn(false);
- return new SlmpConnection(config, new ScriptedAsyncTransport(),
auditLog);
+ 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)
@@ -202,7 +151,6 @@ class SlmpConnectionFailurePathTest {
private int readPosition;
private boolean open = true;
private final AtomicInteger writeCount = new AtomicInteger();
- private final AtomicBoolean failNextWrite = new AtomicBoolean(false);
private final AtomicReference<Runnable> dataListener = new
AtomicReference<>();
void deliver(byte[] bytes) {
@@ -213,10 +161,6 @@ class SlmpConnectionFailurePathTest {
return writeCount.get();
}
- void failNextWrite() {
- failNextWrite.set(true);
- }
-
void runDataListener() {
Runnable listener = dataListener.get();
if (listener == null) {
@@ -261,9 +205,6 @@ class SlmpConnectionFailurePathTest {
@Override
public void write(byte[] bytes) throws TransportException {
writeCount.incrementAndGet();
- if (failNextWrite.compareAndSet(true, false)) {
- throw new TransportException("scripted write failure");
- }
}
@Override
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 @@ class SlmpErrorMappingTest {
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 @@ package org.apache.plc4x.java.slmp;
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 @@ class SlmpRequestBuildTest {
@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 @@ class SlmpRequestBuildTest {
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.
-->
<test:driver-testsuite
xmlns:test="https://plc4x.apache.org/schemas/driver-testsuite.xsd"
byteOrder="LITTLE_ENDIAN">
- <name>SLMP 3E Batch Read</name>
+ <name>SLMP 3E Batch Read/Write</name>
<protocol-name>slmp</protocol-name>
<output-flavor>read-write</output-flavor>
<driver-name>slmp</driver-name>
@@ -67,4 +67,33 @@
</api-response>
</steps>
</testcase>
+ <testcase>
+ <name>Batch Write D350 = 0x1234</name>
+ <steps>
+ <api-request name="Write request">
+ <PlcWriteRequest>
+ <tags isList="true">
+ <tag
className="org.apache.plc4x.java.utils.testutils.driver.internal.api.TestTag">
+ <name>v</name>
+ <address>D350</address>
+ <value>
+ <PlcWORD dataType="uint" bitLength="16">4660</PlcWORD>
+ </value>
+ </tag>
+ </tags>
+ </PlcWriteRequest>
+ </api-request>
+ <outgoing-plc-bytes name="Write request
bytes">500000ffff03000e000000011400005e0100a801003412</outgoing-plc-bytes>
+ <incoming-plc-bytes name="Write response
bytes">d00000ffff030002000000</incoming-plc-bytes>
+ <api-response name="Write response">
+ <PlcWriteResponse>
+ <responseCodes>
+ <v>
+ <ResponseCode>OK</ResponseCode>
+ </v>
+ </responseCodes>
+ </PlcWriteResponse>
+ </api-response>
+ </steps>
+ </testcase>
</test:driver-testsuite>
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)