Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
37a5e70
feat(plc4j/slmp): model Batch Write (0x1401) request in the mspec
LivingLikeKrillin Jul 20, 2026
fb48c02
test(plc4j/slmp): pin the Batch Write 0x1401 frame bytes
LivingLikeKrillin Jul 20, 2026
910a371
docs(plc4j/slmp): sync mspec header section index and SlmpRequestData…
LivingLikeKrillin Jul 20, 2026
3c3695c
feat(plc4j/slmp): add SlmpDataType.encode (mirror of decode)
LivingLikeKrillin Jul 20, 2026
a85b145
feat(plc4j/slmp): map Batch Write endCode to a per-tag response code
LivingLikeKrillin Jul 20, 2026
d82a5e7
feat(plc4j/slmp): implement Batch Write onWrite path
LivingLikeKrillin Jul 20, 2026
7fd1885
refactor(plc4j/slmp): generalize single-frame ceiling wording for bat…
LivingLikeKrillin Jul 20, 2026
9b31bb1
test(plc4j/slmp): end-to-end Batch Write driver-testsuite case
LivingLikeKrillin Jul 20, 2026
822d503
fix(plc4j/slmp): declare write capability in SlmpDriver
LivingLikeKrillin Jul 20, 2026
0f6abc9
docs(plc4j/slmp): drop stale read-only wording now that Batch Write i…
LivingLikeKrillin Jul 20, 2026
55557ee
docs(plc4j/slmp): drop stale read-only wording from the driver POM de…
LivingLikeKrillin Jul 20, 2026
65c87ac
docs(plc4j/slmp): correct SH-080008 section references against the ma…
LivingLikeKrillin Jul 20, 2026
006f344
refactor(plc4j/slmp): address review feedback
LivingLikeKrillin Jul 21, 2026
2bc9889
fix(plc4j/slmp): echo builder error items instead of NPE-masking them…
LivingLikeKrillin Jul 21, 2026
10f2f94
fix(plc4j/slmp): reject unexpected payload on write success; document…
LivingLikeKrillin Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plc4j/drivers/slmp/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]");
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -244,4 +249,54 @@ private CompletableFuture<PlcResponseItem<PlcValue>> readSingleTag(SlmpTag tag)
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.
DefaultPlcWriteRequest request = (DefaultPlcWriteRequest) writeRequest;
LinkedHashMap<String, CompletableFuture<PlcResponseCode>> tagFutures = new LinkedHashMap<>();
CompletableFuture<Void> chain = CompletableFuture.completedFuture(null);
for (String tagName : request.getTagNames()) {
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())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
*/
package org.apache.plc4x.java.slmp;

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;
Expand All @@ -37,9 +39,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),
Expand Down Expand Up @@ -90,6 +93,64 @@ 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 (BufferException | PlcRuntimeException e) {
LOGGER.warn("Failed to encode SLMP {} value", this, e);
return null;
}
Comment thread
sruehl marked this conversation as resolved.
}

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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,9 @@ protected ConnectionBase<?> getConnection(Configuration configuration,
protected boolean canRead() {
return true;
}

@Override
protected boolean canWrite() {
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,13 @@ static PlcResponseItem<PlcValue> 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) {
if (endCode != 0x0000) {
LOGGER.warn("SLMP device returned endCode {} for write {}", String.format("0x%04X", endCode), tag);
return PlcResponseCode.REMOTE_ERROR;
}
return PlcResponseCode.OK;
}
}
Loading
Loading