refactor(ft546): share pure PLC command encoding
This commit is contained in:
8
pom.xml
8
pom.xml
@@ -230,6 +230,13 @@
|
|||||||
<version>${ruoyi.version}</version>
|
<version>${ruoyi.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- FT546 纯协议模块 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.ruoyi</groupId>
|
||||||
|
<artifactId>ruoyi-ft546-protocol</artifactId>
|
||||||
|
<version>${ruoyi.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!--hutool工具包-->
|
<!--hutool工具包-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.hutool</groupId>
|
<groupId>cn.hutool</groupId>
|
||||||
@@ -256,6 +263,7 @@
|
|||||||
|
|
||||||
<modules>
|
<modules>
|
||||||
<module>ruoyi-admin</module>
|
<module>ruoyi-admin</module>
|
||||||
|
<module>ruoyi-ft546-protocol</module>
|
||||||
<module>ruoyi-wcs</module>
|
<module>ruoyi-wcs</module>
|
||||||
<module>ruoyi-framework</module>
|
<module>ruoyi-framework</module>
|
||||||
<module>ruoyi-system</module>
|
<module>ruoyi-system</module>
|
||||||
|
|||||||
@@ -60,6 +60,12 @@
|
|||||||
<artifactId>ruoyi-wcs</artifactId>
|
<artifactId>ruoyi-wcs</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- FT546 纯协议编码 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.ruoyi</groupId>
|
||||||
|
<artifactId>ruoyi-ft546-protocol</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- 定时任务-->
|
<!-- 定时任务-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.ruoyi</groupId>
|
<groupId>com.ruoyi</groupId>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.ruoyi.project.ft546.config;
|
package com.ruoyi.project.ft546.config;
|
||||||
|
|
||||||
|
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.ruoyi.project.ft546.config;
|
package com.ruoyi.project.ft546.config;
|
||||||
|
|
||||||
|
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.ruoyi.project.ft546.device;
|
package com.ruoyi.project.ft546.device;
|
||||||
|
|
||||||
import com.ruoyi.project.ft546.config.Ft546ConfigurationValidator;
|
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.DeviceCommandEnvelope;
|
||||||
import com.ruoyi.wcs.application.device.SendReceipt;
|
import com.ruoyi.wcs.application.device.SendReceipt;
|
||||||
import com.ruoyi.wcs.domain.execution.DeviceCommandType;
|
import com.ruoyi.wcs.domain.execution.DeviceCommandType;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.ruoyi.project.ft546.device.charger;
|
package com.ruoyi.project.ft546.device.charger;
|
||||||
|
|
||||||
import com.ruoyi.device.cdz.domain.DeviceCdz;
|
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.AbstractFt546EquipmentAdapter;
|
||||||
import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec;
|
import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec;
|
||||||
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
|
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.ruoyi.project.ft546.device.conveyor;
|
package com.ruoyi.project.ft546.device.conveyor;
|
||||||
|
|
||||||
import com.ruoyi.device.ssj.domain.DeviceSsj;
|
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.AbstractFt546EquipmentAdapter;
|
||||||
import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec;
|
import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec;
|
||||||
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
|
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.ruoyi.project.ft546.device.lift;
|
package com.ruoyi.project.ft546.device.lift;
|
||||||
|
|
||||||
import com.ruoyi.device.tsj.domain.DeviceTsj;
|
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.AbstractFt546EquipmentAdapter;
|
||||||
import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec;
|
import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec;
|
||||||
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
|
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
|
||||||
|
|||||||
@@ -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<String, String> values = parse(envelope.getPayload());
|
||||||
|
int taskNo = positiveInt(required(values, "taskNo"), "taskNo");
|
||||||
|
short taskType = shortValue(required(values, "taskType"),
|
||||||
|
"taskType");
|
||||||
|
List<Integer> 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<String, String> parse(String payload) {
|
||||||
|
Map<String, String> values = new LinkedHashMap<String, String>();
|
||||||
|
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<Integer> 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<Integer> result = new ArrayList<Integer>(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<String, String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,10 @@ package com.ruoyi.project.ft546.device.shuttle;
|
|||||||
import com.ruoyi.project.ft546.config.CapabilityGateResult;
|
import com.ruoyi.project.ft546.config.CapabilityGateResult;
|
||||||
import com.ruoyi.project.ft546.config.Ft546CapabilityEvidence;
|
import com.ruoyi.project.ft546.config.Ft546CapabilityEvidence;
|
||||||
import com.ruoyi.project.ft546.config.Ft546CapabilityGate;
|
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.Ft546DeviceRuntimeRegistry;
|
||||||
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
|
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
|
||||||
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
|
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
|
||||||
@@ -28,6 +31,10 @@ public final class Ft546LegacySrgvAdapter
|
|||||||
private final SnapshotContextProvider snapshotContextProvider;
|
private final SnapshotContextProvider snapshotContextProvider;
|
||||||
private final Ft546SrgvCommandEncoder encoder =
|
private final Ft546SrgvCommandEncoder encoder =
|
||||||
new Ft546SrgvCommandEncoder();
|
new Ft546SrgvCommandEncoder();
|
||||||
|
private final Ft546CommandIntentMapper intentMapper =
|
||||||
|
new Ft546CommandIntentMapper();
|
||||||
|
private final Ft546PlcRegisterWriter plcRegisterWriter =
|
||||||
|
new Ft546PlcRegisterWriter();
|
||||||
private final Ft546SrgvSnapshotMapper snapshotMapper =
|
private final Ft546SrgvSnapshotMapper snapshotMapper =
|
||||||
new Ft546SrgvSnapshotMapper();
|
new Ft546SrgvSnapshotMapper();
|
||||||
private final Set<String> locallyUnknownDevices =
|
private final Set<String> locallyUnknownDevices =
|
||||||
@@ -81,9 +88,10 @@ public final class Ft546LegacySrgvAdapter
|
|||||||
+ "unresolved UNKNOWN command");
|
+ "unresolved UNKNOWN command");
|
||||||
}
|
}
|
||||||
|
|
||||||
final Ft546SrgvCommandEncoder.EncodedCommand command;
|
final Ft546EncodedCommand command;
|
||||||
try {
|
try {
|
||||||
command = encoder.encode(envelope, profile, capabilities);
|
command = encoder.encode(intentMapper.from(envelope), profile,
|
||||||
|
capabilities.getEnabledCapabilities());
|
||||||
} catch (IllegalArgumentException | IllegalStateException rejected) {
|
} catch (IllegalArgumentException | IllegalStateException rejected) {
|
||||||
return SendReceipt.rejected(receiptId,
|
return SendReceipt.rejected(receiptId,
|
||||||
rejected.getMessage());
|
rejected.getMessage());
|
||||||
@@ -100,10 +108,9 @@ public final class Ft546LegacySrgvAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
boolean wroteAny = false;
|
boolean wroteAny = false;
|
||||||
for (Ft546SrgvCommandEncoder.PlcWrite write
|
for (Ft546RegisterWrite write : command.getWrites()) {
|
||||||
: command.getWrites()) {
|
|
||||||
try {
|
try {
|
||||||
if (!write.writeTo(runtime)) {
|
if (!plcRegisterWriter.write(runtime, write)) {
|
||||||
return markUnknown(envelope.getDeviceId(), receiptId,
|
return markUnknown(envelope.getDeviceId(), receiptId,
|
||||||
wroteAny
|
wroteAny
|
||||||
? "PLC rejected a partial register sequence"
|
? "PLC rejected a partial register sequence"
|
||||||
|
|||||||
@@ -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<Integer> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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<String, String> values,
|
|
||||||
int taskNo, short taskType,
|
|
||||||
int[] route) {
|
|
||||||
List<PlcWrite> writes = new ArrayList<PlcWrite>();
|
|
||||||
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<PlcWrite> writes = new ArrayList<PlcWrite>();
|
|
||||||
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<PlcWrite> writes = new ArrayList<PlcWrite>();
|
|
||||||
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<String, String> parse(String payload) {
|
|
||||||
Map<String, String> values = new LinkedHashMap<String, String>();
|
|
||||||
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<String, String> 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<PlcWrite> writes;
|
|
||||||
|
|
||||||
private EncodedCommand(List<PlcWrite> writes) {
|
|
||||||
this.writes = Collections.unmodifiableList(
|
|
||||||
new ArrayList<PlcWrite>(writes));
|
|
||||||
}
|
|
||||||
|
|
||||||
List<PlcWrite> 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ package com.ruoyi.project.ft546.device.shuttle;
|
|||||||
|
|
||||||
import com.ruoyi.common.domain.PlcConnectInfo;
|
import com.ruoyi.common.domain.PlcConnectInfo;
|
||||||
import com.ruoyi.device.srgv.domain.DeviceSrgv;
|
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 com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import com.ruoyi.common.domain.PlcConnectInfo;
|
|||||||
import com.ruoyi.device.srgv.domain.DeviceSrgv;
|
import com.ruoyi.device.srgv.domain.DeviceSrgv;
|
||||||
import com.ruoyi.project.ft546.config.Ft546CapabilityGate;
|
import com.ruoyi.project.ft546.config.Ft546CapabilityGate;
|
||||||
import com.ruoyi.project.ft546.config.Ft546CapabilityEvidence;
|
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.config.Ft546WcsProperties;
|
||||||
import com.ruoyi.project.ft546.device.Ft546DeviceRuntimeRegistry;
|
import com.ruoyi.project.ft546.device.Ft546DeviceRuntimeRegistry;
|
||||||
import com.ruoyi.project.ft546.device.shuttle.Ft546LegacySrgvAdapter;
|
import com.ruoyi.project.ft546.device.shuttle.Ft546LegacySrgvAdapter;
|
||||||
@@ -614,27 +614,27 @@ class Ft546StandardWcsEndToEndTest {
|
|||||||
profile.setDeviceType("SHUTTLE");
|
profile.setDeviceType("SHUTTLE");
|
||||||
profile.setAdapterType("FT546_LEGACY_SRGV");
|
profile.setAdapterType("FT546_LEGACY_SRGV");
|
||||||
profile.setProfileVersion(1L);
|
profile.setProfileVersion(1L);
|
||||||
profile.getCapabilities().add("POINT_TO_POINT_COMMAND");
|
profile.addCapability("POINT_TO_POINT_COMMAND");
|
||||||
profile.getRegisters().put("taskNo", "4000");
|
profile.putRegister("taskNo", "4000");
|
||||||
profile.getRegisters().put("taskType", "4002");
|
profile.putRegister("taskType", "4002");
|
||||||
profile.getRegisters().put("path", "4004");
|
profile.putRegister("path", "4004");
|
||||||
profile.getRegisters().put("trigger", "4021");
|
profile.putRegister("trigger", "4021");
|
||||||
profile.getRegisters().put("crossFloorFlag", "4020");
|
profile.putRegister("crossFloorFlag", "4020");
|
||||||
profile.getRegisters().put("crossFloorTarget", "4022");
|
profile.putRegister("crossFloorTarget", "4022");
|
||||||
profile.getRegisters().put("status", "4068");
|
profile.putRegister("status", "4068");
|
||||||
profile.getRegisters().put("execution", "4060");
|
profile.putRegister("execution", "4060");
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Ft546DeviceProfile integrationProfile() {
|
private static Ft546DeviceProfile integrationProfile() {
|
||||||
Ft546DeviceProfile profile = pointProfile();
|
Ft546DeviceProfile profile = pointProfile();
|
||||||
profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK");
|
profile.addCapability("TWO_STAGE_ATOMIC_TASK");
|
||||||
profile.setProtocolCorpusHash("sha256:ft546-two-stage-v1");
|
profile.setProtocolCorpusHash("sha256:ft546-two-stage-v1");
|
||||||
profile.getRegisters().put("secondTaskNo", "4400");
|
profile.putRegister("secondTaskNo", "4400");
|
||||||
profile.getRegisters().put("secondTaskType", "4402");
|
profile.putRegister("secondTaskType", "4402");
|
||||||
profile.getRegisters().put("secondPath", "4404");
|
profile.putRegister("secondPath", "4404");
|
||||||
profile.getRegisters().put("secondStageFlag", "4422");
|
profile.putRegister("secondStageFlag", "4422");
|
||||||
profile.getRegisters().put("secondComplete", "4423");
|
profile.putRegister("secondComplete", "4423");
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.ruoyi.project.ft546.config;
|
package com.ruoyi.project.ft546.config;
|
||||||
|
|
||||||
|
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -31,7 +33,7 @@ class Ft546ConfigurationValidatorTest {
|
|||||||
@Test
|
@Test
|
||||||
void rejectsLegacyProfileThatClaimsPrepareCommit() {
|
void rejectsLegacyProfileThatClaimsPrepareCommit() {
|
||||||
Ft546DeviceProfile profile = legacySrgvProfile();
|
Ft546DeviceProfile profile = legacySrgvProfile();
|
||||||
profile.getCapabilities().add("PREPARE_COMMIT_SEGMENT");
|
profile.addCapability("PREPARE_COMMIT_SEGMENT");
|
||||||
|
|
||||||
assertThrows(IllegalArgumentException.class,
|
assertThrows(IllegalArgumentException.class,
|
||||||
() -> new Ft546ConfigurationValidator().validate(profile));
|
() -> new Ft546ConfigurationValidator().validate(profile));
|
||||||
@@ -40,7 +42,7 @@ class Ft546ConfigurationValidatorTest {
|
|||||||
@Test
|
@Test
|
||||||
void rejectsDuplicateRegisterOwnershipWithinOneDevice() {
|
void rejectsDuplicateRegisterOwnershipWithinOneDevice() {
|
||||||
Ft546DeviceProfile profile = legacySrgvProfile();
|
Ft546DeviceProfile profile = legacySrgvProfile();
|
||||||
profile.getRegisters().put("taskType", "4000");
|
profile.putRegister("taskType", "4000");
|
||||||
|
|
||||||
assertThrows(IllegalArgumentException.class,
|
assertThrows(IllegalArgumentException.class,
|
||||||
() -> new Ft546ConfigurationValidator().validate(profile));
|
() -> new Ft546ConfigurationValidator().validate(profile));
|
||||||
@@ -49,7 +51,7 @@ class Ft546ConfigurationValidatorTest {
|
|||||||
@Test
|
@Test
|
||||||
void rejectsTwoStageCapabilityWithoutPassingEvidenceForExactProfile() {
|
void rejectsTwoStageCapabilityWithoutPassingEvidenceForExactProfile() {
|
||||||
Ft546DeviceProfile profile = legacySrgvProfile();
|
Ft546DeviceProfile profile = legacySrgvProfile();
|
||||||
profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK");
|
profile.addCapability("TWO_STAGE_ATOMIC_TASK");
|
||||||
Ft546CapabilityGate gate = gate();
|
Ft546CapabilityGate gate = gate();
|
||||||
|
|
||||||
CapabilityGateResult result = gate.evaluate(profile, null);
|
CapabilityGateResult result = gate.evaluate(profile, null);
|
||||||
@@ -62,7 +64,7 @@ class Ft546ConfigurationValidatorTest {
|
|||||||
@Test
|
@Test
|
||||||
void enablesTwoStageOnlyForPassingUnexpiredEvidenceBoundToProfile() {
|
void enablesTwoStageOnlyForPassingUnexpiredEvidenceBoundToProfile() {
|
||||||
Ft546DeviceProfile profile = legacySrgvProfile();
|
Ft546DeviceProfile profile = legacySrgvProfile();
|
||||||
profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK");
|
profile.addCapability("TWO_STAGE_ATOMIC_TASK");
|
||||||
Ft546CapabilityEvidence evidence = new Ft546CapabilityEvidence(
|
Ft546CapabilityEvidence evidence = new Ft546CapabilityEvidence(
|
||||||
1L, "CORPUS-SHA256", "RUN-1", "COMMIT-1", true,
|
1L, "CORPUS-SHA256", "RUN-1", "COMMIT-1", true,
|
||||||
Instant.parse("2026-08-10T00:00:00Z"),
|
Instant.parse("2026-08-10T00:00:00Z"),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package com.ruoyi.project.ft546.device;
|
|||||||
import com.ruoyi.common.domain.PlcConnectInfo;
|
import com.ruoyi.common.domain.PlcConnectInfo;
|
||||||
import com.ruoyi.common.domain.CustomThread;
|
import com.ruoyi.common.domain.CustomThread;
|
||||||
import com.ruoyi.device.srgv.domain.DeviceSrgv;
|
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.project.ft546.device.shuttle.Ft546SrgvRuntimeFactory;
|
||||||
import com.ruoyi.schedule.ScheduleThread;
|
import com.ruoyi.schedule.ScheduleThread;
|
||||||
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
|
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import com.ruoyi.device.cdz.domain.DeviceCdz;
|
|||||||
import com.ruoyi.device.srgv.domain.DeviceSrgv;
|
import com.ruoyi.device.srgv.domain.DeviceSrgv;
|
||||||
import com.ruoyi.device.ssj.domain.DeviceSsj;
|
import com.ruoyi.device.ssj.domain.DeviceSsj;
|
||||||
import com.ruoyi.device.tsj.domain.DeviceTsj;
|
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.charger.Ft546ChargerAdapter;
|
||||||
import com.ruoyi.project.ft546.device.conveyor.Ft546ConveyorAdapter;
|
import com.ruoyi.project.ft546.device.conveyor.Ft546ConveyorAdapter;
|
||||||
import com.ruoyi.project.ft546.device.lift.Ft546LiftAdapter;
|
import com.ruoyi.project.ft546.device.lift.Ft546LiftAdapter;
|
||||||
@@ -220,8 +220,8 @@ class Ft546EquipmentAdaptersTest {
|
|||||||
profile.setDeviceType(deviceType);
|
profile.setDeviceType(deviceType);
|
||||||
profile.setAdapterType("FT546_LEGACY_" + deviceType);
|
profile.setAdapterType("FT546_LEGACY_" + deviceType);
|
||||||
profile.setProfileVersion(1L);
|
profile.setProfileVersion(1L);
|
||||||
profile.getCapabilities().add(capability);
|
profile.addCapability(capability);
|
||||||
profile.getRegisters().put("command", commandAddress);
|
profile.putRegister("command", commandAddress);
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import com.ruoyi.common.domain.PlcConnectInfo;
|
|||||||
import com.ruoyi.device.srgv.domain.DeviceSrgv;
|
import com.ruoyi.device.srgv.domain.DeviceSrgv;
|
||||||
import com.ruoyi.project.ft546.config.Ft546CapabilityEvidence;
|
import com.ruoyi.project.ft546.config.Ft546CapabilityEvidence;
|
||||||
import com.ruoyi.project.ft546.config.Ft546CapabilityGate;
|
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.Ft546DeviceRuntimeRegistry;
|
||||||
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
|
import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard;
|
||||||
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
|
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
|
||||||
@@ -108,8 +108,8 @@ class Ft546LegacySrgvAdapterTest {
|
|||||||
JsonNode fixture = fixture("srgv-point-to-point.json");
|
JsonNode fixture = fixture("srgv-point-to-point.json");
|
||||||
FakePlcSrgvRead runtime = connectedRuntime("1");
|
FakePlcSrgvRead runtime = connectedRuntime("1");
|
||||||
Ft546DeviceProfile profile = pointProfile();
|
Ft546DeviceProfile profile = pointProfile();
|
||||||
profile.getRegisters().put("crossFloorFlag", "4020");
|
profile.putRegister("crossFloorFlag", "4020");
|
||||||
profile.getRegisters().put("crossFloorTarget", "4022");
|
profile.putRegister("crossFloorTarget", "4022");
|
||||||
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
|
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
|
||||||
null, (deviceId, commandId) -> false);
|
null, (deviceId, commandId) -> false);
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ class Ft546LegacySrgvAdapterTest {
|
|||||||
Ft546DeviceProfile profile = pointProfile();
|
Ft546DeviceProfile profile = pointProfile();
|
||||||
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
|
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
|
||||||
null, (deviceId, commandId) -> false);
|
null, (deviceId, commandId) -> false);
|
||||||
profile.getRegisters().put("path", "9999");
|
profile.putRegister("path", "9999");
|
||||||
|
|
||||||
SendReceipt receipt = adapter.send(envelope("CMD-1",
|
SendReceipt receipt = adapter.send(envelope("CMD-1",
|
||||||
fixture.get("payload").asText()));
|
fixture.get("payload").asText()));
|
||||||
@@ -223,7 +223,7 @@ class Ft546LegacySrgvAdapterTest {
|
|||||||
throws Exception {
|
throws Exception {
|
||||||
JsonNode fixture = fixture("srgv-two-stage-gate.json");
|
JsonNode fixture = fixture("srgv-two-stage-gate.json");
|
||||||
Ft546DeviceProfile profile = twoStageProfile();
|
Ft546DeviceProfile profile = twoStageProfile();
|
||||||
profile.getCapabilities().remove("POINT_TO_POINT_COMMAND");
|
profile.removeCapability("POINT_TO_POINT_COMMAND");
|
||||||
FakePlcSrgvRead runtime = connectedRuntime("1");
|
FakePlcSrgvRead runtime = connectedRuntime("1");
|
||||||
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
|
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
|
||||||
passingEvidence(profile),
|
passingEvidence(profile),
|
||||||
@@ -295,19 +295,19 @@ class Ft546LegacySrgvAdapterTest {
|
|||||||
|
|
||||||
private static Ft546DeviceProfile pointProfile() {
|
private static Ft546DeviceProfile pointProfile() {
|
||||||
Ft546DeviceProfile profile = baseProfile();
|
Ft546DeviceProfile profile = baseProfile();
|
||||||
profile.getCapabilities().add("POINT_TO_POINT_COMMAND");
|
profile.addCapability("POINT_TO_POINT_COMMAND");
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Ft546DeviceProfile twoStageProfile() {
|
private static Ft546DeviceProfile twoStageProfile() {
|
||||||
Ft546DeviceProfile profile = pointProfile();
|
Ft546DeviceProfile profile = pointProfile();
|
||||||
profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK");
|
profile.addCapability("TWO_STAGE_ATOMIC_TASK");
|
||||||
profile.setProtocolCorpusHash("sha256:ft546-two-stage-v1");
|
profile.setProtocolCorpusHash("sha256:ft546-two-stage-v1");
|
||||||
profile.getRegisters().put("secondTaskNo", "4400");
|
profile.putRegister("secondTaskNo", "4400");
|
||||||
profile.getRegisters().put("secondTaskType", "4402");
|
profile.putRegister("secondTaskType", "4402");
|
||||||
profile.getRegisters().put("secondPath", "4404");
|
profile.putRegister("secondPath", "4404");
|
||||||
profile.getRegisters().put("secondStageFlag", "4422");
|
profile.putRegister("secondStageFlag", "4422");
|
||||||
profile.getRegisters().put("secondComplete", "4423");
|
profile.putRegister("secondComplete", "4423");
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,12 +317,12 @@ class Ft546LegacySrgvAdapterTest {
|
|||||||
profile.setDeviceType("SHUTTLE");
|
profile.setDeviceType("SHUTTLE");
|
||||||
profile.setAdapterType("FT546_LEGACY_SRGV");
|
profile.setAdapterType("FT546_LEGACY_SRGV");
|
||||||
profile.setProfileVersion(1L);
|
profile.setProfileVersion(1L);
|
||||||
profile.getRegisters().put("taskNo", "4000");
|
profile.putRegister("taskNo", "4000");
|
||||||
profile.getRegisters().put("taskType", "4002");
|
profile.putRegister("taskType", "4002");
|
||||||
profile.getRegisters().put("path", "4004");
|
profile.putRegister("path", "4004");
|
||||||
profile.getRegisters().put("trigger", "4021");
|
profile.putRegister("trigger", "4021");
|
||||||
profile.getRegisters().put("status", "4068");
|
profile.putRegister("status", "4068");
|
||||||
profile.getRegisters().put("execution", "4060");
|
profile.putRegister("execution", "4060");
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
23
ruoyi-ft546-protocol/pom.xml
Normal file
23
ruoyi-ft546-protocol/pom.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<parent>
|
||||||
|
<groupId>com.ruoyi</groupId>
|
||||||
|
<artifactId>ruoyi</artifactId>
|
||||||
|
<version>3.8.9</version>
|
||||||
|
</parent>
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<artifactId>ruoyi-ft546-protocol</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<description>Pure FT546 command protocol model and encoder</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
@@ -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.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -30,18 +31,42 @@ public class Ft546DeviceProfile {
|
|||||||
public void setPort(Integer port) { this.port = port; }
|
public void setPort(Integer port) { this.port = port; }
|
||||||
public Integer getStation() { return station; }
|
public Integer getStation() { return station; }
|
||||||
public void setStation(Integer station) { this.station = station; }
|
public void setStation(Integer station) { this.station = station; }
|
||||||
public Set<String> getCapabilities() { return capabilities; }
|
public Set<String> getCapabilities() {
|
||||||
|
return Collections.unmodifiableSet(capabilities);
|
||||||
|
}
|
||||||
public void setCapabilities(Set<String> capabilities) {
|
public void setCapabilities(Set<String> capabilities) {
|
||||||
this.capabilities = capabilities == null
|
this.capabilities = capabilities == null
|
||||||
? new LinkedHashSet<String>()
|
? new LinkedHashSet<String>()
|
||||||
: new LinkedHashSet<String>(capabilities);
|
: new LinkedHashSet<String>(capabilities);
|
||||||
}
|
}
|
||||||
public Map<String, String> 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<String, String> getRegisters() {
|
||||||
|
return Collections.unmodifiableMap(registers);
|
||||||
|
}
|
||||||
public void setRegisters(Map<String, String> registers) {
|
public void setRegisters(Map<String, String> registers) {
|
||||||
this.registers = registers == null
|
this.registers = registers == null
|
||||||
? new LinkedHashMap<String, String>()
|
? new LinkedHashMap<String, String>()
|
||||||
: new LinkedHashMap<String, String>(registers);
|
: new LinkedHashMap<String, String>(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 long getProfileVersion() { return profileVersion; }
|
||||||
public void setProfileVersion(long profileVersion) {
|
public void setProfileVersion(long profileVersion) {
|
||||||
this.profileVersion = profileVersion;
|
this.profileVersion = profileVersion;
|
||||||
@@ -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<Integer> pathPoints;
|
||||||
|
private final List<Ft546RegisterWrite> writes;
|
||||||
|
private final int triggerValue;
|
||||||
|
|
||||||
|
Ft546EncodedCommand(Ft546SrgvCommandIntent intent,
|
||||||
|
List<Ft546RegisterWrite> 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<Integer>(intent.getPathPoints()));
|
||||||
|
this.writes = Collections.unmodifiableList(
|
||||||
|
new ArrayList<Ft546RegisterWrite>(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<Integer> getPathPoints() { return pathPoints; }
|
||||||
|
public List<Ft546RegisterWrite> getWrites() { return writes; }
|
||||||
|
public int getTriggerValue() { return triggerValue; }
|
||||||
|
}
|
||||||
@@ -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<Integer> values;
|
||||||
|
private final int writeSequence;
|
||||||
|
private final boolean trigger;
|
||||||
|
|
||||||
|
Ft546RegisterWrite(String address, DataType dataType,
|
||||||
|
List<Integer> 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<Integer>(values));
|
||||||
|
this.writeSequence = writeSequence;
|
||||||
|
this.trigger = trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAddress() { return address; }
|
||||||
|
public DataType getDataType() { return dataType; }
|
||||||
|
public List<Integer> getValues() { return values; }
|
||||||
|
public int getWriteSequence() { return writeSequence; }
|
||||||
|
public boolean isTrigger() { return trigger; }
|
||||||
|
}
|
||||||
@@ -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<String> 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<Ft546RegisterWrite> writes =
|
||||||
|
new ArrayList<Ft546RegisterWrite>();
|
||||||
|
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<Ft546RegisterWrite> writes =
|
||||||
|
new ArrayList<Ft546RegisterWrite>();
|
||||||
|
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<Ft546RegisterWrite> writes =
|
||||||
|
new ArrayList<Ft546RegisterWrite>();
|
||||||
|
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<Ft546RegisterWrite> writes,
|
||||||
|
Ft546DeviceProfile profile, String register,
|
||||||
|
Ft546RegisterWrite.DataType dataType,
|
||||||
|
boolean trigger, int... values) {
|
||||||
|
List<Integer> boxed = new ArrayList<Integer>(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<Integer> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Integer> 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<Integer> 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<Integer>(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<Integer> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> registers = new LinkedHashMap<String, String>(
|
||||||
|
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.<String>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<Integer> mutablePoints = new ArrayList<Integer>(
|
||||||
|
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<Integer> route) {
|
||||||
|
return encoder.encode(intent((short) 1, route, null, null),
|
||||||
|
fullProfile(), capabilities("POINT_TO_POINT_COMMAND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertRouteRejected(List<Integer> 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<Integer> 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<String, String> registers =
|
||||||
|
new LinkedHashMap<String, String>();
|
||||||
|
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<String> capabilities(String... values) {
|
||||||
|
return new LinkedHashSet<String>(Arrays.asList(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Integer> 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<Integer> values;
|
||||||
|
|
||||||
|
private ExpectedWrite(int sequence, String address,
|
||||||
|
Ft546RegisterWrite.DataType dataType,
|
||||||
|
boolean trigger, List<Integer> values) {
|
||||||
|
this.sequence = sequence;
|
||||||
|
this.address = address;
|
||||||
|
this.dataType = dataType;
|
||||||
|
this.trigger = trigger;
|
||||||
|
this.values = values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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]}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user