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 @@ plc4j-driver-slmp PLC4J: Driver: SLMP - Implementation of a PLC4X read-only driver for the SLMP / MELSEC Communication 3E protocol. + Implementation of a PLC4X driver for the SLMP / MELSEC Communication 3E protocol. 2025-08-02T13:55:11Z diff --git a/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpRequestData.java b/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpRequestData.java index 4394dd10ff..83610cf97a 100644 --- a/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpRequestData.java +++ b/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpRequestData.java @@ -52,6 +52,8 @@ public static SlmpRequestData staticParse(ReadBuffer readBuffer, int command) th builder = SlmpRandomReadRequest.staticParseSlmpRequestDataBuilder(readBuffer, command); } else if (EvaluationHelper.equals(command, (int) (0x0406))) { builder = SlmpMultiBlockReadRequest.staticParseSlmpRequestDataBuilder(readBuffer, command); + } else if (EvaluationHelper.equals(command, (int) (0x1401))) { + builder = SlmpWriteRequest.staticParseSlmpRequestDataBuilder(readBuffer, command); } if (builder == null) { throw new BufferException("Unsupported case for discriminated type parameters parameters [command]"); diff --git a/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpWriteRequest.java b/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpWriteRequest.java new file mode 100644 index 0000000000..bd466b3654 --- /dev/null +++ b/plc4j/drivers/slmp/src/main/generated/org/apache/plc4x/java/slmp/readwrite/SlmpWriteRequest.java @@ -0,0 +1,172 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.plc4x.java.slmp.readwrite; + +import org.apache.plc4x.java.spi.buffers.api.Message; +import org.apache.plc4x.java.spi.buffers.api.ReadBuffer; +import org.apache.plc4x.java.spi.buffers.api.WithOption; +import org.apache.plc4x.java.spi.buffers.api.WriteBuffer; +import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException; +import org.apache.plc4x.java.spi.fields.data.reader.DataReaderFactory; +import org.apache.plc4x.java.spi.fields.data.writer.DataWriterFactory; +import org.apache.plc4x.java.spi.fields.fields.reader.FieldReaderFactory; +import org.apache.plc4x.java.spi.fields.fields.writer.FieldWriterFactory; +import org.apache.plc4x.java.spi.fields.utils.ThreadLocalHelper; + +/** + * Code generated by code-generation. DO NOT EDIT. + */ +public class SlmpWriteRequest extends SlmpRequestData implements Message { + protected final int headDeviceNumber; + + protected final SlmpDeviceCode deviceCode; + + protected final int numberOfPoints; + + protected final byte[] writeData; + + public SlmpWriteRequest(Integer headDeviceNumber, SlmpDeviceCode deviceCode, + Integer numberOfPoints, byte[] writeData) { + this.headDeviceNumber = headDeviceNumber; + this.deviceCode = deviceCode; + this.numberOfPoints = numberOfPoints; + this.writeData = writeData; + } + + /** + * Discriminator field command + */ + @Override + public int getCommand() { + return (int) 0x1401; + } + + /** + * Property field headDeviceNumber + */ + public int getHeadDeviceNumber() { + return headDeviceNumber; + } + + /** + * Property field deviceCode + */ + public SlmpDeviceCode getDeviceCode() { + return deviceCode; + } + + /** + * Property field numberOfPoints + */ + public int getNumberOfPoints() { + return numberOfPoints; + } + + /** + * Property field writeData + */ + public byte[] getWriteData() { + return writeData; + } + + public static SlmpRequestDataBuilder staticParseSlmpRequestDataBuilder(ReadBuffer readBuffer, + int command) throws BufferException { + readBuffer.pushContext(WithOption.WithName("SlmpWriteRequest")); + int startPos = readBuffer.getPositionInBits(); + boolean _lastItem = ThreadLocalHelper.lastItemThreadLocal.get(); + // Simple Field: headDeviceNumber + int headDeviceNumber = FieldReaderFactory.readSimpleField(DataReaderFactory.readUnsignedInt(readBuffer, 24), WithOption.WithName("headDeviceNumber")); + + // Simple Field (enum): deviceCode + SlmpDeviceCode deviceCode = FieldReaderFactory.readEnumField(DataReaderFactory.readEnum(SlmpDeviceCode::enumForValue, DataReaderFactory.readUnsignedShort(readBuffer, 8)), WithOption.WithName("deviceCode")); + + // Simple Field: numberOfPoints + int numberOfPoints = FieldReaderFactory.readSimpleField(DataReaderFactory.readUnsignedInt(readBuffer, 16), WithOption.WithName("numberOfPoints")); + + // Array Field: writeData + byte[] writeData = readBuffer.readBits(Math.toIntExact(((numberOfPoints) * (2)) * 8), WithOption.WithName("writeData")); + + readBuffer.popContext(); + return new SlmpRequestDataBuilderImpl(headDeviceNumber, deviceCode, numberOfPoints, writeData); + } + + protected void serializeSlmpRequestDataChild(WriteBuffer writeBuffer) throws BufferException { + writeBuffer.pushContext(WithOption.WithName("SlmpWriteRequest")); + int startPos = writeBuffer.getPositionInBits(); + boolean _lastItem = ThreadLocalHelper.lastItemThreadLocal.get(); + // Simple Field: headDeviceNumber + FieldWriterFactory.writeSimpleField((int) headDeviceNumber, DataWriterFactory.writeUnsignedInt(writeBuffer, 24), WithOption.WithName("headDeviceNumber")); + + // Simple Field (enum): deviceCode + FieldWriterFactory.writeSimpleEnumField((SlmpDeviceCode) deviceCode, DataWriterFactory.writeEnum(SlmpDeviceCode::getValue, SlmpDeviceCode::name, DataWriterFactory.writeUnsignedShort(writeBuffer, 8)), WithOption.WithName("deviceCode")); + + // Simple Field: numberOfPoints + FieldWriterFactory.writeSimpleField((int) numberOfPoints, DataWriterFactory.writeUnsignedInt(writeBuffer, 16), WithOption.WithName("numberOfPoints")); + + // Array Field: writeData + FieldWriterFactory.writeByteArrayField(writeData, DataWriterFactory.writeByteArray(writeBuffer, (int) ((writeData != null) ? writeData.length : 0)), WithOption.WithName("writeData")); + + writeBuffer.popContext(); + } + + @Override + public int getLengthInBytes() { + return (int) Math.ceil((float) getLengthInBits() / 8.0); + } + + @Override + public int getLengthInBits() { + int lengthInBits = super.getLengthInBits(); + SlmpWriteRequest _value = this; + boolean _lastItem = ThreadLocalHelper.lastItemThreadLocal.get(); + // Simple Field: headDeviceNumber + lengthInBits += 24; + + // Simple Field: deviceCode + lengthInBits += 8; + + // Simple Field: numberOfPoints + lengthInBits += 16; + + // Array Field: writeData + lengthInBits += 8 * ((writeData != null) ? writeData.length : 0); + + return lengthInBits; + } + + public static class SlmpRequestDataBuilderImpl implements SlmpRequestData.SlmpRequestDataBuilder { + private final int headDeviceNumber; + + private final SlmpDeviceCode deviceCode; + + private final int numberOfPoints; + + private final byte[] writeData; + + public SlmpRequestDataBuilderImpl(int headDeviceNumber, SlmpDeviceCode deviceCode, + int numberOfPoints, byte[] writeData) { + this.headDeviceNumber = headDeviceNumber; + this.deviceCode = deviceCode; + this.numberOfPoints = numberOfPoints; + this.writeData = writeData; + } + + public SlmpRequestData build() { + return new SlmpWriteRequest(headDeviceNumber, deviceCode, numberOfPoints, writeData); + } + } +} diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java index 216c13fd55..ef0a65b298 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java @@ -22,6 +22,8 @@ import org.apache.plc4x.java.api.exceptions.PlcRuntimeException; import org.apache.plc4x.java.api.messages.PlcReadRequest; import org.apache.plc4x.java.api.messages.PlcReadResponse; +import org.apache.plc4x.java.api.messages.PlcWriteRequest; +import org.apache.plc4x.java.api.messages.PlcWriteResponse; import org.apache.plc4x.java.api.types.ConnectionStateChangeType; import org.apache.plc4x.java.api.types.PlcResponseCode; import org.apache.plc4x.java.api.value.PlcValue; @@ -30,12 +32,15 @@ import org.apache.plc4x.java.slmp.readwrite.SlmpReadRequest; import org.apache.plc4x.java.slmp.readwrite.SlmpRequestFrame3E; import org.apache.plc4x.java.slmp.readwrite.SlmpResponseFrame3E; +import org.apache.plc4x.java.slmp.readwrite.SlmpWriteRequest; import org.apache.plc4x.java.slmp.tag.SlmpTag; import org.apache.plc4x.java.slmp.tag.SlmpTagHandler; import org.apache.plc4x.java.spi.drivers.ConnectionBase; import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException; import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcReadRequest; import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcReadResponse; +import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcWriteRequest; +import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcWriteResponse; import org.apache.plc4x.java.spi.drivers.messages.items.DefaultPlcResponseItem; import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem; import org.apache.plc4x.java.spi.drivers.tags.PlcTagHandler; @@ -168,8 +173,10 @@ private void failPending(Throwable cause) { *

* Caveat: if a request times out, a late response for it may arrive on the receiver thread * after the next request has installed its own slot, and would then be mis-attributed to that - * next request. 3E cannot prevent this (no correlation key), so a timed-out read should be - * treated as unreliable by the caller. + * next request. 3E cannot prevent this (no correlation key), so a timed-out request should be + * treated as unreliable by the caller. For a timed-out WRITE in particular the device may + * nevertheless have applied the write — the driver cannot tell — so blindly retrying a write + * that returned REMOTE_ERROR may apply it twice. *

* All slot clean-up is identity-checked (compare-and-clear): the timeout callback runs on the * delayer thread and may run after the throttle has already released the next request (OpenJDK @@ -203,6 +210,14 @@ protected CompletableFuture onRead(PlcReadRequest readRequest) LinkedHashMap>> tagFutures = new LinkedHashMap<>(); CompletableFuture chain = CompletableFuture.completedFuture(null); for (String tagName : request.getTagNames()) { + PlcResponseCode buildCode = request.getTagResponseCode(tagName); + if (buildCode != null && buildCode != PlcResponseCode.OK) { + // The builder already rejected this tag (unparseable address): echo its code + // instead of dereferencing the null tag. Nothing is sent for this tag. + tagFutures.put(tagName, + CompletableFuture.completedFuture(new DefaultPlcResponseItem<>(buildCode, null))); + continue; + } SlmpTag tag = (SlmpTag) request.getTag(tagName); CompletableFuture> tagFuture = chain.thenComposeAsync(v -> readSingleTag(tag)); @@ -244,4 +259,63 @@ private CompletableFuture> readSingleTag(SlmpTag tag) sendRequest(frame).thenApply(response -> SlmpResponseMapper.mapTag(tag, response.getEndCode(), response.getResponseData()))); } + + @Override + protected CompletableFuture onWrite(PlcWriteRequest writeRequest) { + // Structural mirror of onRead; see there for the handle-vs-thenApply and per-tag partial-failure-isolation rationale. + // A tag that maps to REMOTE_ERROR after a timeout may still have been applied by the device; see sendRequest(). + DefaultPlcWriteRequest request = (DefaultPlcWriteRequest) writeRequest; + LinkedHashMap> tagFutures = new LinkedHashMap<>(); + CompletableFuture chain = CompletableFuture.completedFuture(null); + for (String tagName : request.getTagNames()) { + PlcResponseCode buildCode = request.getTagResponseCode(tagName); + if (buildCode != null && buildCode != PlcResponseCode.OK) { + // The builder already rejected this tag (bad address or un-coercible value): + // echo its code instead of dereferencing the null tag/value. Nothing is sent + // for this tag. + tagFutures.put(tagName, CompletableFuture.completedFuture(buildCode)); + continue; + } + SlmpTag tag = (SlmpTag) request.getTag(tagName); + PlcValue value = request.getPlcValue(tagName); + CompletableFuture tagFuture = + chain.thenComposeAsync(v -> writeSingleTag(tag, value)); + tagFutures.put(tagName, tagFuture); + chain = tagFuture.handle((r, e) -> null); + } + CompletableFuture allDone = + CompletableFuture.allOf(tagFutures.values().toArray(new CompletableFuture[0])); + return allDone.handle((v, anyTagFailure) -> { + Map responseCodes = new LinkedHashMap<>(); + for (Map.Entry> e : tagFutures.entrySet()) { + try { + responseCodes.put(e.getKey(), e.getValue().join()); + } catch (Exception ex) { + Throwable cause = (ex instanceof CompletionException && ex.getCause() != null) + ? ex.getCause() : ex; + PlcResponseCode code = (cause instanceof TimeoutException) + ? PlcResponseCode.REMOTE_ERROR : PlcResponseCode.INTERNAL_ERROR; + responseCodes.put(e.getKey(), code); + } + } + return (PlcWriteResponse) new DefaultPlcWriteResponse(request, responseCodes); + }); + } + + private CompletableFuture writeSingleTag(SlmpTag tag, PlcValue value) { + byte[] payload = tag.getDataType().encode(value, tag.getQuantity()); + if (payload == null) { + return CompletableFuture.completedFuture(PlcResponseCode.INVALID_DATA); + } + // Invariant: encode() returns exactly quantity*wordsPerElement*2 == numberOfPoints*2 bytes, + // so the SlmpWriteRequest header word-count and payload length always agree (the generated + // serialize path derives length from writeData.length, so this must hold by construction). + SlmpWriteRequest data = new SlmpWriteRequest( + tag.getDeviceNumber(), tag.getDeviceCode(), tag.getNumberOfPoints(), payload); + SlmpRequestFrame3E frame = new SlmpRequestFrame3E( + getConfiguration().getMonitoringTimer(), 0x1401, 0x0000, data); + return executeThrottled(() -> + sendRequest(frame).thenApply(response -> + SlmpResponseMapper.mapWriteTag(tag, response.getEndCode(), response.getResponseData()))); + } } diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java index 18908a7216..386b4b313c 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDataType.java @@ -18,11 +18,14 @@ */ package org.apache.plc4x.java.slmp; +import org.apache.plc4x.java.api.exceptions.PlcIncompatibleDatatypeException; +import org.apache.plc4x.java.api.exceptions.PlcRuntimeException; import org.apache.plc4x.java.api.value.PlcValue; import org.apache.plc4x.java.spi.buffers.api.WithOption; import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException; import org.apache.plc4x.java.spi.buffers.bytebased.ReadBufferByteBased; import org.apache.plc4x.java.spi.buffers.bytebased.WithByteBasedOption; +import org.apache.plc4x.java.spi.buffers.bytebased.WriteBufferByteBased; import org.apache.plc4x.java.spi.values.PlcDINT; import org.apache.plc4x.java.spi.values.PlcINT; import org.apache.plc4x.java.spi.values.PlcList; @@ -37,9 +40,10 @@ import java.util.List; /** - * Maps a supported PLC4X data type to its SLMP word footprint and the decode of - * little-endian response words into a {@link PlcValue}. The SLMP wire layer leaves - * {@code responseData} as raw bytes; this enum owns typed decoding (word units only). + * Maps a supported PLC4X data type to its SLMP word footprint and the conversion + * between little-endian SLMP words and a {@link PlcValue} in both directions. The SLMP + * wire layer leaves {@code responseData}/request data as raw bytes; this enum owns typed + * decoding and encoding of word-unit values (word units only). */ public enum SlmpDataType { WORD(1), @@ -90,6 +94,69 @@ public PlcValue decode(byte[] responseData, int quantity) { } } + /** + * Encode {@code quantity} elements of this type to little-endian SLMP request bytes + * ({@code 2 * wordsPerElement} per element). Returns {@code null} on a type/arity mismatch + * (caller maps to INVALID_DATA), symmetric with {@link #decode}. + */ + public byte[] encode(PlcValue value, int quantity) { + int totalBytes = quantity * wordsPerElement * 2; + WriteBufferByteBased buffer = new WriteBufferByteBased(new byte[totalBytes], + WithByteBasedOption.WithByteOrder("LITTLE_ENDIAN"), + WithOption.WithUnsignedIntegerEncoding("unsigned-binary"), + WithOption.WithSignedIntegerEncoding("twos-complement"), + WithOption.WithFloatEncoding("IEEE754")); + try { + if (quantity == 1) { + if (value == null || value.isList()) { + return null; + } + writeOne(buffer, value); + } else { + if (value == null || !value.isList() || value.getList().size() != quantity) { + return null; + } + for (PlcValue element : value.getList()) { + writeOne(buffer, element); + } + } + return buffer.getBytes(); + } catch (PlcIncompatibleDatatypeException e) { + // An un-coercible value is an expected caller error (mapped to INVALID_DATA), not an + // internal fault -- keep it out of the WARN log and skip the stack trace. + LOGGER.debug("Incompatible value for SLMP {} encode: {}", this, e.getMessage()); + return null; + } catch (BufferException | PlcRuntimeException e) { + LOGGER.warn("Failed to encode SLMP {} value", this, e); + return null; + } + } + + private void writeOne(WriteBufferByteBased buffer, PlcValue value) throws BufferException { + switch (this) { + case WORD: + case UINT: + // writeUnsignedInt(16, int) accepts the full 0..65535 range; writeUnsignedShort(16, short) + // takes a SIGNED short and would reject 65535 (0xFFFF) as negative, corrupting unsigned WORD/UINT. + buffer.writeUnsignedInt(16, value.getInt()); + break; + case INT: + buffer.writeSignedShort(16, value.getShort()); + break; + case DINT: + buffer.writeSignedInt(32, value.getInt()); + break; + case UDINT: + buffer.writeUnsignedLong(32, value.getLong()); + break; + case REAL: + buffer.writeFloat(32, value.getFloat()); + break; + default: + throw new BufferException("Unsupported SLMP data type " + this); + } + } + private PlcValue readOne(ReadBufferByteBased buffer) throws BufferException { switch (this) { case WORD: diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDriver.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDriver.java index 0dc9b87e9f..4b5c4ec691 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDriver.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpDriver.java @@ -93,4 +93,9 @@ protected ConnectionBase getConnection(Configuration configuration, protected boolean canRead() { return true; } + + @Override + protected boolean canWrite() { + return true; + } } diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java index 72f9547fe5..67d125db1e 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpResponseMapper.java @@ -47,4 +47,21 @@ static PlcResponseItem mapTag(SlmpTag tag, int endCode, byte[] respons } return new DefaultPlcResponseItem<>(PlcResponseCode.OK, value); } + + /** Maps a single Batch Write 3E response (endCode only; success carries no payload). */ + static PlcResponseCode mapWriteTag(SlmpTag tag, int endCode, byte[] responseData) { + if (endCode != 0x0000) { + LOGGER.warn("SLMP device returned endCode {} for write {}", String.format("0x%04X", endCode), tag); + return PlcResponseCode.REMOTE_ERROR; + } + if (responseData != null && responseData.length > 0) { + // SH-080008: a Batch Write success response carries no data. A payload here is the + // signature of a mis-attributed response (e.g. a late read answer arriving after its + // own request timed out; 3E has no correlation id), so do not report a false OK. + LOGGER.warn("SLMP write success frame for {} unexpectedly carried {} payload byte(s); " + + "treating it as a possibly mis-attributed response", tag, responseData.length); + return PlcResponseCode.REMOTE_ERROR; + } + return PlcResponseCode.OK; + } } diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java index 946930eb41..9fe90059f4 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/tag/SlmpTag.java @@ -44,7 +44,7 @@ public class SlmpTag implements PlcTag, Serializable { "^(?[A-Za-z]+)(?0[xX])?(?

[0-9A-Fa-f]+)" + "(:(?[A-Za-z_]+))?(\\[(?\\d+)])?$"); - /** Conservative single-frame word ceiling for 3E binary Batch Read (not the exact device max). */ + /** Conservative single-frame word ceiling for 3E binary Batch Read/Write (not the exact device max). */ static final int MAX_POINTS = 960; /** Device addresses are serialized as an unsigned 24-bit field in the 3E frame. */ @@ -135,7 +135,7 @@ public static SlmpTag of(String addressString) { long numberOfPoints = (long) quantity * dataType.getWordsPerElement(); if (numberOfPoints > MAX_POINTS) { throw new PlcInvalidTagException("requested " + numberOfPoints + " words exceeds the v0 single-frame " - + "Batch Read ceiling of " + MAX_POINTS + " (no optimizer to split): " + addressString); + + "Batch Read/Write ceiling of " + MAX_POINTS + " (no optimizer to split): " + addressString); } return new SlmpTag(device, deviceNumber, dataType, quantity); } @@ -156,7 +156,7 @@ public int getQuantity() { return quantity; } - /** Number of 16-bit words to request (Batch Read, word units). */ + /** Number of 16-bit words to transfer (Batch Read/Write, word units). */ public int getNumberOfPoints() { return quantity * dataType.getWordsPerElement(); } diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java index 736e61da13..cecc6c0249 100644 --- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java @@ -154,6 +154,22 @@ void connectRejectsNonPositiveRequestTimeout() { "a non-positive request-timeout must be rejected at connect rather than failing every read"); } + @Test + void builderErrorItemIsEchoedForReads() throws Exception { + ScriptedAsyncTransport transport = new ScriptedAsyncTransport(); + SlmpConnection connection = newConnectedConnection(transport, 5000); + CompletableFuture future = connection.readRequestBuilder() + .addTagAddress("bad", "Z99") // unparseable device -> builder error item + .addTagAddress("good", "D350") + .build().execute(); + awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS); // only the good tag reaches the wire + transport.deliver(responseFrame(0x0000, new byte[]{(byte) 0xAB, 0x56})); + transport.runDataListener(); + PlcReadResponse response = future.get(5, TimeUnit.SECONDS); + assertEquals(PlcResponseCode.INVALID_ADDRESS, response.getResponseCode("bad")); + assertEquals(PlcResponseCode.OK, response.getResponseCode("good")); + } + private static SlmpConnection newDisconnectedConnection(SlmpConfiguration config) { AuditLog auditLog = mock(AuditLog.class); when(auditLog.isEnabled()).thenReturn(false); diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionWritePathTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionWritePathTest.java new file mode 100644 index 0000000000..1443143997 --- /dev/null +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionWritePathTest.java @@ -0,0 +1,225 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.slmp; + +import org.apache.plc4x.java.api.messages.PlcWriteResponse; +import org.apache.plc4x.java.api.types.PlcResponseCode; +import org.apache.plc4x.java.slmp.config.SlmpConfiguration; +import org.apache.plc4x.java.slmp.readwrite.SlmpResponseFrame3E; +import org.apache.plc4x.java.spi.buffers.bytebased.WriteBufferByteBased; +import org.apache.plc4x.java.spi.transports.api.AsyncTransportInstance; +import org.apache.plc4x.java.spi.transports.api.config.TransportConfiguration; +import org.apache.plc4x.java.spi.transports.api.exceptions.TransportException; +import org.apache.plc4x.java.utils.auditlog.api.AuditLog; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Happy-path and error-mapping coverage for the SLMP Batch Write (0x1401) path + * in {@link SlmpConnection}: a successful device acknowledgement maps to OK, a + * non-zero endCode maps to REMOTE_ERROR, and a device that never answers times + * out to REMOTE_ERROR for that tag. Mirrors {@code SlmpConnectionFailurePathTest}. + */ +class SlmpConnectionWritePathTest { + + @Test + void successfulWriteMapsToOk() throws Exception { + ScriptedAsyncTransport transport = new ScriptedAsyncTransport(); + SlmpConnection connection = newConnectedConnection(transport, 5000); + CompletableFuture future = connection.writeRequestBuilder() + .addTagAddress("v", "D350", 0x1234) + .build().execute(); + awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS); + transport.deliver(responseFrame(0x0000, new byte[0])); // Batch Write success: empty payload + transport.runDataListener(); + PlcWriteResponse response = future.get(5, TimeUnit.SECONDS); + assertEquals(PlcResponseCode.OK, response.getResponseCode("v")); + } + + @Test + void deviceErrorMapsToRemoteError() throws Exception { + ScriptedAsyncTransport transport = new ScriptedAsyncTransport(); + SlmpConnection connection = newConnectedConnection(transport, 5000); + CompletableFuture future = connection.writeRequestBuilder() + .addTagAddress("v", "D350", 0x1234) + .build().execute(); + awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS); + transport.deliver(responseFrame(0xC059, new byte[0])); + transport.runDataListener(); + PlcWriteResponse response = future.get(5, TimeUnit.SECONDS); + assertEquals(PlcResponseCode.REMOTE_ERROR, response.getResponseCode("v")); + } + + @Test + void timedOutWriteIsRemoteError() throws Exception { + ScriptedAsyncTransport transport = new ScriptedAsyncTransport(); + SlmpConnection connection = newConnectedConnection(transport, 200); + CompletableFuture future = connection.writeRequestBuilder() + .addTagAddress("v", "D350", 0x1234) + .build().execute(); + PlcWriteResponse response = future.get(5, TimeUnit.SECONDS); + assertEquals(PlcResponseCode.REMOTE_ERROR, response.getResponseCode("v")); + } + + @Test + void builderErrorItemsAreEchoedNotMaskedAsInternalError() throws Exception { + ScriptedAsyncTransport transport = new ScriptedAsyncTransport(); + SlmpConnection connection = newConnectedConnection(transport, 5000); + + CompletableFuture future = connection.writeRequestBuilder() + .addTagAddress("badValue", "D350", 70000) // out of WORD range -> builder error item + .addTagAddress("badAddress", "Z99", 1) // unparseable device -> builder error item + .addTagAddress("good", "D351", 0x1234) + .build().execute(); + + awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS); // only the good tag reaches the wire + transport.deliver(responseFrame(0x0000, new byte[0])); + transport.runDataListener(); + + PlcWriteResponse response = future.get(5, TimeUnit.SECONDS); + assertEquals(PlcResponseCode.INVALID_DATA, response.getResponseCode("badValue")); + assertEquals(PlcResponseCode.INVALID_ADDRESS, response.getResponseCode("badAddress")); + assertEquals(PlcResponseCode.OK, response.getResponseCode("good")); + } + + private static SlmpConnection newConnectedConnection(ScriptedAsyncTransport transport, int requestTimeoutMs) + throws Exception { + SlmpConfiguration config = new SlmpConfiguration(); + config.setRequestTimeout(requestTimeoutMs); + config.setMonitoringTimer(0x0000); + + AuditLog auditLog = mock(AuditLog.class); + when(auditLog.isEnabled()).thenReturn(false); + + SlmpConnection connection = new SlmpConnection(config, transport, auditLog); + connection.connect(); + return connection; + } + + private static byte[] responseFrame(int endCode, byte[] data) throws Exception { + SlmpResponseFrame3E frame = new SlmpResponseFrame3E(endCode, data); + WriteBufferByteBased writeBuffer = new WriteBufferByteBased(new byte[frame.getLengthInBytes()]); + frame.serialize(writeBuffer); + return writeBuffer.getBytes(); + } + + private static void awaitTrue(BooleanSupplier condition, long timeout, TimeUnit unit) throws InterruptedException { + long deadline = System.currentTimeMillis() + unit.toMillis(timeout); + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) { + fail("condition not met within timeout"); + } + Thread.sleep(10); + } + } + + /** + * In-memory transport double implementing the async listener-registration + * contract {@code ConnectionBase.startReceiving} relies on. Bytes queued + * via {@link #deliver(byte[])} are consumed through the real + * TransportInstance read contract by the codec. + */ + static final class ScriptedAsyncTransport implements AsyncTransportInstance { + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private int readPosition; + private boolean open = true; + private final AtomicInteger writeCount = new AtomicInteger(); + private final AtomicReference dataListener = new AtomicReference<>(); + + void deliver(byte[] bytes) { + buffer.writeBytes(bytes); + } + + int writeCount() { + return writeCount.get(); + } + + void runDataListener() { + Runnable listener = dataListener.get(); + if (listener == null) { + throw new IllegalStateException("No data listener registered"); + } + listener.run(); + } + + @Override + public TransportConfiguration getConfiguration() { + return null; + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public int getNumBytesAvailable() { + return buffer.size() - readPosition; + } + + @Override + public byte[] peekReadableBytes(int numBytes) throws TransportException { + if (numBytes > getNumBytesAvailable()) { + throw new TransportException("peek beyond available: " + numBytes); + } + byte[] all = buffer.toByteArray(); + byte[] result = new byte[numBytes]; + System.arraycopy(all, readPosition, result, 0, numBytes); + return result; + } + + @Override + public byte[] read(int numBytes) throws TransportException { + byte[] result = peekReadableBytes(numBytes); + readPosition += numBytes; + return result; + } + + @Override + public void write(byte[] bytes) throws TransportException { + writeCount.incrementAndGet(); + } + + @Override + public void close() { + open = false; + } + + @Override + public void registerDataListener(Runnable listener) { + dataListener.set(listener); + } + + @Override + public void removeDataListener() { + dataListener.set(null); + } + } +} diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDataTypeEncodeTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDataTypeEncodeTest.java new file mode 100644 index 0000000000..641380f029 --- /dev/null +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDataTypeEncodeTest.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.slmp; + +import org.apache.plc4x.java.spi.values.PlcDATE; +import org.apache.plc4x.java.spi.values.PlcDINT; +import org.apache.plc4x.java.spi.values.PlcINT; +import org.apache.plc4x.java.spi.values.PlcList; +import org.apache.plc4x.java.spi.values.PlcREAL; +import org.apache.plc4x.java.spi.values.PlcUDINT; +import org.apache.plc4x.java.spi.values.PlcUINT; +import org.apache.plc4x.java.spi.values.PlcWORD; +import org.apache.plc4x.java.api.value.PlcValue; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SlmpDataTypeEncodeTest { + + private static String toHex(byte[] b) { + StringBuilder sb = new StringBuilder(); + for (byte x : b) { + sb.append(String.format("%02x", x)); + } + return sb.toString(); + } + + @Test + void encodeSingleWordLittleEndian() { + assertEquals("3412", toHex(SlmpDataType.WORD.encode(new PlcWORD(0x1234), 1))); + } + + @Test + void encodeWordHighBitUnsigned() { + assertEquals("ffff", toHex(SlmpDataType.WORD.encode(new PlcWORD(65535), 1))); + } + + @Test + void encodeSignedIntTwosComplement() { + assertEquals("ffff", toHex(SlmpDataType.INT.encode(new PlcINT((short) -1), 1))); + } + + @Test + void encodeDintLowWordFirst() { + // 0x00010002 -> word0 0x0002 (02 00), word1 0x0001 (01 00) + assertEquals("02000100", toHex(SlmpDataType.DINT.encode(new PlcDINT(0x00010002), 1))); + } + + @Test + void encodeRealIeee754() { + // 1.0f = 0x3F800000, little-endian 00 00 80 3f + assertEquals("0000803f", toHex(SlmpDataType.REAL.encode(new PlcREAL(1.0f), 1))); + } + + @Test + void encodeListWhenQuantityGreaterThanOne() { + PlcValue list = new PlcList(List.of(new PlcWORD(0x56AB), new PlcWORD(0x170F))); + assertEquals("ab560f17", toHex(SlmpDataType.WORD.encode(list, 2))); + } + + @Test + void encodeReturnsNullOnArityMismatch() { + assertNull(SlmpDataType.WORD.encode(new PlcWORD(0x1234), 2)); + assertNull(SlmpDataType.WORD.encode(new PlcList(List.of(new PlcWORD(1))), 2)); + } + + @Test + void encodeReturnsNullOnListInputWhenQuantityIsOne() { + assertNull(SlmpDataType.WORD.encode(new PlcList(List.of(new PlcWORD(1))), 1)); + } + + @Test + void encodeReturnsNullOnIncompatibleValue() { + // PlcDATE.getInt() throws PlcIncompatibleDatatypeException (no numeric coercion); + // the narrowed catch must map this to null rather than propagating. + assertNull(SlmpDataType.WORD.encode(new PlcDATE(LocalDate.of(2020, 1, 1)), 1)); + } + + @Test + void encodeDecodeRoundTrips() { + byte[] bytes = SlmpDataType.UDINT.encode(new PlcUDINT(0xDEADBEEFL), 1); + assertEquals(0xDEADBEEFL, SlmpDataType.UDINT.decode(bytes, 1).getLong()); + } + + @Test + void encodeUintRoundTrips() { + byte[] bytes = SlmpDataType.UINT.encode(new PlcUINT(40000), 1); + assertEquals(40000, SlmpDataType.UINT.decode(bytes, 1).getInt()); + } +} diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDriverTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDriverTest.java new file mode 100644 index 0000000000..429bb70627 --- /dev/null +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpDriverTest.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.slmp; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SlmpDriverTest { + + private final SlmpDriver driver = new SlmpDriver(); + + @Test + void capabilities() { + // Same package as SlmpDriver, so the protected can* methods are directly accessible. + assertTrue(driver.canRead(), "canRead should return true"); + assertTrue(driver.canWrite(), "canWrite should return true"); + } +} diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java index 78b9de5c78..04a36a49d6 100644 --- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpErrorMappingTest.java @@ -57,4 +57,24 @@ void shortResponseMapsToInvalidData() { var item = SlmpResponseMapper.mapTag(tag, 0x0000, hex("ab56")); // only 1 assertEquals(PlcResponseCode.INVALID_DATA, item.getResponseCode()); } + + @Test + void mapWriteTagOkOnZeroEndCode() { + assertEquals(PlcResponseCode.OK, SlmpResponseMapper.mapWriteTag(SlmpTag.of("D350"), 0x0000, new byte[0])); + } + + @Test + void mapWriteTagRemoteErrorOnNonZeroEndCode() { + // Extra error information alongside a non-zero endCode is still just REMOTE_ERROR. + assertEquals(PlcResponseCode.REMOTE_ERROR, + SlmpResponseMapper.mapWriteTag(SlmpTag.of("D350"), 0xC059, new byte[]{0x03, 0x04})); + } + + @Test + void mapWriteTagRejectsUnexpectedPayloadOnSuccess() { + // SH-080008: a Batch Write success response carries NO data. A payload on endCode 0 + // is the signature of a mis-attributed (late read) response and must not map to OK. + assertEquals(PlcResponseCode.REMOTE_ERROR, + SlmpResponseMapper.mapWriteTag(SlmpTag.of("D350"), 0x0000, new byte[]{0x01, 0x02})); + } } diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java index 1795d9e798..a38c367d87 100644 --- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpRequestBuildTest.java @@ -20,6 +20,7 @@ import org.apache.plc4x.java.slmp.readwrite.SlmpReadRequest; import org.apache.plc4x.java.slmp.readwrite.SlmpRequestFrame3E; +import org.apache.plc4x.java.slmp.readwrite.SlmpWriteRequest; import org.apache.plc4x.java.slmp.tag.SlmpTag; import org.apache.plc4x.java.spi.buffers.bytebased.WriteBufferByteBased; import org.junit.jupiter.api.Test; @@ -38,7 +39,7 @@ private static String toHex(byte[] b) { @Test void buildsBatchReadFrameMatchingSh080008Example() throws Exception { - // SH-080008 section 8.1: read D350, 2 words. + // SH-080008 section 8.2 (Batch Read and Write): read D350, 2 words. SlmpTag tag = SlmpTag.of("D350:WORD[2]"); SlmpReadRequest data = new SlmpReadRequest( tag.getDeviceNumber(), tag.getDeviceCode(), tag.getNumberOfPoints()); @@ -49,4 +50,20 @@ void buildsBatchReadFrameMatchingSh080008Example() throws Exception { assertEquals("500000ffff03000c000000010400005e0100a80200", toHex(buffer.getBytes())); } + + @Test + void buildsBatchWriteFrameForSingleWord() throws Exception { + // Batch Write D350 = one WORD 0x1234 (little-endian payload 34 12). + SlmpTag tag = SlmpTag.of("D350"); + SlmpWriteRequest data = new SlmpWriteRequest( + tag.getDeviceNumber(), tag.getDeviceCode(), tag.getNumberOfPoints(), + new byte[]{0x34, 0x12}); + SlmpRequestFrame3E frame = new SlmpRequestFrame3E(0x0000, 0x1401, 0x0000, data); + + WriteBufferByteBased buffer = new WriteBufferByteBased(new byte[frame.getLengthInBytes()]); + frame.serialize(buffer); + + // requestDataLength = 6 + (3+1+2+2) = 14 (0x000E); command 0x1401 -> 01 14 LE. + assertEquals("500000ffff03000e000000011400005e0100a801003412", toHex(buffer.getBytes())); + } } diff --git a/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml b/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml index 03d224c60e..ae09036420 100644 --- a/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml +++ b/plc4j/drivers/slmp/src/test/resources/slmp/slmp-driver-testsuite.xml @@ -18,7 +18,7 @@ under the License. --> - SLMP 3E Batch Read + SLMP 3E Batch Read/Write slmp read-write slmp @@ -67,4 +67,33 @@ + + Batch Write D350 = 0x1234 + + + + + + v +
D350
+ + 4660 + +
+
+
+
+ 500000ffff03000e000000011400005e0100a801003412 + d00000ffff030002000000 + + + + + OK + + + + +
+
diff --git a/protocols/slmp/src/main/resources/protocols/slmp/slmp.mspec b/protocols/slmp/src/main/resources/protocols/slmp/slmp.mspec index 20170095ae..9b6e149ac7 100644 --- a/protocols/slmp/src/main/resources/protocols/slmp/slmp.mspec +++ b/protocols/slmp/src/main/resources/protocols/slmp/slmp.mspec @@ -25,18 +25,19 @@ // - Subheader (50 00 / D0 00) ...... section 5.3 // - Access route (3E fixed value) .. chapter 6 // - Request/response data length ... section 5.3 (2-byte, little-endian) -// - Commands (Batch/Random/block Read) chapter 7 / 8.1 / 8.3 / 8.4 -// - Device code list ............... section 8.1 (MELSEC-Q/L, 1-byte binary) -// - Batch Read data layout ......... section 8.1 (binary, word units) +// - Command list ................... chapter 7 (section 7.1) +// - Device data to be specified .... section 8.1 (devices, device code list, points) +// - Batch Read/Write data layout ... section 8.2 (binary, word units; Read 0x0401 / Write 0x1401) // - Random Read data layout ........ section 8.3 (binary, word units) // - Multi-block Read data layout ... section 8.4 (binary, word units) // -// Scope of this initial version: 3E binary frame, read-only, Batch Read +// Scope of this initial version: 3E binary frame, Batch Read // (command 0x0401), Random Read (command 0x0403) and Batch Read Multiple Blocks -// (command 0x0406) in word units (subcommand 0x0000). This is the wire layer -// only; typed value decoding -// (INT/WORD/DINT/REAL) and the device-addressing tag layer are intentionally -// NOT modelled here yet and will follow once the driver logic is built. +// (command 0x0406) in word units (subcommand 0x0000), plus Batch Write +// (command 0x1401) in word units (subcommand 0x0000). This is the wire layer +// only; typed value encoding/decoding (INT/WORD/DINT/REAL) and the +// device-addressing tag layer are owned by the driver layer and intentionally +// not modelled in the wire spec. // Validated hardware-free via the ParserSerializer test suite. // // All multi-byte numeric fields are little-endian (transmitted least-significant byte first). @@ -51,7 +52,7 @@ // Device codes for MELSEC-Q/L series commands (subcommand 0x0000 / 0x0001), // 1-byte binary, from the device code list in SH-080008 section 8.1. -// Only the word/bit devices needed by the read-only road-map are listed. +// Only the word/bit devices needed by the initial road-map are listed. // // These 1-byte binary device codes are confirmed identical in the Mitsubishi // MELSEC iQ-F FX5 SLMP manual (JY997D56001) -- D=A8 W=B4 R=AF M=90 X=9C Y=9D @@ -108,8 +109,9 @@ ] ] -// Request data, dispatched by command. Batch Read (0x0401) and Random Read -// (0x0403) are modelled here; both are read-only, word units (subcommand 0x0000). +// Request data, dispatched by command: Batch Read (0x0401), Random Read (0x0403), +// Batch Read Multiple Blocks (0x0406) and Batch Write (0x1401) are modelled here, +// all in word units (subcommand 0x0000). [discriminatedType SlmpRequestData(uint 16 command) [typeSwitch command ['0x0401' SlmpReadRequest @@ -143,6 +145,18 @@ [array SlmpDeviceBlock wordDeviceBlocks count 'numberOfWordDeviceBlocks'] [array SlmpDeviceBlock bitDeviceBlocks count 'numberOfBitDeviceBlocks'] ] + ['0x1401' SlmpWriteRequest + // Batch Write in word units (SH-080008 section 8.2 "Batch Read and Write"): the same + // device addressing as SlmpReadRequest (0x0401), followed by the data to write. + // numberOfPoints is the number of 16-bit words; writeData is exactly that many + // little-endian words carried as raw bytes (2 * numberOfPoints). Typed encoding + // (INT/WORD/DINT/REAL) is owned by the driver layer, symmetric with how the read + // response leaves responseData as raw bytes for the driver to decode. + [simple uint 24 headDeviceNumber] + [simple SlmpDeviceCode deviceCode] + [simple uint 16 numberOfPoints] + [array byte writeData count 'numberOfPoints * 2'] + ] ] ] diff --git a/protocols/slmp/src/test/resources/protocols/slmp/ParserSerializerTestsuite.xml b/protocols/slmp/src/test/resources/protocols/slmp/ParserSerializerTestsuite.xml index 6054c1963b..db4d12cd00 100644 --- a/protocols/slmp/src/test/resources/protocols/slmp/ParserSerializerTestsuite.xml +++ b/protocols/slmp/src/test/resources/protocols/slmp/ParserSerializerTestsuite.xml @@ -23,7 +23,7 @@ is assembled per section 5.2 / chapter 6. Batch Read (0x0401) word-unit example reading the values stored in D350 and - D351 (section 8.1): + D351 (section 8.2 "Batch Read and Write"): request data : 5E 01 00 A8 02 00 (head=D350, code=D, points=2) response data: AB 56 0F 17 (D350=0x56AB, D351=0x170F)