future = pendingResponse.getAndSet(null);
+ if (future != null) {
+ future.completeExceptionally(cause);
+ }
+ }
+
+ /**
+ * Sends one 3E request and returns a future for its response. Because the 3E frame carries
+ * no transaction/correlation id and {@link #getMaxConcurrentRequests()} is 1 (so
+ * {@code executeThrottled} serializes I/O), a single {@code pendingResponse} slot suffices.
+ *
+ * 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.
+ *
+ * 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
+ * completes dependents LIFO), so an unconditional clear could wipe the successor's freshly
+ * installed slot and spuriously time it out too. The compare-and-clear is correct regardless
+ * of that ordering.
+ */
+ private CompletableFuture sendRequest(SlmpRequestFrame3E request) {
+ CompletableFuture responseFuture = new CompletableFuture<>();
+ pendingResponse.set(responseFuture);
+ try {
+ messageCodec.send(request);
+ } catch (MessageCodecException e) {
+ pendingResponse.compareAndSet(responseFuture, null);
+ responseFuture.completeExceptionally(new PlcRuntimeException("Failed to send SLMP request", e));
+ return responseFuture;
+ }
+ responseFuture.orTimeout(getConfiguration().getRequestTimeout(), TimeUnit.MILLISECONDS)
+ .whenComplete((r, e) -> {
+ if (e instanceof TimeoutException) {
+ pendingResponse.compareAndSet(responseFuture, null);
+ }
+ });
+ return responseFuture;
+ }
+
+ @Override
+ protected CompletableFuture onRead(PlcReadRequest readRequest) {
+ DefaultPlcReadRequest request = (DefaultPlcReadRequest) readRequest;
+
+ LinkedHashMap>> tagFutures = new LinkedHashMap<>();
+ CompletableFuture chain = CompletableFuture.completedFuture(null);
+ for (String tagName : request.getTagNames()) {
+ SlmpTag tag = (SlmpTag) request.getTag(tagName);
+ CompletableFuture> tagFuture =
+ chain.thenComposeAsync(v -> readSingleTag(tag));
+ tagFutures.put(tagName, tagFuture);
+ chain = tagFuture.handle((r, e) -> null);
+ }
+
+ CompletableFuture allDone =
+ CompletableFuture.allOf(tagFutures.values().toArray(new CompletableFuture[0]));
+ // handle, not thenApply: allOf completes exceptionally if ANY tag failed, but a failed
+ // tag must still yield a response with that tag mapped to an error code below.
+ return allDone.handle((v, anyTagFailure) -> {
+ Map> items = new LinkedHashMap<>();
+ for (Map.Entry>> e : tagFutures.entrySet()) {
+ try {
+ items.put(e.getKey(), e.getValue().join());
+ } catch (Exception ex) {
+ // Partial-failure isolation (v0): a transport/timeout error on one tag fails
+ // only that tag's entry rather than the whole request. A timeout is surfaced as
+ // REMOTE_ERROR (the device did not answer in time); any other failure is an
+ // INTERNAL_ERROR. See sendRequest() for the timeout/correlation caveat.
+ Throwable cause = (ex instanceof CompletionException && ex.getCause() != null)
+ ? ex.getCause() : ex;
+ PlcResponseCode code = (cause instanceof TimeoutException)
+ ? PlcResponseCode.REMOTE_ERROR : PlcResponseCode.INTERNAL_ERROR;
+ items.put(e.getKey(), new DefaultPlcResponseItem<>(code, null));
+ }
+ }
+ return (PlcReadResponse) new DefaultPlcReadResponse(request, items);
+ });
+ }
+
+ private CompletableFuture> readSingleTag(SlmpTag tag) {
+ SlmpReadRequest data = new SlmpReadRequest(
+ tag.getDeviceNumber(), tag.getDeviceCode(), tag.getNumberOfPoints());
+ SlmpRequestFrame3E frame = new SlmpRequestFrame3E(
+ getConfiguration().getMonitoringTimer(), 0x0401, 0x0000, data);
+ return executeThrottled(() ->
+ sendRequest(frame).thenApply(response ->
+ SlmpResponseMapper.mapTag(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
new file mode 100644
index 0000000000..18908a7216
--- /dev/null
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java
@@ -0,0 +1,113 @@
+/*
+ * 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.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.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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+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).
+ */
+public enum SlmpDataType {
+ WORD(1),
+ INT(1),
+ UINT(1),
+ DINT(2),
+ UDINT(2),
+ REAL(2);
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(SlmpDataType.class);
+
+ private final int wordsPerElement;
+
+ SlmpDataType(int wordsPerElement) {
+ this.wordsPerElement = wordsPerElement;
+ }
+
+ public int getWordsPerElement() {
+ return wordsPerElement;
+ }
+
+ /**
+ * Decode {@code quantity} elements of this type from little-endian SLMP response bytes.
+ * Returns {@code null} when {@code responseData} is shorter than required (caller maps to INVALID_DATA).
+ */
+ public PlcValue decode(byte[] responseData, int quantity) {
+ int requiredBytes = quantity * wordsPerElement * 2;
+ if (responseData == null || responseData.length < requiredBytes) {
+ return null;
+ }
+ ReadBufferByteBased buffer = new ReadBufferByteBased(responseData,
+ WithByteBasedOption.WithByteOrder("LITTLE_ENDIAN"),
+ WithOption.WithUnsignedIntegerEncoding("unsigned-binary"),
+ WithOption.WithSignedIntegerEncoding("twos-complement"),
+ WithOption.WithFloatEncoding("IEEE754"));
+ try {
+ if (quantity == 1) {
+ return readOne(buffer);
+ }
+ List values = new ArrayList<>(quantity);
+ for (int i = 0; i < quantity; i++) {
+ values.add(readOne(buffer));
+ }
+ return new PlcList(values);
+ } catch (BufferException e) {
+ LOGGER.warn("Failed to decode SLMP {} value", this, e);
+ return null;
+ }
+ }
+
+ private PlcValue readOne(ReadBufferByteBased buffer) throws BufferException {
+ switch (this) {
+ case WORD:
+ // readUnsignedInt(16) returns int 0..65535; readUnsignedShort(16) returns a SIGNED short
+ // (negative for values > 0x7FFF), which would corrupt unsigned WORD/UINT.
+ return new PlcWORD(buffer.readUnsignedInt(16));
+ case INT:
+ return new PlcINT(buffer.readSignedShort(16));
+ case UINT:
+ return new PlcUINT(buffer.readUnsignedInt(16));
+ case DINT:
+ return new PlcDINT(buffer.readSignedInt(32));
+ case UDINT:
+ return new PlcUDINT(buffer.readUnsignedLong(32));
+ case REAL:
+ return new PlcREAL(buffer.readFloat(32));
+ default:
+ throw new BufferException("Unsupported SLMP data type " + this);
+ }
+ }
+}
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
new file mode 100644
index 0000000000..0dc9b87e9f
--- /dev/null
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDriver.java
@@ -0,0 +1,96 @@
+/*
+ * 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.model.PlcTag;
+import org.apache.plc4x.java.slmp.config.SlmpConfiguration;
+import org.apache.plc4x.java.slmp.config.SlmpTcpTransportConfiguration;
+import org.apache.plc4x.java.slmp.tag.SlmpTag;
+import org.apache.plc4x.java.spi.config.Configuration;
+import org.apache.plc4x.java.spi.drivers.ConnectionBase;
+import org.apache.plc4x.java.spi.drivers.DriverBase;
+import org.apache.plc4x.java.spi.transports.api.Transport;
+import org.apache.plc4x.java.spi.transports.api.TransportInstance;
+import org.apache.plc4x.java.spi.transports.api.config.TransportConfiguration;
+import org.apache.plc4x.java.utils.auditlog.api.AuditLog;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+public class SlmpDriver extends DriverBase {
+
+ @Override
+ public String getProtocolCode() {
+ return "slmp";
+ }
+
+ @Override
+ public String getProtocolName() {
+ return "SLMP (MELSEC) 3E";
+ }
+
+ @Override
+ protected Class extends Configuration> getConfigurationClass() {
+ return SlmpConfiguration.class;
+ }
+
+ @Override
+ protected Class extends TransportConfiguration> getTransportConfigurationClass(Transport> transport) {
+ if ("tcp".equals(transport.getTransportCode())) {
+ return SlmpTcpTransportConfiguration.class;
+ }
+ return super.getTransportConfigurationClass(transport);
+ }
+
+ @Override
+ public Optional getDefaultTransportCode() {
+ return Optional.of("tcp");
+ }
+
+ @Override
+ public List getSupportedTransportCodes() {
+ return List.of("tcp", "test");
+ }
+
+ @Override
+ public Set defaultPorts(String transportCode) {
+ if ("tcp".equalsIgnoreCase(transportCode)) {
+ return Set.of(5007);
+ }
+ return Set.of();
+ }
+
+ @Override
+ public PlcTag prepareTag(String tagAddress) {
+ return SlmpTag.of(tagAddress);
+ }
+
+ @Override
+ protected ConnectionBase> getConnection(Configuration configuration,
+ TransportInstance> transportInstance,
+ AuditLog auditLog) {
+ return new SlmpConnection((SlmpConfiguration) configuration, transportInstance, auditLog);
+ }
+
+ @Override
+ protected boolean canRead() {
+ return true;
+ }
+}
diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpMessageCodec.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpMessageCodec.java
new file mode 100644
index 0000000000..e7d5356a8b
--- /dev/null
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpMessageCodec.java
@@ -0,0 +1,60 @@
+/*
+ * 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.slmp.readwrite.SlmpMessage;
+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.drivers.MessageCodecBase;
+import org.apache.plc4x.java.spi.transports.api.TransportInstance;
+
+import java.util.function.Consumer;
+
+/**
+ * Frames {@link SlmpMessage} over a TCP byte stream using the 3E length field.
+ * The fixed 3E response header is 9 bytes; the 2-byte responseDataLength is
+ * little-endian at offset 7 (NB: modbus's MBAP length is big-endian — do not
+ * copy that read here).
+ */
+public class SlmpMessageCodec extends MessageCodecBase {
+
+ private static final int SLMP_3E_HEADER_SIZE = 9; // subHeader(2) + accessRoute(5) + length(2)
+
+ public SlmpMessageCodec(TransportInstance> transportInstance, Consumer messageHandler) {
+ super("SLMP", transportInstance, messageHandler);
+ }
+
+ @Override
+ protected int getMinimumHeaderSize() {
+ return SLMP_3E_HEADER_SIZE;
+ }
+
+ @Override
+ protected int calculateTotalMessageSize(byte[] header, int availableBytes) {
+ // availableBytes is unused: the total size is fully determined by the length field.
+ // responseDataLength: little-endian uint16 at byte offset 7..8.
+ int length = (header[7] & 0xFF) | ((header[8] & 0xFF) << 8);
+ return SLMP_3E_HEADER_SIZE + length;
+ }
+
+ @Override
+ protected SlmpMessage parseMessage(ReadBufferByteBased readBuffer) throws BufferException {
+ return SlmpMessage.staticParse(readBuffer);
+ }
+}
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
new file mode 100644
index 0000000000..72f9547fe5
--- /dev/null
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java
@@ -0,0 +1,50 @@
+/*
+ * 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.types.PlcResponseCode;
+import org.apache.plc4x.java.api.value.PlcValue;
+import org.apache.plc4x.java.slmp.tag.SlmpTag;
+import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcResponseItem;
+import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Maps a single 3E response (endCode + raw words) to a per-tag {@link PlcResponseItem}. */
+final class SlmpResponseMapper {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(SlmpResponseMapper.class);
+
+ private SlmpResponseMapper() {
+ }
+
+ static PlcResponseItem mapTag(SlmpTag tag, int endCode, byte[] responseData) {
+ if (endCode != 0x0000) {
+ LOGGER.warn("SLMP device returned endCode {} for {}", String.format("0x%04X", endCode), tag);
+ return new DefaultPlcResponseItem<>(PlcResponseCode.REMOTE_ERROR, null);
+ }
+ PlcValue value = tag.getDataType().decode(responseData, tag.getQuantity());
+ if (value == null) {
+ LOGGER.warn("SLMP response too short for {} ({} bytes)", tag,
+ responseData == null ? 0 : responseData.length);
+ return new DefaultPlcResponseItem<>(PlcResponseCode.INVALID_DATA, null);
+ }
+ return new DefaultPlcResponseItem<>(PlcResponseCode.OK, value);
+ }
+}
diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/config/SlmpConfiguration.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/config/SlmpConfiguration.java
new file mode 100644
index 0000000000..10c1a6d494
--- /dev/null
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/config/SlmpConfiguration.java
@@ -0,0 +1,59 @@
+/*
+ * 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.config;
+
+import org.apache.plc4x.java.spi.config.Configuration;
+import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter;
+import org.apache.plc4x.java.spi.config.annotations.Description;
+import org.apache.plc4x.java.spi.config.annotations.defaults.IntDefaultValue;
+
+public class SlmpConfiguration implements Configuration {
+
+ @ConfigurationParameter("monitoring-timer")
+ @IntDefaultValue(0x0000)
+ @Description("SLMP monitoring timer written into each 3E request frame (0 = wait infinitely).")
+ private int monitoringTimer;
+
+ @ConfigurationParameter("request-timeout")
+ @IntDefaultValue(5_000)
+ @Description("Client-side timeout in milliseconds awaiting a response.")
+ private int requestTimeout;
+
+ public int getMonitoringTimer() {
+ return monitoringTimer;
+ }
+
+ public void setMonitoringTimer(int monitoringTimer) {
+ this.monitoringTimer = monitoringTimer;
+ }
+
+ public int getRequestTimeout() {
+ return requestTimeout;
+ }
+
+ public void setRequestTimeout(int requestTimeout) {
+ this.requestTimeout = requestTimeout;
+ }
+
+ @Override
+ public String toString() {
+ return "SlmpConfiguration{monitoringTimer=" + monitoringTimer
+ + ", requestTimeout=" + requestTimeout + '}';
+ }
+}
diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/config/SlmpTcpTransportConfiguration.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/config/SlmpTcpTransportConfiguration.java
new file mode 100644
index 0000000000..a7eba56425
--- /dev/null
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/config/SlmpTcpTransportConfiguration.java
@@ -0,0 +1,30 @@
+/*
+ * 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.config;
+
+import org.apache.plc4x.java.slmp.readwrite.Constants;
+import org.apache.plc4x.java.transport.tcp.config.TcpTransportConfiguration;
+
+public class SlmpTcpTransportConfiguration extends TcpTransportConfiguration {
+
+ @Override
+ public int getDefaultPort() {
+ return Constants.SLMPDEFAULTPORT;
+ }
+}
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
new file mode 100644
index 0000000000..946930eb41
--- /dev/null
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java
@@ -0,0 +1,196 @@
+/*
+ * 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.tag;
+
+import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException;
+import org.apache.plc4x.java.api.model.ArrayInfo;
+import org.apache.plc4x.java.api.model.PlcTag;
+import org.apache.plc4x.java.api.types.PlcValueType;
+import org.apache.plc4x.java.slmp.SlmpDataType;
+import org.apache.plc4x.java.slmp.readwrite.SlmpDeviceCode;
+import org.apache.plc4x.java.spi.drivers.model.DefaultArrayInfo;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A single SLMP word-device tag. v0 supports the word devices D (decimal addr),
+ * W (hex addr) and R (decimal addr); bit devices are rejected. Address grammar:
+ * {@code [:][\[\]]}, e.g. {@code D350},
+ * {@code R200:REAL[4]}, {@code W1A:WORD[10]} (W also accepts {@code W0x1A}).
+ */
+public class SlmpTag implements PlcTag, Serializable {
+
+ public static final Pattern ADDRESS_PATTERN = Pattern.compile(
+ "^(?[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). */
+ static final int MAX_POINTS = 960;
+
+ /** Device addresses are serialized as an unsigned 24-bit field in the 3E frame. */
+ static final int MAX_DEVICE_NUMBER = 0xFFFFFF;
+
+ private final SlmpDeviceCode deviceCode;
+ private final int deviceNumber;
+ private final SlmpDataType dataType;
+ private final int quantity;
+
+ public SlmpTag(SlmpDeviceCode deviceCode, int deviceNumber, SlmpDataType dataType, int quantity) {
+ this.deviceCode = deviceCode;
+ this.deviceNumber = deviceNumber;
+ this.dataType = dataType;
+ this.quantity = quantity;
+ }
+
+ public static SlmpTag of(String addressString) {
+ Matcher matcher = ADDRESS_PATTERN.matcher(addressString);
+ if (!matcher.matches()) {
+ throw new PlcInvalidTagException("Unable to parse SLMP address: " + addressString);
+ }
+ String deviceToken = matcher.group("device").toUpperCase();
+ SlmpDeviceCode device;
+ int radix;
+ switch (deviceToken) {
+ case "D":
+ device = SlmpDeviceCode.D;
+ radix = 10;
+ break;
+ case "R":
+ device = SlmpDeviceCode.R;
+ radix = 10;
+ break;
+ case "W":
+ device = SlmpDeviceCode.W;
+ radix = 16;
+ break;
+ default:
+ throw new PlcInvalidTagException(
+ "device '" + deviceToken + "' not supported in this version (word devices D/W/R only)");
+ }
+
+ boolean hasHexPrefix = matcher.group("hexPrefix") != null;
+ if (hasHexPrefix && radix != 16) {
+ throw new PlcInvalidTagException("0x prefix is only valid for hex devices (W): " + addressString);
+ }
+ int deviceNumber;
+ try {
+ deviceNumber = Integer.parseInt(matcher.group("address"), radix);
+ } catch (NumberFormatException e) {
+ throw new PlcInvalidTagException("Invalid " + (radix == 16 ? "hex" : "decimal")
+ + " device number in: " + addressString);
+ }
+ if (deviceNumber > MAX_DEVICE_NUMBER) {
+ throw new PlcInvalidTagException("device number " + deviceNumber
+ + " exceeds the 24-bit SLMP device-address range [0.." + MAX_DEVICE_NUMBER + "]: " + addressString);
+ }
+
+ String datatypeToken = matcher.group("datatype");
+ SlmpDataType dataType;
+ if (datatypeToken == null) {
+ dataType = SlmpDataType.WORD;
+ } else {
+ try {
+ dataType = SlmpDataType.valueOf(datatypeToken.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ throw new PlcInvalidTagException("Unsupported SLMP data type '" + datatypeToken
+ + "' (supported: WORD, INT, UINT, DINT, UDINT, REAL)");
+ }
+ }
+
+ String quantityToken = matcher.group("quantity");
+ int quantity;
+ if (quantityToken == null) {
+ quantity = 1;
+ } else {
+ try {
+ quantity = Integer.parseInt(quantityToken);
+ } catch (NumberFormatException e) {
+ throw new PlcInvalidTagException("quantity out of range in: " + addressString);
+ }
+ }
+ if (quantity < 1) {
+ throw new PlcInvalidTagException("quantity must be >= 1 in: " + 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);
+ }
+ return new SlmpTag(device, deviceNumber, dataType, quantity);
+ }
+
+ public SlmpDeviceCode getDeviceCode() {
+ return deviceCode;
+ }
+
+ public int getDeviceNumber() {
+ return deviceNumber;
+ }
+
+ public SlmpDataType getDataType() {
+ return dataType;
+ }
+
+ public int getQuantity() {
+ return quantity;
+ }
+
+ /** Number of 16-bit words to request (Batch Read, word units). */
+ public int getNumberOfPoints() {
+ return quantity * dataType.getWordsPerElement();
+ }
+
+ @Override
+ public String getAddressString() {
+ String addr = (deviceCode == SlmpDeviceCode.W)
+ ? "0x" + Integer.toHexString(deviceNumber).toUpperCase()
+ : Integer.toString(deviceNumber);
+ StringBuilder sb = new StringBuilder(deviceCode.name()).append(addr);
+ if (dataType != SlmpDataType.WORD || quantity != 1) {
+ sb.append(':').append(dataType.name());
+ }
+ if (quantity != 1) {
+ sb.append('[').append(quantity).append(']');
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public PlcValueType getPlcValueType() {
+ return PlcValueType.valueOf(dataType.name());
+ }
+
+ @Override
+ public List getArrayInfo() {
+ if (quantity > 1) {
+ return Collections.singletonList(new DefaultArrayInfo(0, quantity - 1));
+ }
+ return Collections.emptyList();
+ }
+
+ @Override
+ public String toString() {
+ return "SlmpTag{" + getAddressString() + '}';
+ }
+}
diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTagHandler.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTagHandler.java
new file mode 100644
index 0000000000..6baace78c8
--- /dev/null
+++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTagHandler.java
@@ -0,0 +1,36 @@
+/*
+ * 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.tag;
+
+import org.apache.plc4x.java.api.model.PlcQuery;
+import org.apache.plc4x.java.api.model.PlcTag;
+import org.apache.plc4x.java.spi.drivers.tags.PlcTagHandler;
+
+public class SlmpTagHandler implements PlcTagHandler {
+
+ @Override
+ public PlcTag parseTag(String tagAddress) {
+ return SlmpTag.of(tagAddress);
+ }
+
+ @Override
+ public PlcQuery parseQuery(String query) {
+ throw new UnsupportedOperationException("SLMP does not support queries");
+ }
+}
diff --git a/plc4j/drivers/slmp/src/main/resources/META-INF/services/org.apache.plc4x.java.api.PlcDriver b/plc4j/drivers/slmp/src/main/resources/META-INF/services/org.apache.plc4x.java.api.PlcDriver
new file mode 100644
index 0000000000..4c152da6e2
--- /dev/null
+++ b/plc4j/drivers/slmp/src/main/resources/META-INF/services/org.apache.plc4x.java.api.PlcDriver
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+org.apache.plc4x.java.slmp.SlmpDriver
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
new file mode 100644
index 0000000000..736e61da13
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java
@@ -0,0 +1,284 @@
+/*
+ * 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.PlcReadResponse;
+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.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}.
+ */
+class SlmpConnectionFailurePathTest {
+
+ @Test
+ void timedOutTagIsIsolatedToRemoteErrorWhileOthersSucceed() throws Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ SlmpConnection connection = newConnectedConnection(transport, 200);
+
+ CompletableFuture extends PlcReadResponse> responseFuture = connection.readRequestBuilder()
+ .addTagAddress("answered", "D350")
+ .addTagAddress("silent", "D351")
+ .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.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");
+ }
+
+ @Test
+ void transportDisconnectFailsThePendingTagAsInternalError() throws Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ SlmpConnection connection = newConnectedConnection(transport, 5000);
+
+ CompletableFuture extends PlcReadResponse> responseFuture = connection.readRequestBuilder()
+ .addTagAddress("pending", "D350")
+ .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");
+ }
+
+ @Test
+ void failedSendFailsOnlyThatTag() throws Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ SlmpConnection connection = newConnectedConnection(transport, 5000);
+
+ transport.failNextWrite();
+ CompletableFuture extends PlcReadResponse> responseFuture = connection.readRequestBuilder()
+ .addTagAddress("unsendable", "D350")
+ .build().execute();
+
+ PlcReadResponse response = responseFuture.get(5, TimeUnit.SECONDS);
+ assertEquals(PlcResponseCode.INTERNAL_ERROR, response.getResponseCode("unsendable"));
+ }
+
+ @Test
+ void unsolicitedFrameIsIgnoredAndTheConnectionStaysUsable() 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")
+ .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);
+
+ 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);
+ }
+
+ 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 AtomicBoolean failNextWrite = new AtomicBoolean(false);
+ private final AtomicReference dataListener = new AtomicReference<>();
+
+ void deliver(byte[] bytes) {
+ buffer.writeBytes(bytes);
+ }
+
+ int writeCount() {
+ return writeCount.get();
+ }
+
+ void failNextWrite() {
+ failNextWrite.set(true);
+ }
+
+ 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();
+ if (failNextWrite.compareAndSet(true, false)) {
+ throw new TransportException("scripted write failure");
+ }
+ }
+
+ @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/SlmpDataTypeDecodeTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDataTypeDecodeTest.java
new file mode 100644
index 0000000000..46bfa96785
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDataTypeDecodeTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.value.PlcValue;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SlmpDataTypeDecodeTest {
+
+ // Wire bytes are little-endian, words LSB-first. D350=0x56AB -> bytes ab 56.
+ private static byte[] hex(String s) {
+ byte[] out = new byte[s.length() / 2];
+ for (int i = 0; i < out.length; i++) {
+ out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
+ }
+ return out;
+ }
+
+ @Test
+ void wordsPerElement() {
+ assertEquals(1, SlmpDataType.WORD.getWordsPerElement());
+ assertEquals(1, SlmpDataType.INT.getWordsPerElement());
+ assertEquals(1, SlmpDataType.UINT.getWordsPerElement());
+ assertEquals(2, SlmpDataType.DINT.getWordsPerElement());
+ assertEquals(2, SlmpDataType.UDINT.getWordsPerElement());
+ assertEquals(2, SlmpDataType.REAL.getWordsPerElement());
+ }
+
+ @Test
+ void decodeSingleWordUnsigned() {
+ PlcValue v = SlmpDataType.WORD.decode(hex("ab56"), 1);
+ assertEquals(0x56AB, v.getInt());
+ }
+
+ @Test
+ void decodeWordHighBitStaysUnsigned() {
+ // 0xFFFF little-endian must decode to 65535 (unsigned), NOT -1.
+ PlcValue v = SlmpDataType.WORD.decode(hex("ffff"), 1);
+ assertEquals(65535, v.getInt());
+ }
+
+ @Test
+ void decodeSingleIntSigned() {
+ // 0xFFFF little-endian -> -1 as signed 16
+ PlcValue v = SlmpDataType.INT.decode(hex("ffff"), 1);
+ assertEquals(-1, v.getInt());
+ }
+
+ @Test
+ void decodeDintTwoWordsLowWordFirst() {
+ // value 0x00010002 stored low-word-first: word0=0x0002 (bytes 02 00), word1=0x0001 (bytes 01 00)
+ PlcValue v = SlmpDataType.DINT.decode(hex("02000100"), 1);
+ assertEquals(0x00010002, v.getInt());
+ }
+
+ @Test
+ void decodeRealTwoWords() {
+ // 1.0f = 0x3F800000; little-endian bytes 00 00 80 3f
+ PlcValue v = SlmpDataType.REAL.decode(hex("0000803f"), 1);
+ assertEquals(1.0f, v.getFloat(), 0.0f);
+ }
+
+ @Test
+ void decodeListWhenQuantityGreaterThanOne() {
+ // D350=0x56AB (ab 56), D351=0x170F (0f 17)
+ PlcValue v = SlmpDataType.WORD.decode(hex("ab560f17"), 2);
+ assertTrue(v.isList());
+ assertEquals(0x56AB, v.getList().get(0).getInt());
+ assertEquals(0x170F, v.getList().get(1).getInt());
+ }
+
+ @Test
+ void decodeReturnsNullOnShortResponse() {
+ // asking for 2 words but only 1 word of data
+ assertNull(SlmpDataType.WORD.decode(hex("ab56"), 2));
+ }
+}
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDriverDriverTestsuiteIT.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDriverDriverTestsuiteIT.java
new file mode 100644
index 0000000000..5f2342b3b0
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDriverDriverTestsuiteIT.java
@@ -0,0 +1,28 @@
+/*
+ * 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.utils.testutils.driver.DriverTestsuiteRunner;
+
+public class SlmpDriverDriverTestsuiteIT extends DriverTestsuiteRunner {
+
+ public SlmpDriverDriverTestsuiteIT() {
+ super("/slmp/slmp-driver-testsuite.xml", "org.apache.plc4x.java.slmp.readwrite", false);
+ }
+}
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
new file mode 100644
index 0000000000..78b9de5c78
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java
@@ -0,0 +1,60 @@
+/*
+ * 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.types.PlcResponseCode;
+import org.apache.plc4x.java.slmp.tag.SlmpTag;
+import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SlmpErrorMappingTest {
+
+ private static byte[] hex(String s) {
+ byte[] out = new byte[s.length() / 2];
+ for (int i = 0; i < out.length; i++) {
+ out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
+ }
+ return out;
+ }
+
+ @Test
+ void normalCompletionDecodesValue() {
+ SlmpTag tag = SlmpTag.of("D350:WORD[2]");
+ var item = SlmpResponseMapper.mapTag(tag, 0x0000, hex("ab560f17"));
+ assertEquals(PlcResponseCode.OK, item.getResponseCode());
+ assertEquals(0x56AB, item.getValue().getList().get(0).getInt());
+ }
+
+ @Test
+ void nonZeroEndCodeMapsToRemoteError() {
+ SlmpTag tag = SlmpTag.of("D350:WORD");
+ var item = SlmpResponseMapper.mapTag(tag, 0xC059, new byte[0]);
+ assertEquals(PlcResponseCode.REMOTE_ERROR, item.getResponseCode());
+ assertNull(item.getValue());
+ }
+
+ @Test
+ void shortResponseMapsToInvalidData() {
+ SlmpTag tag = SlmpTag.of("D350:WORD[2]"); // wants 2 words
+ var item = SlmpResponseMapper.mapTag(tag, 0x0000, hex("ab56")); // only 1
+ assertEquals(PlcResponseCode.INVALID_DATA, item.getResponseCode());
+ }
+}
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpParserSerializerTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpParserSerializerTest.java
new file mode 100644
index 0000000000..1a9a1f12de
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpParserSerializerTest.java
@@ -0,0 +1,29 @@
+/*
+ * 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.utils.testutils.parserserializer.ParserSerializerTestsuiteRunner;
+
+public class SlmpParserSerializerTest extends ParserSerializerTestsuiteRunner {
+
+ public SlmpParserSerializerTest() {
+ super("/protocols/slmp/ParserSerializerTestsuite.xml");
+ }
+
+}
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
new file mode 100644
index 0000000000..1795d9e798
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.slmp.readwrite.SlmpReadRequest;
+import org.apache.plc4x.java.slmp.readwrite.SlmpRequestFrame3E;
+import org.apache.plc4x.java.slmp.tag.SlmpTag;
+import org.apache.plc4x.java.spi.buffers.bytebased.WriteBufferByteBased;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class SlmpRequestBuildTest {
+
+ 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 buildsBatchReadFrameMatchingSh080008Example() throws Exception {
+ // SH-080008 section 8.1: read D350, 2 words.
+ SlmpTag tag = SlmpTag.of("D350:WORD[2]");
+ SlmpReadRequest data = new SlmpReadRequest(
+ tag.getDeviceNumber(), tag.getDeviceCode(), tag.getNumberOfPoints());
+ SlmpRequestFrame3E frame = new SlmpRequestFrame3E(0x0000, 0x0401, 0x0000, data);
+
+ WriteBufferByteBased buffer = new WriteBufferByteBased(new byte[frame.getLengthInBytes()]);
+ frame.serialize(buffer);
+
+ assertEquals("500000ffff03000c000000010400005e0100a80200", toHex(buffer.getBytes()));
+ }
+}
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/config/SlmpConfigurationTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/config/SlmpConfigurationTest.java
new file mode 100644
index 0000000000..065da4f114
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/config/SlmpConfigurationTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.config;
+
+import org.apache.plc4x.java.spi.config.ConfigurationFactory;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class SlmpConfigurationTest {
+
+ @Test
+ void gettersAndSetters() {
+ SlmpConfiguration config = new SlmpConfiguration();
+ config.setMonitoringTimer(4);
+ assertEquals(4, config.getMonitoringTimer());
+ config.setRequestTimeout(2000);
+ assertEquals(2000, config.getRequestTimeout());
+ }
+
+ @Test
+ void defaultsAreApplied() {
+ SlmpConfiguration config = new ConfigurationFactory()
+ .createConfiguration(SlmpConfiguration.class, "");
+ assertEquals(0x0000, config.getMonitoringTimer());
+ assertEquals(5_000, config.getRequestTimeout());
+ }
+
+ @Test
+ void overridesAreParsed() {
+ SlmpConfiguration config = new ConfigurationFactory()
+ .createConfiguration(SlmpConfiguration.class, "monitoring-timer=4&request-timeout=2000");
+ assertEquals(4, config.getMonitoringTimer());
+ assertEquals(2_000, config.getRequestTimeout());
+ }
+}
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpTagHandlerTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpTagHandlerTest.java
new file mode 100644
index 0000000000..47628313ef
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpTagHandlerTest.java
@@ -0,0 +1,38 @@
+/*
+ * 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.tag;
+
+import org.apache.plc4x.java.api.model.PlcTag;
+import org.apache.plc4x.java.slmp.readwrite.SlmpDeviceCode;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SlmpTagHandlerTest {
+
+ @Test
+ void parseTagReturnsPopulatedSlmpTag() {
+ PlcTag tag = new SlmpTagHandler().parseTag("D350:DINT");
+ assertInstanceOf(SlmpTag.class, tag);
+ SlmpTag slmp = (SlmpTag) tag;
+ assertEquals(SlmpDeviceCode.D, slmp.getDeviceCode());
+ assertEquals(350, slmp.getDeviceNumber());
+ assertEquals(2, slmp.getNumberOfPoints()); // DINT = 2 words
+ }
+}
diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpTagTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpTagTest.java
new file mode 100644
index 0000000000..fcfbf03005
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/tag/SlmpTagTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.tag;
+
+import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException;
+import org.apache.plc4x.java.slmp.SlmpDataType;
+import org.apache.plc4x.java.slmp.readwrite.SlmpDeviceCode;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SlmpTagTest {
+
+ @Test
+ void parsesDecimalDeviceWithDefaults() {
+ SlmpTag tag = SlmpTag.of("D350");
+ assertEquals(SlmpDeviceCode.D, tag.getDeviceCode());
+ assertEquals(350, tag.getDeviceNumber());
+ assertEquals(SlmpDataType.WORD, tag.getDataType()); // default WORD
+ assertEquals(1, tag.getQuantity());
+ assertEquals(1, tag.getNumberOfPoints());
+ }
+
+ @Test
+ void parsesDataTypeAndQuantity() {
+ SlmpTag tag = SlmpTag.of("R200:REAL[4]");
+ assertEquals(SlmpDeviceCode.R, tag.getDeviceCode());
+ assertEquals(200, tag.getDeviceNumber());
+ assertEquals(SlmpDataType.REAL, tag.getDataType());
+ assertEquals(4, tag.getQuantity());
+ assertEquals(8, tag.getNumberOfPoints()); // 4 * 2 words
+ }
+
+ @Test
+ void parsesHexLinkRegisterBareForm() {
+ SlmpTag tag = SlmpTag.of("W1A:WORD[10]");
+ assertEquals(SlmpDeviceCode.W, tag.getDeviceCode());
+ assertEquals(0x1A, tag.getDeviceNumber()); // W is hex
+ assertEquals(10, tag.getQuantity());
+ }
+
+ @Test
+ void parsesHexLinkRegisterExplicit0xForm() {
+ assertEquals(0x1A, SlmpTag.of("W0x1A").getDeviceNumber());
+ assertEquals(0x1A, SlmpTag.of("W0X1A").getDeviceNumber());
+ }
+
+ @Test
+ void parses0xPrefixCombinedWithDatatypeAndQuantity() {
+ // the documented example form: 0x prefix + datatype + quantity in one address
+ SlmpTag tag = SlmpTag.of("W0x1A:WORD[10]");
+ assertEquals(SlmpDeviceCode.W, tag.getDeviceCode());
+ assertEquals(0x1A, tag.getDeviceNumber());
+ assertEquals(SlmpDataType.WORD, tag.getDataType());
+ assertEquals(10, tag.getQuantity());
+ }
+
+ @Test
+ void canonicalAddressStringRoundTrips() {
+ assertEquals("D350:INT[2]", SlmpTag.of("D350:INT[2]").getAddressString());
+ assertEquals("W0x1A", SlmpTag.of("W1A").getAddressString()); // W prints 0x hex
+ }
+
+ @Test
+ void rejectsUnsupportedBitDevice() {
+ PlcInvalidTagException ex = assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("M100"));
+ assertTrue(ex.getMessage().contains("M"));
+ }
+
+ @Test
+ void rejects0xPrefixOnDecimalDevice() {
+ assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D0x10"));
+ }
+
+ @Test
+ void rejectsHexDigitsInDecimalDevice() {
+ assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D1A"));
+ }
+
+ @Test
+ void rejectsZeroQuantity() {
+ assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D100:WORD[0]"));
+ }
+
+ @Test
+ void rejectsOverCeiling() {
+ assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D0:WORD[961]"));
+ }
+
+ @Test
+ void rejectsQuantityOverflowingInt() {
+ // a quantity beyond Integer.MAX_VALUE must surface as PlcInvalidTagException,
+ // consistent with the device-number parse, not a raw NumberFormatException
+ assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D0:WORD[999999999999]"));
+ }
+
+ @Test
+ void rejectsDeviceNumberExceeding24Bit() {
+ // device addresses occupy a 24-bit field on the wire; an in-int but out-of-range value
+ // must be rejected at parse time, not leak a BufferException during serialization
+ assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D16777216")); // 0x1000000, one past the 24-bit max
+ }
+
+ @Test
+ void rejectsQuantityWhoseWordCountOverflowsInt() {
+ // quantity * wordsPerElement must not overflow int and slip past the MAX_POINTS ceiling:
+ // 2147483647 * 2 words wraps negative in int arithmetic, so the tag would otherwise be accepted
+ assertThrows(PlcInvalidTagException.class, () -> SlmpTag.of("D0:REAL[2147483647]"));
+ }
+}
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
new file mode 100644
index 0000000000..03d224c60e
--- /dev/null
+++ b/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml
@@ -0,0 +1,70 @@
+
+
+
+ SLMP 3E Batch Read
+ slmp
+ read-write
+ slmp
+
+
+ transport
+ test
+
+
+ monitoring-timer
+ 0
+
+
+ true
+
+ Batch Read D350, 2 words
+
+
+
+
+
+ value
+ D350:WORD[2]
+
+
+
+
+ 500000ffff03000c000000010400005e0100a80200
+ d00000ffff030006000000ab560f17
+
+
+
+
+
+ OK
+
+
+ 22187
+ 5903
+
+
+
+
+
+
+
+
+
+
diff --git a/website/asciidoc/modules/users/nav.adoc b/website/asciidoc/modules/users/nav.adoc
index 5616166f9f..919658db78 100644
--- a/website/asciidoc/modules/users/nav.adoc
+++ b/website/asciidoc/modules/users/nav.adoc
@@ -55,6 +55,7 @@
*** xref:protocols/profinet.adoc[]
*** xref:protocols/s7.adoc[]
*** xref:protocols/simulated.adoc[]
+*** xref:protocols/slmp.adoc[]
*** xref:protocols/umas.adoc[]
** xref:transports/index.adoc[]
diff --git a/website/asciidoc/modules/users/pages/protocols/index.adoc b/website/asciidoc/modules/users/pages/protocols/index.adoc
index 089393ecdc..c16fffed18 100644
--- a/website/asciidoc/modules/users/pages/protocols/index.adoc
+++ b/website/asciidoc/modules/users/pages/protocols/index.adoc
@@ -156,6 +156,13 @@
|icon:check[role="green"]
|icon:times[role="red"]
+|SLMP (MELSEC)
+|icon:times[role="red"]
+|icon:times[role="red"]
+|icon:times[role="red"]
+|icon:exclamation[role="yellow"]
+|icon:times[role="red"]
+
|UMAS
|icon:times[role="red"]
|icon:times[role="red"]
@@ -391,6 +398,17 @@ The following table contains a list of operations and the protocols that support
|icon:question[role="red"]
|icon:question[role="red"]
+|SLMP (MELSEC)
+|icon:times[role="red"]
+|icon:times[role="red"]
+|icon:check[role="green"]
+|icon:check[role="green"]
+|icon:times[role="red"]
+|icon:times[role="red"]
+|icon:times[role="red"]
+|icon:times[role="red"]
+|icon:times[role="red"]
+
|UMAS
|icon:question[role="red"]
|icon:check[role="green"]
diff --git a/website/asciidoc/modules/users/pages/protocols/slmp.adoc b/website/asciidoc/modules/users/pages/protocols/slmp.adoc
new file mode 100644
index 0000000000..51257c8b39
--- /dev/null
+++ b/website/asciidoc/modules/users/pages/protocols/slmp.adoc
@@ -0,0 +1,116 @@
+//
+// 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.
+//
+:imagesdir: ../../images/users/protocols
+:icons: font
+
+= SLMP (Mitsubishi MELSEC)
+
+SLMP (Seamless Message Protocol, also known as MELSEC Communication Protocol / MC protocol)
+is used by Mitsubishi Electric MELSEC PLCs such as the iQ-R, iQ-F, Q and L series.
+
+The driver communicates using binary 3E frames over TCP.
+
+This initial version is *read-only* and supports reading word devices (D, W and R).
+
+(Supported by Plc4J Only)
+
+== Connection String Options
+
+[cols="2,2a,2a,2a,4a"]
+|===
+|Name |Type |Default Value |Required |Description
+|Name 4+|SLMP
+|Code 4+|`slmp`
+|Default Transport 4+|`tcp`
+|Default Port 4+|`5007`
+|Supported Transports 4+|
+ - `tcp`
+5+|Config options:
+|`monitoring-timer` |INT |0| |SLMP monitoring timer written into each 3E request frame (0 = wait infinitely).
+|`request-timeout` |INT |5000| |Client-side timeout in milliseconds awaiting a response.
+|===
+
+== Supported Operations
+
+[cols="2,2a,5a"]
+|===
+|Name |Value |Description
+
+3+|Supported Operations
+
+|
+2+| `read`
+|===
+
+== Individual Resource Address Format
+
+=== Connection String
+
+SLMP has the following connection string format:
+----
+slmp:{transport}://{ip-address}:{port}?{options}
+----
+An example connection string would look like:
+----
+slmp:tcp://192.168.0.10:5007
+----
+Note the transport, port and option fields are optional.
+
+=== General Format
+
+In general all SLMP addresses have this format:
+
+----
+{device}{address}:{data-type}[{quantity}]
+----
+
+If the data-type part is omitted, it defaults to `WORD`.
+If the quantity part is omitted, the default of `1` is assumed.
+
+Examples:
+
+- `D350` (one word from data register 350)
+- `D350:DINT` (one 32-bit integer, occupying two words)
+- `R200:REAL[4]` (four floats from file register 200)
+- `W0x1A:WORD[10]` (ten words from link register 0x1A)
+
+=== Devices
+
+The following word devices are supported:
+
+- `D` (data register, decimal address)
+- `R` (file register, decimal address)
+- `W` (link register, hexadecimal address; an optional `0x` prefix is accepted)
+
+The `0x` prefix is only valid for hexadecimally addressed devices.
+
+A single read request may cover at most 960 words (a conservative single-frame ceiling);
+larger requests are rejected instead of being split.
+
+=== Data Types
+
+The following data types are supported:
+
+- WORD (uint 16, default)
+- INT (int 16)
+- UINT (uint 16)
+- DINT (int 32)
+- UDINT (uint 32)
+- REAL (float 32)
+
+Multi-word types (DINT, UDINT, REAL) are decoded from consecutive words in
+little-endian (low-word-first) order, as transmitted by the PLC.