refactor(ft546): share pure PLC command encoding

This commit is contained in:
zgw
2026-08-12 13:27:30 +08:00
parent d30fa0bd1c
commit 778444e5ea
28 changed files with 1021 additions and 265 deletions

View File

@@ -60,6 +60,12 @@
<artifactId>ruoyi-wcs</artifactId>
</dependency>
<!-- FT546 纯协议编码 -->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-ft546-protocol</artifactId>
</dependency>
<!-- 定时任务-->
<dependency>
<groupId>com.ruoyi</groupId>

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,53 +0,0 @@
package com.ruoyi.project.ft546.config;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
public class Ft546DeviceProfile {
private String deviceId;
private String deviceType;
private String adapterType;
private String ip;
private Integer port;
private Integer station;
private Set<String> capabilities = new LinkedHashSet<String>();
private Map<String, String> registers =
new LinkedHashMap<String, String>();
private long profileVersion;
private String protocolCorpusHash;
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
public String getDeviceType() { return deviceType; }
public void setDeviceType(String deviceType) { this.deviceType = deviceType; }
public String getAdapterType() { return adapterType; }
public void setAdapterType(String adapterType) { this.adapterType = adapterType; }
public String getIp() { return ip; }
public void setIp(String ip) { this.ip = ip; }
public Integer getPort() { return port; }
public void setPort(Integer port) { this.port = port; }
public Integer getStation() { return station; }
public void setStation(Integer station) { this.station = station; }
public Set<String> getCapabilities() { return capabilities; }
public void setCapabilities(Set<String> capabilities) {
this.capabilities = capabilities == null
? new LinkedHashSet<String>()
: new LinkedHashSet<String>(capabilities);
}
public Map<String, String> getRegisters() { return registers; }
public void setRegisters(Map<String, String> registers) {
this.registers = registers == null
? new LinkedHashMap<String, String>()
: new LinkedHashMap<String, String>(registers);
}
public long getProfileVersion() { return profileVersion; }
public void setProfileVersion(long profileVersion) {
this.profileVersion = profileVersion;
}
public String getProtocolCorpusHash() { return protocolCorpusHash; }
public void setProtocolCorpusHash(String protocolCorpusHash) {
this.protocolCorpusHash = protocolCorpusHash;
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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<String> 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"

View File

@@ -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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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"),

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}