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