diff --git a/pom.xml b/pom.xml
index 3710acf..a892d68 100644
--- a/pom.xml
+++ b/pom.xml
@@ -230,6 +230,13 @@
${ruoyi.version}
+
+
+ com.ruoyi
+ ruoyi-ft546-protocol
+ ${ruoyi.version}
+
+
cn.hutool
@@ -256,6 +263,7 @@
ruoyi-admin
+ ruoyi-ft546-protocol
ruoyi-wcs
ruoyi-framework
ruoyi-system
diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml
index e1da1a6..0c8fc52 100644
--- a/ruoyi-admin/pom.xml
+++ b/ruoyi-admin/pom.xml
@@ -60,6 +60,12 @@
ruoyi-wcs
+
+
+ com.ruoyi
+ ruoyi-ft546-protocol
+
+
com.ruoyi
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java
index d162328..bb8126d 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java
@@ -1,5 +1,7 @@
package com.ruoyi.project.ft546.config;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
+
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.Objects;
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java
index 9a9a8c4..178ea68 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java
@@ -1,5 +1,7 @@
package com.ruoyi.project.ft546.config;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
+
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/AbstractFt546EquipmentAdapter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/AbstractFt546EquipmentAdapter.java
index f5680d6..aaef9ba 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/AbstractFt546EquipmentAdapter.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/AbstractFt546EquipmentAdapter.java
@@ -1,7 +1,7 @@
package com.ruoyi.project.ft546.device;
import com.ruoyi.project.ft546.config.Ft546ConfigurationValidator;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.wcs.application.device.DeviceCommandEnvelope;
import com.ruoyi.wcs.application.device.SendReceipt;
import com.ruoyi.wcs.domain.execution.DeviceCommandType;
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/charger/Ft546ChargerAdapter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/charger/Ft546ChargerAdapter.java
index df0410b..52197b4 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/charger/Ft546ChargerAdapter.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/charger/Ft546ChargerAdapter.java
@@ -1,7 +1,7 @@
package com.ruoyi.project.ft546.device.charger;
import com.ruoyi.device.cdz.domain.DeviceCdz;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.project.ft546.device.AbstractFt546EquipmentAdapter;
import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec;
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/conveyor/Ft546ConveyorAdapter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/conveyor/Ft546ConveyorAdapter.java
index efd1ce4..192c09c 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/conveyor/Ft546ConveyorAdapter.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/conveyor/Ft546ConveyorAdapter.java
@@ -1,7 +1,7 @@
package com.ruoyi.project.ft546.device.conveyor;
import com.ruoyi.device.ssj.domain.DeviceSsj;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.project.ft546.device.AbstractFt546EquipmentAdapter;
import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec;
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/lift/Ft546LiftAdapter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/lift/Ft546LiftAdapter.java
index df09908..ce66547 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/lift/Ft546LiftAdapter.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/lift/Ft546LiftAdapter.java
@@ -1,7 +1,7 @@
package com.ruoyi.project.ft546.device.lift;
import com.ruoyi.device.tsj.domain.DeviceTsj;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.project.ft546.device.AbstractFt546EquipmentAdapter;
import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec;
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546CommandIntentMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546CommandIntentMapper.java
new file mode 100644
index 0000000..89f2e9e
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546CommandIntentMapper.java
@@ -0,0 +1,139 @@
+package com.ruoyi.project.ft546.device.shuttle;
+
+import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
+import com.ruoyi.wcs.application.device.DeviceCommandEnvelope;
+import com.ruoyi.wcs.domain.execution.DeviceCommandType;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public final class Ft546CommandIntentMapper {
+ public Ft546SrgvCommandIntent from(DeviceCommandEnvelope envelope) {
+ if (envelope == null) {
+ throw new IllegalArgumentException("envelope must not be null");
+ }
+ if (envelope.getType() != DeviceCommandType.MOVE_ROUTE) {
+ throw new IllegalArgumentException(
+ "legacy shuttle supports MOVE_ROUTE only");
+ }
+ Map values = parse(envelope.getPayload());
+ int taskNo = positiveInt(required(values, "taskNo"), "taskNo");
+ short taskType = shortValue(required(values, "taskType"),
+ "taskType");
+ List route = route(required(values, "route"));
+ Integer stage = values.containsKey("stage")
+ ? Integer.valueOf(positiveInt(values.get("stage"), "stage"))
+ : null;
+ Short target = stage == null && taskType == 8
+ ? Short.valueOf(shortValue(
+ required(values, "crossFloorTarget"),
+ "crossFloorTarget"))
+ : null;
+ String parentTaskId = firstText(values.get("parentTaskId"),
+ values.get("orderId"), envelope.getCommandId());
+ String phase = firstText(values.get("phase"),
+ legacyPhase(stage));
+ long routeVersion = values.containsKey("routeVersion")
+ ? positiveLong(values.get("routeVersion"), "routeVersion")
+ : envelope.getTopologyVersion();
+ return new Ft546SrgvCommandIntent(envelope.getDeviceId(),
+ parentTaskId, phase, envelope.getCommandKey(), routeVersion,
+ taskNo, taskType, route, target, stage);
+ }
+
+ private static Map parse(String payload) {
+ Map values = new LinkedHashMap();
+ for (String segment : payload.split("\\|")) {
+ int separator = segment.indexOf('=');
+ if (separator <= 0 || separator == segment.length() - 1) {
+ throw new IllegalArgumentException(
+ "invalid command payload segment: " + segment);
+ }
+ String key = segment.substring(0, separator).trim();
+ String value = segment.substring(separator + 1).trim();
+ if (values.put(key, value) != null) {
+ throw new IllegalArgumentException(
+ "duplicate command payload field: " + key);
+ }
+ }
+ return values;
+ }
+
+ private static List route(String value) {
+ String[] nodes = value.split(">");
+ if (nodes.length < 2 || nodes.length > 8) {
+ throw new IllegalArgumentException(
+ "legacy route must contain between 2 and 8 command points");
+ }
+ List result = new ArrayList(nodes.length);
+ for (String nodeValue : nodes) {
+ int node = positiveInt(nodeValue, "route node");
+ if (node > 999999) {
+ throw new IllegalArgumentException(
+ "route node exceeds six digits");
+ }
+ result.add(node);
+ }
+ return result;
+ }
+
+ private static int positiveInt(String value, String field) {
+ try {
+ int parsed = Integer.parseInt(value);
+ if (parsed <= 0) {
+ throw new IllegalArgumentException(field + " must be positive");
+ }
+ return parsed;
+ } catch (NumberFormatException invalid) {
+ throw new IllegalArgumentException(field + " is not an integer",
+ invalid);
+ }
+ }
+
+ private static long positiveLong(String value, String field) {
+ try {
+ long parsed = Long.parseLong(value);
+ if (parsed <= 0L) {
+ throw new IllegalArgumentException(field + " must be positive");
+ }
+ return parsed;
+ } catch (NumberFormatException invalid) {
+ throw new IllegalArgumentException(field + " is not an integer",
+ invalid);
+ }
+ }
+
+ private static short shortValue(String value, String field) {
+ try {
+ return Short.parseShort(value);
+ } catch (NumberFormatException invalid) {
+ throw new IllegalArgumentException(field + " is not an int16",
+ invalid);
+ }
+ }
+
+ private static String required(Map values, String field) {
+ String value = values.get(field);
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException(field + " is required");
+ }
+ return value;
+ }
+
+ private static String firstText(String... values) {
+ for (String value : values) {
+ if (value != null && !value.trim().isEmpty()) {
+ return value.trim();
+ }
+ }
+ throw new IllegalArgumentException("command evidence is required");
+ }
+
+ private static String legacyPhase(Integer stage) {
+ return stage == null
+ ? "LEGACY_MOVE_ROUTE"
+ : "TWO_STAGE_" + stage;
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java
index 95789c2..fc62a9a 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java
@@ -3,7 +3,10 @@ package com.ruoyi.project.ft546.device.shuttle;
import com.ruoyi.project.ft546.config.CapabilityGateResult;
import com.ruoyi.project.ft546.config.Ft546CapabilityEvidence;
import com.ruoyi.project.ft546.config.Ft546CapabilityGate;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
+import com.ruoyi.ft546.protocol.Ft546RegisterWrite;
+import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.project.ft546.device.Ft546DeviceRuntimeRegistry;
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
@@ -28,6 +31,10 @@ public final class Ft546LegacySrgvAdapter
private final SnapshotContextProvider snapshotContextProvider;
private final Ft546SrgvCommandEncoder encoder =
new Ft546SrgvCommandEncoder();
+ private final Ft546CommandIntentMapper intentMapper =
+ new Ft546CommandIntentMapper();
+ private final Ft546PlcRegisterWriter plcRegisterWriter =
+ new Ft546PlcRegisterWriter();
private final Ft546SrgvSnapshotMapper snapshotMapper =
new Ft546SrgvSnapshotMapper();
private final Set locallyUnknownDevices =
@@ -81,9 +88,10 @@ public final class Ft546LegacySrgvAdapter
+ "unresolved UNKNOWN command");
}
- final Ft546SrgvCommandEncoder.EncodedCommand command;
+ final Ft546EncodedCommand command;
try {
- command = encoder.encode(envelope, profile, capabilities);
+ command = encoder.encode(intentMapper.from(envelope), profile,
+ capabilities.getEnabledCapabilities());
} catch (IllegalArgumentException | IllegalStateException rejected) {
return SendReceipt.rejected(receiptId,
rejected.getMessage());
@@ -100,10 +108,9 @@ public final class Ft546LegacySrgvAdapter
}
boolean wroteAny = false;
- for (Ft546SrgvCommandEncoder.PlcWrite write
- : command.getWrites()) {
+ for (Ft546RegisterWrite write : command.getWrites()) {
try {
- if (!write.writeTo(runtime)) {
+ if (!plcRegisterWriter.write(runtime, write)) {
return markUnknown(envelope.getDeviceId(), receiptId,
wroteAny
? "PLC rejected a partial register sequence"
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546PlcRegisterWriter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546PlcRegisterWriter.java
new file mode 100644
index 0000000..ec3ca42
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546PlcRegisterWriter.java
@@ -0,0 +1,24 @@
+package com.ruoyi.project.ft546.device.shuttle;
+
+import com.ruoyi.ft546.protocol.Ft546RegisterWrite;
+import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
+
+import java.util.List;
+
+final class Ft546PlcRegisterWriter {
+ boolean write(PlcSrgvRead runtime, Ft546RegisterWrite write) {
+ List values = write.getValues();
+ if (write.getDataType() == Ft546RegisterWrite.DataType.INT32) {
+ int[] converted = new int[values.size()];
+ for (int index = 0; index < values.size(); index++) {
+ converted[index] = values.get(index);
+ }
+ return runtime.Write(write.getAddress(), converted);
+ }
+ short[] converted = new short[values.size()];
+ for (int index = 0; index < values.size(); index++) {
+ converted[index] = values.get(index).shortValue();
+ }
+ return runtime.Write(write.getAddress(), converted);
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvCommandEncoder.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvCommandEncoder.java
deleted file mode 100644
index 7abfe4c..0000000
--- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvCommandEncoder.java
+++ /dev/null
@@ -1,209 +0,0 @@
-package com.ruoyi.project.ft546.device.shuttle;
-
-import com.ruoyi.project.ft546.config.CapabilityGateResult;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
-import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
-import com.ruoyi.wcs.application.device.DeviceCommandEnvelope;
-import com.ruoyi.wcs.domain.execution.DeviceCommandType;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-final class Ft546SrgvCommandEncoder {
- EncodedCommand encode(DeviceCommandEnvelope envelope,
- Ft546DeviceProfile profile,
- CapabilityGateResult capabilities) {
- if (envelope.getType() != DeviceCommandType.MOVE_ROUTE) {
- throw new IllegalArgumentException(
- "legacy shuttle supports MOVE_ROUTE only");
- }
- Map values = parse(envelope.getPayload());
- int taskNo = positiveInt(required(values, "taskNo"), "taskNo");
- short taskType = shortValue(required(values, "taskType"),
- "taskType");
- int[] route = route(required(values, "route"));
- String stageValue = values.get("stage");
- if (stageValue == null) {
- if (!capabilities.isEnabled("POINT_TO_POINT_COMMAND")) {
- throw new IllegalStateException(
- "POINT_TO_POINT_COMMAND is not enabled");
- }
- return main(profile, values, taskNo, taskType, route);
- }
- if (!capabilities.isEnabled("TWO_STAGE_ATOMIC_TASK")) {
- throw new IllegalStateException(
- "TWO_STAGE_ATOMIC_TASK is not enabled");
- }
- int stage = positiveInt(stageValue, "stage");
- if (stage == 1) {
- return secondStageOne(profile, taskNo, taskType, route);
- }
- if (stage == 2) {
- return secondStageTwo(profile, taskNo, route);
- }
- throw new IllegalArgumentException("stage must be 1 or 2");
- }
-
- private static EncodedCommand main(Ft546DeviceProfile profile,
- Map values,
- int taskNo, short taskType,
- int[] route) {
- List writes = new ArrayList();
- writes.add(PlcWrite.int32(register(profile, "path"), route));
- writes.add(PlcWrite.int32(register(profile, "taskNo"), taskNo));
- writes.add(PlcWrite.int16(register(profile, "taskType"), taskType));
- writes.add(PlcWrite.int16(register(profile, "trigger"), (short) 1));
- if (taskType == 8) {
- short targetFloor = shortValue(required(values,
- "crossFloorTarget"), "crossFloorTarget");
- writes.add(PlcWrite.int16(register(profile, "crossFloorFlag"),
- (short) 1));
- writes.add(PlcWrite.int16(register(profile,
- "crossFloorTarget"), targetFloor));
- }
- return new EncodedCommand(writes);
- }
-
- private static EncodedCommand secondStageOne(Ft546DeviceProfile profile,
- int taskNo,
- short taskType,
- int[] route) {
- List writes = new ArrayList();
- writes.add(PlcWrite.int32(register(profile, "secondPath"), route));
- writes.add(PlcWrite.int16(register(profile, "secondStageFlag"),
- (short) 1));
- writes.add(PlcWrite.int16(register(profile, "secondTaskType"),
- taskType));
- writes.add(PlcWrite.int32(register(profile, "secondTaskNo"), taskNo));
- return new EncodedCommand(writes);
- }
-
- private static EncodedCommand secondStageTwo(Ft546DeviceProfile profile,
- int taskNo, int[] route) {
- List writes = new ArrayList();
- writes.add(PlcWrite.int32(register(profile, "secondPath"), route));
- writes.add(PlcWrite.int16(register(profile, "secondStageFlag"),
- (short) 2));
- writes.add(PlcWrite.int16(register(profile, "secondComplete"),
- (short) 99));
- writes.add(PlcWrite.int16(register(profile, "secondTaskType"),
- (short) 9));
- writes.add(PlcWrite.int32(register(profile, "secondTaskNo"), taskNo));
- return new EncodedCommand(writes);
- }
-
- private static Map parse(String payload) {
- Map values = new LinkedHashMap();
- for (String segment : payload.split("\\|")) {
- int separator = segment.indexOf('=');
- if (separator <= 0 || separator == segment.length() - 1) {
- throw new IllegalArgumentException(
- "invalid command payload segment: " + segment);
- }
- String key = segment.substring(0, separator).trim();
- String value = segment.substring(separator + 1).trim();
- if (values.put(key, value) != null) {
- throw new IllegalArgumentException(
- "duplicate command payload field: " + key);
- }
- }
- return values;
- }
-
- private static int[] route(String value) {
- String[] nodes = value.split(">");
- if (nodes.length < 2 || nodes.length > 8) {
- throw new IllegalArgumentException(
- "legacy route must contain between 2 and 8 command points");
- }
- int[] result = new int[8];
- for (int index = 0; index < nodes.length; index++) {
- int node = positiveInt(nodes[index], "route node");
- if (node > 999999) {
- throw new IllegalArgumentException(
- "route node exceeds six digits");
- }
- result[index] = node;
- }
- return result;
- }
-
- private static int positiveInt(String value, String field) {
- try {
- int parsed = Integer.parseInt(value);
- if (parsed <= 0) {
- throw new IllegalArgumentException(field + " must be positive");
- }
- return parsed;
- } catch (NumberFormatException invalid) {
- throw new IllegalArgumentException(field + " is not an integer",
- invalid);
- }
- }
-
- private static short shortValue(String value, String field) {
- try {
- return Short.parseShort(value);
- } catch (NumberFormatException invalid) {
- throw new IllegalArgumentException(field + " is not an int16",
- invalid);
- }
- }
-
- private static String required(Map values, String field) {
- String value = values.get(field);
- if (value == null || value.trim().isEmpty()) {
- throw new IllegalArgumentException(field + " is required");
- }
- return value;
- }
-
- private static String register(Ft546DeviceProfile profile, String name) {
- String address = profile.getRegisters().get(name);
- if (address == null || address.trim().isEmpty()) {
- throw new IllegalArgumentException(
- "profile register is missing: " + name);
- }
- return address.trim();
- }
-
- static final class EncodedCommand {
- private final List writes;
-
- private EncodedCommand(List writes) {
- this.writes = Collections.unmodifiableList(
- new ArrayList(writes));
- }
-
- List getWrites() { return writes; }
- }
-
- static final class PlcWrite {
- private final String address;
- private final int[] int32;
- private final short[] int16;
-
- private PlcWrite(String address, int[] int32, short[] int16) {
- this.address = address;
- this.int32 = int32;
- this.int16 = int16;
- }
-
- static PlcWrite int32(String address, int... values) {
- return new PlcWrite(address, values.clone(), null);
- }
-
- static PlcWrite int16(String address, short... values) {
- return new PlcWrite(address, null, values.clone());
- }
-
- boolean writeTo(PlcSrgvRead runtime) {
- return int32 != null
- ? runtime.Write(address, int32.clone())
- : runtime.Write(address, int16.clone());
- }
- }
-}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvRuntimeFactory.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvRuntimeFactory.java
index cfb010c..d79df4a 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvRuntimeFactory.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvRuntimeFactory.java
@@ -2,7 +2,7 @@ package com.ruoyi.project.ft546.device.shuttle;
import com.ruoyi.common.domain.PlcConnectInfo;
import com.ruoyi.device.srgv.domain.DeviceSrgv;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
import org.springframework.stereotype.Component;
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/Ft546StandardWcsEndToEndTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/Ft546StandardWcsEndToEndTest.java
index 8c34c4d..48d43ec 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/Ft546StandardWcsEndToEndTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/Ft546StandardWcsEndToEndTest.java
@@ -12,7 +12,7 @@ import com.ruoyi.common.domain.PlcConnectInfo;
import com.ruoyi.device.srgv.domain.DeviceSrgv;
import com.ruoyi.project.ft546.config.Ft546CapabilityGate;
import com.ruoyi.project.ft546.config.Ft546CapabilityEvidence;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.project.ft546.config.Ft546WcsProperties;
import com.ruoyi.project.ft546.device.Ft546DeviceRuntimeRegistry;
import com.ruoyi.project.ft546.device.shuttle.Ft546LegacySrgvAdapter;
@@ -614,27 +614,27 @@ class Ft546StandardWcsEndToEndTest {
profile.setDeviceType("SHUTTLE");
profile.setAdapterType("FT546_LEGACY_SRGV");
profile.setProfileVersion(1L);
- profile.getCapabilities().add("POINT_TO_POINT_COMMAND");
- profile.getRegisters().put("taskNo", "4000");
- profile.getRegisters().put("taskType", "4002");
- profile.getRegisters().put("path", "4004");
- profile.getRegisters().put("trigger", "4021");
- profile.getRegisters().put("crossFloorFlag", "4020");
- profile.getRegisters().put("crossFloorTarget", "4022");
- profile.getRegisters().put("status", "4068");
- profile.getRegisters().put("execution", "4060");
+ profile.addCapability("POINT_TO_POINT_COMMAND");
+ profile.putRegister("taskNo", "4000");
+ profile.putRegister("taskType", "4002");
+ profile.putRegister("path", "4004");
+ profile.putRegister("trigger", "4021");
+ profile.putRegister("crossFloorFlag", "4020");
+ profile.putRegister("crossFloorTarget", "4022");
+ profile.putRegister("status", "4068");
+ profile.putRegister("execution", "4060");
return profile;
}
private static Ft546DeviceProfile integrationProfile() {
Ft546DeviceProfile profile = pointProfile();
- profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK");
+ profile.addCapability("TWO_STAGE_ATOMIC_TASK");
profile.setProtocolCorpusHash("sha256:ft546-two-stage-v1");
- profile.getRegisters().put("secondTaskNo", "4400");
- profile.getRegisters().put("secondTaskType", "4402");
- profile.getRegisters().put("secondPath", "4404");
- profile.getRegisters().put("secondStageFlag", "4422");
- profile.getRegisters().put("secondComplete", "4423");
+ profile.putRegister("secondTaskNo", "4400");
+ profile.putRegister("secondTaskType", "4402");
+ profile.putRegister("secondPath", "4404");
+ profile.putRegister("secondStageFlag", "4422");
+ profile.putRegister("secondComplete", "4423");
return profile;
}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidatorTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidatorTest.java
index 3d2a8a1..ee1b846 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidatorTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidatorTest.java
@@ -1,5 +1,7 @@
package com.ruoyi.project.ft546.config;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
+
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
@@ -31,7 +33,7 @@ class Ft546ConfigurationValidatorTest {
@Test
void rejectsLegacyProfileThatClaimsPrepareCommit() {
Ft546DeviceProfile profile = legacySrgvProfile();
- profile.getCapabilities().add("PREPARE_COMMIT_SEGMENT");
+ profile.addCapability("PREPARE_COMMIT_SEGMENT");
assertThrows(IllegalArgumentException.class,
() -> new Ft546ConfigurationValidator().validate(profile));
@@ -40,7 +42,7 @@ class Ft546ConfigurationValidatorTest {
@Test
void rejectsDuplicateRegisterOwnershipWithinOneDevice() {
Ft546DeviceProfile profile = legacySrgvProfile();
- profile.getRegisters().put("taskType", "4000");
+ profile.putRegister("taskType", "4000");
assertThrows(IllegalArgumentException.class,
() -> new Ft546ConfigurationValidator().validate(profile));
@@ -49,7 +51,7 @@ class Ft546ConfigurationValidatorTest {
@Test
void rejectsTwoStageCapabilityWithoutPassingEvidenceForExactProfile() {
Ft546DeviceProfile profile = legacySrgvProfile();
- profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK");
+ profile.addCapability("TWO_STAGE_ATOMIC_TASK");
Ft546CapabilityGate gate = gate();
CapabilityGateResult result = gate.evaluate(profile, null);
@@ -62,7 +64,7 @@ class Ft546ConfigurationValidatorTest {
@Test
void enablesTwoStageOnlyForPassingUnexpiredEvidenceBoundToProfile() {
Ft546DeviceProfile profile = legacySrgvProfile();
- profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK");
+ profile.addCapability("TWO_STAGE_ATOMIC_TASK");
Ft546CapabilityEvidence evidence = new Ft546CapabilityEvidence(
1L, "CORPUS-SHA256", "RUN-1", "COMMIT-1", true,
Instant.parse("2026-08-10T00:00:00Z"),
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546DeviceRuntimeRegistryTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546DeviceRuntimeRegistryTest.java
index 285761d..5739c64 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546DeviceRuntimeRegistryTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546DeviceRuntimeRegistryTest.java
@@ -3,7 +3,7 @@ package com.ruoyi.project.ft546.device;
import com.ruoyi.common.domain.PlcConnectInfo;
import com.ruoyi.common.domain.CustomThread;
import com.ruoyi.device.srgv.domain.DeviceSrgv;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.project.ft546.device.shuttle.Ft546SrgvRuntimeFactory;
import com.ruoyi.schedule.ScheduleThread;
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546EquipmentAdaptersTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546EquipmentAdaptersTest.java
index e1a267d..4d7f7dc 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546EquipmentAdaptersTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546EquipmentAdaptersTest.java
@@ -5,7 +5,7 @@ import com.ruoyi.device.cdz.domain.DeviceCdz;
import com.ruoyi.device.srgv.domain.DeviceSrgv;
import com.ruoyi.device.ssj.domain.DeviceSsj;
import com.ruoyi.device.tsj.domain.DeviceTsj;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.project.ft546.device.charger.Ft546ChargerAdapter;
import com.ruoyi.project.ft546.device.conveyor.Ft546ConveyorAdapter;
import com.ruoyi.project.ft546.device.lift.Ft546LiftAdapter;
@@ -220,8 +220,8 @@ class Ft546EquipmentAdaptersTest {
profile.setDeviceType(deviceType);
profile.setAdapterType("FT546_LEGACY_" + deviceType);
profile.setProfileVersion(1L);
- profile.getCapabilities().add(capability);
- profile.getRegisters().put("command", commandAddress);
+ profile.addCapability(capability);
+ profile.putRegister("command", commandAddress);
return profile;
}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546CommandIntentMapperTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546CommandIntentMapperTest.java
new file mode 100644
index 0000000..acc8726
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546CommandIntentMapperTest.java
@@ -0,0 +1,110 @@
+package com.ruoyi.project.ft546.device.shuttle;
+
+import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
+import com.ruoyi.wcs.application.device.DeviceCommandEnvelope;
+import com.ruoyi.wcs.domain.execution.DeviceCommandType;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class Ft546CommandIntentMapperTest {
+ private final Ft546CommandIntentMapper mapper =
+ new Ft546CommandIntentMapper();
+
+ @Test
+ void oldPayloadUsesDeterministicEvidenceFallbacks() {
+ Ft546SrgvCommandIntent intent = mapper.from(envelope("CMD-1",
+ "KEY-1", "taskNo=30001|taskType=1|"
+ + "route=070501>070502", 9L));
+
+ assertEquals("1", intent.getDeviceId());
+ assertEquals("CMD-1", intent.getParentTaskId());
+ assertEquals("LEGACY_MOVE_ROUTE", intent.getPhase());
+ assertEquals("KEY-1", intent.getCommandKey());
+ assertEquals(9L, intent.getRouteVersion());
+ assertEquals(30001, intent.getTaskNo());
+ assertEquals((short) 1, intent.getTaskType());
+ assertEquals(Arrays.asList(70501, 70502),
+ intent.getPathPoints());
+ assertNull(intent.getCrossFloorTarget());
+ assertNull(intent.getTwoStageNumber());
+ }
+
+ @Test
+ void explicitEvidenceFieldsOverrideOldFallbacks() {
+ Ft546SrgvCommandIntent intent = mapper.from(envelope("CMD-1",
+ "KEY-1", "parentTaskId=PARENT-1|phase=LOADED_TO_PLACE|"
+ + "routeVersion=3|taskNo=30001|taskType=8|"
+ + "route=070501>070502|crossFloorTarget=8", 9L));
+
+ assertEquals("PARENT-1", intent.getParentTaskId());
+ assertEquals("LOADED_TO_PLACE", intent.getPhase());
+ assertEquals(3L, intent.getRouteVersion());
+ assertEquals(Short.valueOf((short) 8),
+ intent.getCrossFloorTarget());
+ }
+
+ @Test
+ void orderIdIsTheOldPayloadParentTaskFallback() {
+ Ft546SrgvCommandIntent intent = mapper.from(envelope("CMD-1",
+ "KEY-1", "orderId=ORDER-1|taskNo=30001|taskType=1|"
+ + "route=070501>070502", 9L));
+
+ assertEquals("ORDER-1", intent.getParentTaskId());
+ }
+
+ @Test
+ void twoStagePayloadUsesStableStagePhase() {
+ Ft546SrgvCommandIntent intent = mapper.from(envelope("CMD-1",
+ "KEY-1", "taskNo=30001|taskType=9|stage=2|"
+ + "route=070501>070502", 9L));
+
+ assertEquals("TWO_STAGE_2", intent.getPhase());
+ assertEquals(Integer.valueOf(2), intent.getTwoStageNumber());
+ }
+
+ @Test
+ void irrelevantCrossFloorValueDoesNotRejectOrdinaryOrTwoStagePayload() {
+ Ft546SrgvCommandIntent ordinary = mapper.from(envelope("CMD-1",
+ "KEY-1", "taskNo=30001|taskType=1|"
+ + "route=070501>070502|crossFloorTarget=dirty", 9L));
+ Ft546SrgvCommandIntent stage = mapper.from(envelope("CMD-2",
+ "KEY-2", "taskNo=30001|taskType=9|stage=2|"
+ + "route=070501>070502|crossFloorTarget=dirty", 9L));
+
+ assertNull(ordinary.getCrossFloorTarget());
+ assertNull(stage.getCrossFloorTarget());
+ }
+
+ @Test
+ void malformedDuplicateAndWrongTypeKeepLegacyRejections() {
+ assertMessage("invalid command payload segment: broken",
+ envelope("CMD-1", "KEY-1", "broken", 9L));
+ assertMessage("duplicate command payload field: taskNo",
+ envelope("CMD-1", "KEY-1", "taskNo=1|taskNo=2|"
+ + "taskType=1|route=1>2", 9L));
+ assertMessage("legacy shuttle supports MOVE_ROUTE only",
+ new DeviceCommandEnvelope("CMD-1", "KEY-1", "1",
+ DeviceCommandType.CHARGE, "taskNo=1", 1, 9L, 17L));
+ }
+
+ private void assertMessage(String message,
+ DeviceCommandEnvelope envelope) {
+ RuntimeException failure = assertThrows(RuntimeException.class,
+ () -> mapper.from(envelope));
+ assertEquals(message, failure.getMessage());
+ }
+
+ private static DeviceCommandEnvelope envelope(String commandId,
+ String commandKey,
+ String payload,
+ long topologyVersion) {
+ return new DeviceCommandEnvelope(commandId, commandKey, "1",
+ DeviceCommandType.MOVE_ROUTE, payload, 1,
+ topologyVersion, 17L);
+ }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java
index fc113f4..6b9fd42 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java
@@ -6,7 +6,7 @@ import com.ruoyi.common.domain.PlcConnectInfo;
import com.ruoyi.device.srgv.domain.DeviceSrgv;
import com.ruoyi.project.ft546.config.Ft546CapabilityEvidence;
import com.ruoyi.project.ft546.config.Ft546CapabilityGate;
-import com.ruoyi.project.ft546.config.Ft546DeviceProfile;
+import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.project.ft546.device.Ft546DeviceRuntimeRegistry;
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
@@ -108,8 +108,8 @@ class Ft546LegacySrgvAdapterTest {
JsonNode fixture = fixture("srgv-point-to-point.json");
FakePlcSrgvRead runtime = connectedRuntime("1");
Ft546DeviceProfile profile = pointProfile();
- profile.getRegisters().put("crossFloorFlag", "4020");
- profile.getRegisters().put("crossFloorTarget", "4022");
+ profile.putRegister("crossFloorFlag", "4020");
+ profile.putRegister("crossFloorTarget", "4022");
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
null, (deviceId, commandId) -> false);
@@ -127,7 +127,7 @@ class Ft546LegacySrgvAdapterTest {
Ft546DeviceProfile profile = pointProfile();
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
null, (deviceId, commandId) -> false);
- profile.getRegisters().put("path", "9999");
+ profile.putRegister("path", "9999");
SendReceipt receipt = adapter.send(envelope("CMD-1",
fixture.get("payload").asText()));
@@ -223,7 +223,7 @@ class Ft546LegacySrgvAdapterTest {
throws Exception {
JsonNode fixture = fixture("srgv-two-stage-gate.json");
Ft546DeviceProfile profile = twoStageProfile();
- profile.getCapabilities().remove("POINT_TO_POINT_COMMAND");
+ profile.removeCapability("POINT_TO_POINT_COMMAND");
FakePlcSrgvRead runtime = connectedRuntime("1");
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
passingEvidence(profile),
@@ -295,19 +295,19 @@ class Ft546LegacySrgvAdapterTest {
private static Ft546DeviceProfile pointProfile() {
Ft546DeviceProfile profile = baseProfile();
- profile.getCapabilities().add("POINT_TO_POINT_COMMAND");
+ profile.addCapability("POINT_TO_POINT_COMMAND");
return profile;
}
private static Ft546DeviceProfile twoStageProfile() {
Ft546DeviceProfile profile = pointProfile();
- profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK");
+ profile.addCapability("TWO_STAGE_ATOMIC_TASK");
profile.setProtocolCorpusHash("sha256:ft546-two-stage-v1");
- profile.getRegisters().put("secondTaskNo", "4400");
- profile.getRegisters().put("secondTaskType", "4402");
- profile.getRegisters().put("secondPath", "4404");
- profile.getRegisters().put("secondStageFlag", "4422");
- profile.getRegisters().put("secondComplete", "4423");
+ profile.putRegister("secondTaskNo", "4400");
+ profile.putRegister("secondTaskType", "4402");
+ profile.putRegister("secondPath", "4404");
+ profile.putRegister("secondStageFlag", "4422");
+ profile.putRegister("secondComplete", "4423");
return profile;
}
@@ -317,12 +317,12 @@ class Ft546LegacySrgvAdapterTest {
profile.setDeviceType("SHUTTLE");
profile.setAdapterType("FT546_LEGACY_SRGV");
profile.setProfileVersion(1L);
- profile.getRegisters().put("taskNo", "4000");
- profile.getRegisters().put("taskType", "4002");
- profile.getRegisters().put("path", "4004");
- profile.getRegisters().put("trigger", "4021");
- profile.getRegisters().put("status", "4068");
- profile.getRegisters().put("execution", "4060");
+ profile.putRegister("taskNo", "4000");
+ profile.putRegister("taskType", "4002");
+ profile.putRegister("path", "4004");
+ profile.putRegister("trigger", "4021");
+ profile.putRegister("status", "4068");
+ profile.putRegister("execution", "4060");
return profile;
}
diff --git a/ruoyi-ft546-protocol/pom.xml b/ruoyi-ft546-protocol/pom.xml
new file mode 100644
index 0000000..3394ddc
--- /dev/null
+++ b/ruoyi-ft546-protocol/pom.xml
@@ -0,0 +1,23 @@
+
+
+
+ com.ruoyi
+ ruoyi
+ 3.8.9
+
+ 4.0.0
+ ruoyi-ft546-protocol
+ jar
+
+ Pure FT546 command protocol model and encoder
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546DeviceProfile.java b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546DeviceProfile.java
similarity index 67%
rename from ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546DeviceProfile.java
rename to ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546DeviceProfile.java
index ea55a8a..44175fd 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546DeviceProfile.java
+++ b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546DeviceProfile.java
@@ -1,5 +1,6 @@
-package com.ruoyi.project.ft546.config;
+package com.ruoyi.ft546.protocol;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
@@ -30,18 +31,42 @@ public class Ft546DeviceProfile {
public void setPort(Integer port) { this.port = port; }
public Integer getStation() { return station; }
public void setStation(Integer station) { this.station = station; }
- public Set getCapabilities() { return capabilities; }
+ public Set getCapabilities() {
+ return Collections.unmodifiableSet(capabilities);
+ }
public void setCapabilities(Set capabilities) {
this.capabilities = capabilities == null
? new LinkedHashSet()
: new LinkedHashSet(capabilities);
}
- public Map getRegisters() { return registers; }
+ public void addCapability(String capability) {
+ if (capability == null || capability.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "capability must not be blank");
+ }
+ capabilities.add(capability.trim());
+ }
+ public void removeCapability(String capability) {
+ capabilities.remove(capability);
+ }
+ public Map getRegisters() {
+ return Collections.unmodifiableMap(registers);
+ }
public void setRegisters(Map registers) {
this.registers = registers == null
? new LinkedHashMap()
: new LinkedHashMap(registers);
}
+ public void putRegister(String name, String address) {
+ if (name == null || name.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "register name must not be blank");
+ }
+ registers.put(name.trim(), address);
+ }
+ public void removeRegister(String name) {
+ registers.remove(name);
+ }
public long getProfileVersion() { return profileVersion; }
public void setProfileVersion(long profileVersion) {
this.profileVersion = profileVersion;
diff --git a/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546EncodedCommand.java b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546EncodedCommand.java
new file mode 100644
index 0000000..7e32a7d
--- /dev/null
+++ b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546EncodedCommand.java
@@ -0,0 +1,46 @@
+package com.ruoyi.ft546.protocol;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class Ft546EncodedCommand {
+ private final String deviceId;
+ private final String parentTaskId;
+ private final String phase;
+ private final String commandKey;
+ private final long routeVersion;
+ private final int taskNo;
+ private final short taskType;
+ private final List pathPoints;
+ private final List writes;
+ private final int triggerValue;
+
+ Ft546EncodedCommand(Ft546SrgvCommandIntent intent,
+ List writes,
+ int triggerValue) {
+ this.deviceId = intent.getDeviceId();
+ this.parentTaskId = intent.getParentTaskId();
+ this.phase = intent.getPhase();
+ this.commandKey = intent.getCommandKey();
+ this.routeVersion = intent.getRouteVersion();
+ this.taskNo = intent.getTaskNo();
+ this.taskType = intent.getTaskType();
+ this.pathPoints = Collections.unmodifiableList(
+ new ArrayList(intent.getPathPoints()));
+ this.writes = Collections.unmodifiableList(
+ new ArrayList(writes));
+ this.triggerValue = triggerValue;
+ }
+
+ public String getDeviceId() { return deviceId; }
+ public String getParentTaskId() { return parentTaskId; }
+ public String getPhase() { return phase; }
+ public String getCommandKey() { return commandKey; }
+ public long getRouteVersion() { return routeVersion; }
+ public int getTaskNo() { return taskNo; }
+ public short getTaskType() { return taskType; }
+ public List getPathPoints() { return pathPoints; }
+ public List getWrites() { return writes; }
+ public int getTriggerValue() { return triggerValue; }
+}
diff --git a/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546RegisterWrite.java b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546RegisterWrite.java
new file mode 100644
index 0000000..643bd46
--- /dev/null
+++ b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546RegisterWrite.java
@@ -0,0 +1,45 @@
+package com.ruoyi.ft546.protocol;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class Ft546RegisterWrite {
+ public enum DataType { INT16, INT32 }
+
+ private final String address;
+ private final DataType dataType;
+ private final List values;
+ private final int writeSequence;
+ private final boolean trigger;
+
+ Ft546RegisterWrite(String address, DataType dataType,
+ List values, int writeSequence,
+ boolean trigger) {
+ if (address == null || address.trim().isEmpty()) {
+ throw new IllegalArgumentException("address must not be blank");
+ }
+ if (dataType == null) {
+ throw new IllegalArgumentException("dataType must not be null");
+ }
+ if (values == null || values.isEmpty()) {
+ throw new IllegalArgumentException("values must not be empty");
+ }
+ if (writeSequence <= 0) {
+ throw new IllegalArgumentException(
+ "writeSequence must be positive");
+ }
+ this.address = address.trim();
+ this.dataType = dataType;
+ this.values = Collections.unmodifiableList(
+ new ArrayList(values));
+ this.writeSequence = writeSequence;
+ this.trigger = trigger;
+ }
+
+ public String getAddress() { return address; }
+ public DataType getDataType() { return dataType; }
+ public List getValues() { return values; }
+ public int getWriteSequence() { return writeSequence; }
+ public boolean isTrigger() { return trigger; }
+}
diff --git a/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoder.java b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoder.java
new file mode 100644
index 0000000..fb53906
--- /dev/null
+++ b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoder.java
@@ -0,0 +1,153 @@
+package com.ruoyi.ft546.protocol;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+public final class Ft546SrgvCommandEncoder {
+ public Ft546EncodedCommand encode(Ft546SrgvCommandIntent intent,
+ Ft546DeviceProfile profile,
+ Set capabilities) {
+ if (intent == null) {
+ throw new IllegalArgumentException("intent must not be null");
+ }
+ if (profile == null) {
+ throw new IllegalArgumentException("profile must not be null");
+ }
+ if (capabilities == null) {
+ throw new IllegalArgumentException(
+ "capabilities must not be null");
+ }
+ int[] route = route(intent.getPathPoints());
+ Integer stage = intent.getTwoStageNumber();
+ if (stage == null) {
+ if (!capabilities.contains("POINT_TO_POINT_COMMAND")) {
+ throw new IllegalStateException(
+ "POINT_TO_POINT_COMMAND is not enabled");
+ }
+ return main(intent, profile, route);
+ }
+ if (!capabilities.contains("TWO_STAGE_ATOMIC_TASK")) {
+ throw new IllegalStateException(
+ "TWO_STAGE_ATOMIC_TASK is not enabled");
+ }
+ if (stage.intValue() == 1) {
+ return secondStageOne(intent, profile, route);
+ }
+ if (stage.intValue() == 2) {
+ return secondStageTwo(intent, profile, route);
+ }
+ throw new IllegalArgumentException("stage must be 1 or 2");
+ }
+
+ private static Ft546EncodedCommand main(Ft546SrgvCommandIntent intent,
+ Ft546DeviceProfile profile,
+ int[] route) {
+ List writes =
+ new ArrayList();
+ add(writes, profile, "path", Ft546RegisterWrite.DataType.INT32,
+ false, route);
+ add(writes, profile, "taskNo", Ft546RegisterWrite.DataType.INT32,
+ false, intent.getTaskNo());
+ add(writes, profile, "taskType", Ft546RegisterWrite.DataType.INT16,
+ false, intent.getTaskType());
+ add(writes, profile, "trigger", Ft546RegisterWrite.DataType.INT16,
+ true, 1);
+ if (intent.getTaskType() == 8) {
+ if (intent.getCrossFloorTarget() == null) {
+ throw new IllegalArgumentException(
+ "crossFloorTarget is required");
+ }
+ add(writes, profile, "crossFloorFlag",
+ Ft546RegisterWrite.DataType.INT16, false, 1);
+ add(writes, profile, "crossFloorTarget",
+ Ft546RegisterWrite.DataType.INT16, false,
+ intent.getCrossFloorTarget());
+ }
+ return new Ft546EncodedCommand(intent, writes, 1);
+ }
+
+ private static Ft546EncodedCommand secondStageOne(
+ Ft546SrgvCommandIntent intent, Ft546DeviceProfile profile,
+ int[] route) {
+ List writes =
+ new ArrayList();
+ add(writes, profile, "secondPath",
+ Ft546RegisterWrite.DataType.INT32, false, route);
+ add(writes, profile, "secondStageFlag",
+ Ft546RegisterWrite.DataType.INT16, true, 1);
+ add(writes, profile, "secondTaskType",
+ Ft546RegisterWrite.DataType.INT16, false,
+ intent.getTaskType());
+ add(writes, profile, "secondTaskNo",
+ Ft546RegisterWrite.DataType.INT32, false,
+ intent.getTaskNo());
+ return new Ft546EncodedCommand(intent, writes, 1);
+ }
+
+ private static Ft546EncodedCommand secondStageTwo(
+ Ft546SrgvCommandIntent intent, Ft546DeviceProfile profile,
+ int[] route) {
+ List writes =
+ new ArrayList();
+ add(writes, profile, "secondPath",
+ Ft546RegisterWrite.DataType.INT32, false, route);
+ add(writes, profile, "secondStageFlag",
+ Ft546RegisterWrite.DataType.INT16, true, 2);
+ add(writes, profile, "secondComplete",
+ Ft546RegisterWrite.DataType.INT16, false, 99);
+ add(writes, profile, "secondTaskType",
+ Ft546RegisterWrite.DataType.INT16, false, 9);
+ add(writes, profile, "secondTaskNo",
+ Ft546RegisterWrite.DataType.INT32, false,
+ intent.getTaskNo());
+ return new Ft546EncodedCommand(intent, writes, 2);
+ }
+
+ private static void add(List writes,
+ Ft546DeviceProfile profile, String register,
+ Ft546RegisterWrite.DataType dataType,
+ boolean trigger, int... values) {
+ List boxed = new ArrayList(values.length);
+ for (int value : values) {
+ if (dataType == Ft546RegisterWrite.DataType.INT16
+ && (value < Short.MIN_VALUE || value > Short.MAX_VALUE)) {
+ throw new IllegalArgumentException(
+ register + " value is not an int16");
+ }
+ boxed.add(value);
+ }
+ writes.add(new Ft546RegisterWrite(register(profile, register),
+ dataType, boxed, writes.size() + 1, trigger));
+ }
+
+ private static int[] route(List points) {
+ if (points.size() < 2 || points.size() > 8) {
+ throw new IllegalArgumentException(
+ "legacy route must contain between 2 and 8 command points");
+ }
+ int[] result = new int[8];
+ for (int index = 0; index < points.size(); index++) {
+ Integer point = points.get(index);
+ if (point == null || point.intValue() <= 0) {
+ throw new IllegalArgumentException(
+ "route node must be positive");
+ }
+ if (point.intValue() > 999999) {
+ throw new IllegalArgumentException(
+ "route node exceeds six digits");
+ }
+ result[index] = point.intValue();
+ }
+ return result;
+ }
+
+ private static String register(Ft546DeviceProfile profile, String name) {
+ String address = profile.getRegisters().get(name);
+ if (address == null || address.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "profile register is missing: " + name);
+ }
+ return address.trim();
+ }
+}
diff --git a/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandIntent.java b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandIntent.java
new file mode 100644
index 0000000..b883b0c
--- /dev/null
+++ b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandIntent.java
@@ -0,0 +1,64 @@
+package com.ruoyi.ft546.protocol;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+public final class Ft546SrgvCommandIntent {
+ private final String deviceId;
+ private final String parentTaskId;
+ private final String phase;
+ private final String commandKey;
+ private final long routeVersion;
+ private final int taskNo;
+ private final short taskType;
+ private final List pathPoints;
+ private final Short crossFloorTarget;
+ private final Integer twoStageNumber;
+
+ public Ft546SrgvCommandIntent(String deviceId, String parentTaskId,
+ String phase, String commandKey,
+ long routeVersion, int taskNo,
+ short taskType, List pathPoints,
+ Short crossFloorTarget,
+ Integer twoStageNumber) {
+ this.deviceId = requireText(deviceId, "deviceId");
+ this.parentTaskId = requireText(parentTaskId, "parentTaskId");
+ this.phase = requireText(phase, "phase");
+ this.commandKey = requireText(commandKey, "commandKey");
+ if (routeVersion <= 0L) {
+ throw new IllegalArgumentException(
+ "routeVersion must be positive");
+ }
+ if (taskNo <= 0) {
+ throw new IllegalArgumentException("taskNo must be positive");
+ }
+ this.routeVersion = routeVersion;
+ this.taskNo = taskNo;
+ this.taskType = taskType;
+ this.pathPoints = Collections.unmodifiableList(
+ new ArrayList(Objects.requireNonNull(pathPoints,
+ "pathPoints")));
+ this.crossFloorTarget = crossFloorTarget;
+ this.twoStageNumber = twoStageNumber;
+ }
+
+ public String getDeviceId() { return deviceId; }
+ public String getParentTaskId() { return parentTaskId; }
+ public String getPhase() { return phase; }
+ public String getCommandKey() { return commandKey; }
+ public long getRouteVersion() { return routeVersion; }
+ public int getTaskNo() { return taskNo; }
+ public short getTaskType() { return taskType; }
+ public List getPathPoints() { return pathPoints; }
+ public Short getCrossFloorTarget() { return crossFloorTarget; }
+ public Integer getTwoStageNumber() { return twoStageNumber; }
+
+ private static String requireText(String value, String field) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException(field + " must not be blank");
+ }
+ return value.trim();
+ }
+}
diff --git a/ruoyi-ft546-protocol/src/test/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoderTest.java b/ruoyi-ft546-protocol/src/test/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoderTest.java
new file mode 100644
index 0000000..8b06932
--- /dev/null
+++ b/ruoyi-ft546-protocol/src/test/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoderTest.java
@@ -0,0 +1,278 @@
+package com.ruoyi.ft546.protocol;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static com.ruoyi.ft546.protocol.Ft546RegisterWrite.DataType.INT16;
+import static com.ruoyi.ft546.protocol.Ft546RegisterWrite.DataType.INT32;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class Ft546SrgvCommandEncoderTest {
+ private final Ft546SrgvCommandEncoder encoder =
+ new Ft546SrgvCommandEncoder();
+
+ @Test
+ void ordinaryIntentEncodesTheExactLegacySequence() {
+ Ft546EncodedCommand encoded = encoder.encode(
+ intent((short) 1, points(70501, 70502), null, null),
+ fullProfile(), capabilities("POINT_TO_POINT_COMMAND"));
+
+ assertWrites(encoded,
+ write(1, "4004", INT32, false,
+ 70501, 70502, 0, 0, 0, 0, 0, 0),
+ write(2, "4000", INT32, false, 30001),
+ write(3, "4002", INT16, false, 1),
+ write(4, "4021", INT16, true, 1));
+ assertEquals("1", encoded.getDeviceId());
+ assertEquals("PARENT-1", encoded.getParentTaskId());
+ assertEquals("LOADED_TO_PLACE", encoded.getPhase());
+ assertEquals("KEY-1", encoded.getCommandKey());
+ assertEquals(3L, encoded.getRouteVersion());
+ assertEquals(30001, encoded.getTaskNo());
+ assertEquals((short) 1, encoded.getTaskType());
+ assertEquals(points(70501, 70502), encoded.getPathPoints());
+ assertEquals(1, encoded.getTriggerValue());
+ }
+
+ @Test
+ void crossFloorIntentEncodesTheExactLegacyPostTriggerSequence() {
+ Ft546EncodedCommand encoded = encoder.encode(
+ intent((short) 8, points(70501, 70502),
+ Short.valueOf((short) 8), null),
+ fullProfile(), capabilities("POINT_TO_POINT_COMMAND"));
+
+ assertWrites(encoded,
+ write(1, "4004", INT32, false,
+ 70501, 70502, 0, 0, 0, 0, 0, 0),
+ write(2, "4000", INT32, false, 30001),
+ write(3, "4002", INT16, false, 8),
+ write(4, "4021", INT16, true, 1),
+ write(5, "4020", INT16, false, 1),
+ write(6, "4022", INT16, false, 8));
+ }
+
+ @Test
+ void twoStageOneEncodesTheExactLegacySequence() {
+ Ft546EncodedCommand encoded = encoder.encode(
+ intent((short) 1, points(70501, 70503), null, 1),
+ fullProfile(), capabilities("TWO_STAGE_ATOMIC_TASK"));
+
+ assertWrites(encoded,
+ write(1, "4404", INT32, false,
+ 70501, 70503, 0, 0, 0, 0, 0, 0),
+ write(2, "4422", INT16, true, 1),
+ write(3, "4402", INT16, false, 1),
+ write(4, "4400", INT32, false, 30001));
+ assertEquals(1, encoded.getTriggerValue());
+ }
+
+ @Test
+ void twoStageTwoEncodesTheExactLegacySequence() {
+ Ft546EncodedCommand encoded = encoder.encode(
+ intent((short) 9, points(70503, 70504), null, 2),
+ fullProfile(), capabilities("TWO_STAGE_ATOMIC_TASK"));
+
+ assertWrites(encoded,
+ write(1, "4404", INT32, false,
+ 70503, 70504, 0, 0, 0, 0, 0, 0),
+ write(2, "4422", INT16, true, 2),
+ write(3, "4423", INT16, false, 99),
+ write(4, "4402", INT16, false, 9),
+ write(5, "4400", INT32, false, 30001));
+ assertEquals(2, encoded.getTriggerValue());
+ }
+
+ @Test
+ void missingRegisterFailsClosedBeforeEncoding() {
+ Ft546DeviceProfile profile = fullProfile();
+ Map registers = new LinkedHashMap(
+ profile.getRegisters());
+ registers.remove("trigger");
+ profile.setRegisters(registers);
+
+ IllegalArgumentException failure = assertThrows(
+ IllegalArgumentException.class,
+ () -> encoder.encode(intent((short) 1,
+ points(70501, 70502), null, null),
+ profile, capabilities("POINT_TO_POINT_COMMAND")));
+
+ assertEquals("profile register is missing: trigger",
+ failure.getMessage());
+ }
+
+ @Test
+ void routeLengthTwoAndEightAreAcceptedAndPaddedExactly() {
+ Ft546EncodedCommand two = encodeRoute(points(1, 2));
+ Ft546EncodedCommand eight = encodeRoute(
+ points(1, 2, 3, 4, 5, 6, 7, 8));
+
+ assertEquals(points(1, 2, 0, 0, 0, 0, 0, 0),
+ two.getWrites().get(0).getValues());
+ assertEquals(points(1, 2, 3, 4, 5, 6, 7, 8),
+ eight.getWrites().get(0).getValues());
+ }
+
+ @Test
+ void routeLengthOneAndNineAreRejected() {
+ assertRouteRejected(points(1));
+ assertRouteRejected(points(1, 2, 3, 4, 5, 6, 7, 8, 9));
+ }
+
+ @Test
+ void routePointBeyondSixDigitsIsRejected() {
+ IllegalArgumentException failure = assertThrows(
+ IllegalArgumentException.class,
+ () -> encodeRoute(points(1, 1000000)));
+
+ assertEquals("route node exceeds six digits", failure.getMessage());
+ }
+
+ @Test
+ void capabilityGatesMainAndTwoStageIndependently() {
+ IllegalStateException main = assertThrows(IllegalStateException.class,
+ () -> encoder.encode(intent((short) 1,
+ points(1, 2), null, null),
+ fullProfile(), Collections.emptySet()));
+ IllegalStateException stage = assertThrows(IllegalStateException.class,
+ () -> encoder.encode(intent((short) 1,
+ points(1, 2), null, 1),
+ fullProfile(), capabilities(
+ "POINT_TO_POINT_COMMAND")));
+
+ assertEquals("POINT_TO_POINT_COMMAND is not enabled",
+ main.getMessage());
+ assertEquals("TWO_STAGE_ATOMIC_TASK is not enabled",
+ stage.getMessage());
+ }
+
+ @Test
+ void profileIntentAndEncodedCollectionsAreImmutableSnapshots() {
+ Ft546DeviceProfile profile = fullProfile();
+ List mutablePoints = new ArrayList(
+ points(70501, 70502));
+ Ft546SrgvCommandIntent intent = intent((short) 1,
+ mutablePoints, null, null);
+ mutablePoints.set(0, 99999);
+ Ft546EncodedCommand encoded = encoder.encode(intent, profile,
+ capabilities("POINT_TO_POINT_COMMAND"));
+
+ assertThrows(UnsupportedOperationException.class,
+ () -> profile.getRegisters().put("path", "9999"));
+ assertThrows(UnsupportedOperationException.class,
+ () -> profile.getCapabilities().add("UNSAFE"));
+ assertThrows(UnsupportedOperationException.class,
+ () -> intent.getPathPoints().add(3));
+ assertThrows(UnsupportedOperationException.class,
+ () -> encoded.getWrites().clear());
+ assertThrows(UnsupportedOperationException.class,
+ () -> encoded.getWrites().get(0).getValues().set(0, 99999));
+ assertEquals(Integer.valueOf(70501),
+ encoded.getPathPoints().get(0));
+ }
+
+ private Ft546EncodedCommand encodeRoute(List route) {
+ return encoder.encode(intent((short) 1, route, null, null),
+ fullProfile(), capabilities("POINT_TO_POINT_COMMAND"));
+ }
+
+ private void assertRouteRejected(List route) {
+ IllegalArgumentException failure = assertThrows(
+ IllegalArgumentException.class, () -> encodeRoute(route));
+ assertEquals(
+ "legacy route must contain between 2 and 8 command points",
+ failure.getMessage());
+ }
+
+ private static Ft546SrgvCommandIntent intent(short taskType,
+ List route,
+ Short crossFloorTarget,
+ Integer stage) {
+ return new Ft546SrgvCommandIntent("1", "PARENT-1",
+ "LOADED_TO_PLACE", "KEY-1", 3L, 30001, taskType,
+ route, crossFloorTarget, stage);
+ }
+
+ private static Ft546DeviceProfile fullProfile() {
+ Ft546DeviceProfile profile = new Ft546DeviceProfile();
+ profile.setDeviceId("1");
+ profile.setDeviceType("SHUTTLE");
+ profile.setAdapterType("FT546_LEGACY_SRGV");
+ profile.setProfileVersion(1L);
+ profile.setCapabilities(capabilities("POINT_TO_POINT_COMMAND",
+ "TWO_STAGE_ATOMIC_TASK"));
+ Map registers =
+ new LinkedHashMap();
+ registers.put("taskNo", "4000");
+ registers.put("taskType", "4002");
+ registers.put("path", "4004");
+ registers.put("trigger", "4021");
+ registers.put("crossFloorFlag", "4020");
+ registers.put("crossFloorTarget", "4022");
+ registers.put("secondTaskNo", "4400");
+ registers.put("secondTaskType", "4402");
+ registers.put("secondPath", "4404");
+ registers.put("secondStageFlag", "4422");
+ registers.put("secondComplete", "4423");
+ profile.setRegisters(registers);
+ return profile;
+ }
+
+ private static Set capabilities(String... values) {
+ return new LinkedHashSet(Arrays.asList(values));
+ }
+
+ private static List points(Integer... values) {
+ return Arrays.asList(values);
+ }
+
+ private static ExpectedWrite write(int sequence, String address,
+ Ft546RegisterWrite.DataType dataType,
+ boolean trigger,
+ Integer... values) {
+ return new ExpectedWrite(sequence, address, dataType, trigger,
+ Arrays.asList(values));
+ }
+
+ private static void assertWrites(Ft546EncodedCommand command,
+ ExpectedWrite... expected) {
+ assertEquals(expected.length, command.getWrites().size());
+ for (int index = 0; index < expected.length; index++) {
+ ExpectedWrite wanted = expected[index];
+ Ft546RegisterWrite actual = command.getWrites().get(index);
+ assertEquals(wanted.sequence, actual.getWriteSequence());
+ assertEquals(wanted.address, actual.getAddress());
+ assertEquals(wanted.dataType, actual.getDataType());
+ assertEquals(wanted.values, actual.getValues());
+ assertEquals(wanted.trigger, actual.isTrigger());
+ }
+ }
+
+ private static final class ExpectedWrite {
+ private final int sequence;
+ private final String address;
+ private final Ft546RegisterWrite.DataType dataType;
+ private final boolean trigger;
+ private final List values;
+
+ private ExpectedWrite(int sequence, String address,
+ Ft546RegisterWrite.DataType dataType,
+ boolean trigger, List values) {
+ this.sequence = sequence;
+ this.address = address;
+ this.dataType = dataType;
+ this.trigger = trigger;
+ this.values = values;
+ }
+ }
+}
diff --git a/ruoyi-ft546-protocol/src/test/resources/ft546/plc/srgv-point-to-point.json b/ruoyi-ft546-protocol/src/test/resources/ft546/plc/srgv-point-to-point.json
new file mode 100644
index 0000000..df7fc2e
--- /dev/null
+++ b/ruoyi-ft546-protocol/src/test/resources/ft546/plc/srgv-point-to-point.json
@@ -0,0 +1,16 @@
+{
+ "ordinary": [
+ {"address": "4004", "kind": "INT32", "values": [70501, 70502, 0, 0, 0, 0, 0, 0]},
+ {"address": "4000", "kind": "INT32", "values": [30001]},
+ {"address": "4002", "kind": "INT16", "values": [1]},
+ {"address": "4021", "kind": "INT16", "values": [1]}
+ ],
+ "crossFloor": [
+ {"address": "4004", "kind": "INT32", "values": [70501, 70502, 0, 0, 0, 0, 0, 0]},
+ {"address": "4000", "kind": "INT32", "values": [30001]},
+ {"address": "4002", "kind": "INT16", "values": [8]},
+ {"address": "4021", "kind": "INT16", "values": [1]},
+ {"address": "4020", "kind": "INT16", "values": [1]},
+ {"address": "4022", "kind": "INT16", "values": [8]}
+ ]
+}
diff --git a/ruoyi-ft546-protocol/src/test/resources/ft546/plc/srgv-two-stage-gate.json b/ruoyi-ft546-protocol/src/test/resources/ft546/plc/srgv-two-stage-gate.json
new file mode 100644
index 0000000..4463024
--- /dev/null
+++ b/ruoyi-ft546-protocol/src/test/resources/ft546/plc/srgv-two-stage-gate.json
@@ -0,0 +1,15 @@
+{
+ "stageOne": [
+ {"address": "4404", "kind": "INT32", "values": [70501, 70503, 0, 0, 0, 0, 0, 0]},
+ {"address": "4422", "kind": "INT16", "values": [1]},
+ {"address": "4402", "kind": "INT16", "values": [1]},
+ {"address": "4400", "kind": "INT32", "values": [30001]}
+ ],
+ "stageTwo": [
+ {"address": "4404", "kind": "INT32", "values": [70503, 70504, 0, 0, 0, 0, 0, 0]},
+ {"address": "4422", "kind": "INT16", "values": [2]},
+ {"address": "4423", "kind": "INT16", "values": [99]},
+ {"address": "4402", "kind": "INT16", "values": [9]},
+ {"address": "4400", "kind": "INT32", "values": [30001]}
+ ]
+}