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 new file mode 100644 index 0000000..6791b9b --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java @@ -0,0 +1,158 @@ +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.project.ft546.device.Ft546DeviceRuntimeRegistry; +import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead; +import com.ruoyi.wcs.application.device.DeviceCommandEnvelope; +import com.ruoyi.wcs.application.device.SendReceipt; +import com.ruoyi.wcs.domain.execution.DeviceSnapshot; +import com.ruoyi.wcs.port.out.DeviceCommandGateway; +import com.ruoyi.wcs.port.out.DeviceSnapshotGateway; + +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public final class Ft546LegacySrgvAdapter + implements DeviceCommandGateway, DeviceSnapshotGateway { + private final Ft546DeviceRuntimeRegistry registry; + private final Ft546DeviceProfile profile; + private final CapabilityGateResult capabilities; + private final UnknownCommandGuard unknownCommandGuard; + private final SnapshotContextProvider snapshotContextProvider; + private final Ft546SrgvCommandEncoder encoder = + new Ft546SrgvCommandEncoder(); + private final Ft546SrgvSnapshotMapper snapshotMapper = + new Ft546SrgvSnapshotMapper(); + private final Set locallyUnknownDevices = + Collections.newSetFromMap( + new ConcurrentHashMap()); + + public Ft546LegacySrgvAdapter(Ft546DeviceRuntimeRegistry registry, + Ft546DeviceProfile profile, + Ft546CapabilityEvidence evidence, + Ft546CapabilityGate capabilityGate, + UnknownCommandGuard unknownCommandGuard, + SnapshotContextProvider contextProvider) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.profile = copyProfile(Objects.requireNonNull(profile, + "profile")); + this.capabilities = Objects.requireNonNull(capabilityGate, + "capabilityGate").evaluate(this.profile, evidence); + if (!capabilities.isPublishable()) { + throw new IllegalStateException(capabilities.getReasonCode()); + } + if (!capabilities.isEnabled("POINT_TO_POINT_COMMAND")) { + throw new IllegalStateException( + "POINT_TO_POINT_COMMAND is required"); + } + this.unknownCommandGuard = Objects.requireNonNull( + unknownCommandGuard, "unknownCommandGuard"); + this.snapshotContextProvider = Objects.requireNonNull(contextProvider, + "contextProvider"); + } + + @Override + public SendReceipt send(DeviceCommandEnvelope envelope) { + Objects.requireNonNull(envelope, "envelope"); + String receiptId = receiptId(envelope); + if (!profile.getDeviceId().equals(envelope.getDeviceId())) { + return SendReceipt.rejected(receiptId, + "profile belongs to another device"); + } + if (locallyUnknownDevices.contains(envelope.getDeviceId()) + || unknownCommandGuard.hasActiveUnknownCommand( + envelope.getDeviceId())) { + return SendReceipt.rejected(receiptId, + "device has an active UNKNOWN command"); + } + + final Ft546SrgvCommandEncoder.EncodedCommand command; + try { + command = encoder.encode(envelope, profile, capabilities); + } catch (IllegalArgumentException | IllegalStateException rejected) { + return SendReceipt.rejected(receiptId, rejected.getMessage()); + } + + final PlcSrgvRead runtime; + try { + runtime = registry.requireSrgv(envelope.getDeviceId()); + } catch (IllegalArgumentException missingRuntime) { + return SendReceipt.notSent(missingRuntime.getMessage()); + } + if (!runtime.IsConnect()) { + return SendReceipt.notSent("shuttle PLC is disconnected"); + } + + boolean wroteAny = false; + for (Ft546SrgvCommandEncoder.PlcWrite write + : command.getWrites()) { + try { + if (!write.writeTo(runtime)) { + return markUnknown(envelope.getDeviceId(), receiptId, + wroteAny + ? "PLC rejected a partial register sequence" + : "first register write result is ambiguous"); + } + wroteAny = true; + } catch (RuntimeException ambiguous) { + return markUnknown(envelope.getDeviceId(), receiptId, + "register write result is ambiguous: " + + ambiguous.getClass().getSimpleName()); + } + } + return SendReceipt.accepted(receiptId); + } + + @Override + public DeviceSnapshot readSnapshot(String deviceId) { + if (!profile.getDeviceId().equals(deviceId)) { + throw new IllegalArgumentException( + "profile belongs to another device"); + } + PlcSrgvRead runtime = registry.requireSrgv(deviceId); + return snapshotMapper.map(runtime, + snapshotContextProvider.contextFor(deviceId)); + } + + private SendReceipt markUnknown(String deviceId, String receiptId, + String detail) { + locallyUnknownDevices.add(deviceId); + return SendReceipt.acceptedUnknown(receiptId, detail); + } + + private static String receiptId(DeviceCommandEnvelope envelope) { + return "FT546:" + envelope.getCommandId() + ":" + + envelope.getAttempt(); + } + + private static Ft546DeviceProfile copyProfile( + Ft546DeviceProfile source) { + Ft546DeviceProfile copy = new Ft546DeviceProfile(); + copy.setDeviceId(source.getDeviceId()); + copy.setDeviceType(source.getDeviceType()); + copy.setAdapterType(source.getAdapterType()); + copy.setIp(source.getIp()); + copy.setPort(source.getPort()); + copy.setStation(source.getStation()); + copy.setCapabilities(source.getCapabilities()); + copy.setRegisters(source.getRegisters()); + copy.setProfileVersion(source.getProfileVersion()); + copy.setProtocolCorpusHash(source.getProtocolCorpusHash()); + return copy; + } + + @FunctionalInterface + public interface UnknownCommandGuard { + boolean hasActiveUnknownCommand(String deviceId); + } + + @FunctionalInterface + public interface SnapshotContextProvider { + Ft546SrgvSnapshotMapper.Context contextFor(String deviceId); + } +} 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 new file mode 100644 index 0000000..e1597b5 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvCommandEncoder.java @@ -0,0 +1,209 @@ +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"); + } + if (!capabilities.isEnabled("POINT_TO_POINT_COMMAND")) { + throw new IllegalStateException( + "POINT_TO_POINT_COMMAND is not enabled"); + } + 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) { + 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/Ft546SrgvSnapshotMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvSnapshotMapper.java new file mode 100644 index 0000000..019f447 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvSnapshotMapper.java @@ -0,0 +1,99 @@ +package com.ruoyi.project.ft546.device.shuttle; + +import com.ruoyi.device.srgv.domain.DeviceSrgv; +import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead; +import com.ruoyi.wcs.domain.execution.DeviceSnapshot; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class Ft546SrgvSnapshotMapper { + public DeviceSnapshot map(PlcSrgvRead runtime, Context context) { + Objects.requireNonNull(runtime, "runtime"); + Objects.requireNonNull(context, "context"); + DeviceSrgv device = Objects.requireNonNull(runtime.getDeviceSrgv(), + "runtime device"); + String deviceId = String.valueOf(Objects.requireNonNull( + device.getDeviceId(), "deviceId")); + String taskNumber = value(device.getTaskNo()); + String currentSite = fact(device.getCurrSite()); + Map facts = new LinkedHashMap(); + facts.put("connection", runtime.IsConnect() ? "ONLINE" : "OFFLINE"); + facts.put("mode", value(device.getWorkMode())); + facts.put("status", value(device.getStatus())); + facts.put("battery", value(device.getBattery())); + facts.put("currentSite", currentSite); + facts.put("taskNumber", taskNumber); + facts.put("source", fact(device.getSrcSite())); + facts.put("target", fact(device.getTarSite())); + facts.put("floor", value(device.getCurrFloor())); + facts.put("load", value(device.getCargoFlag())); + facts.put("deviceSequenceSource", "UNAVAILABLE"); + facts.put("bootSessionSource", "UNAVAILABLE"); + return DeviceSnapshot.unorderedWithoutBootSession( + context.snapshotId, deviceId, context.commandKey, + taskNumber, context.observedAt, runtime.IsConnect(), + context.topologyVersion, context.fencingToken, currentSite, + remainingRoute(device, currentSite), false, facts); + } + + private static List remainingRoute(DeviceSrgv device, + String currentSite) { + if (device.getPath() == null || device.getPath().trim().isEmpty()) { + return Collections.singletonList(currentSite); + } + List nodes = new ArrayList(); + for (String node : device.getPath().split(",")) { + if (!node.trim().isEmpty()) { + nodes.add(node.trim()); + } + } + return nodes.isEmpty() ? Collections.singletonList(currentSite) + : nodes; + } + + private static String value(Object value) { + return value == null ? "UNKNOWN" : String.valueOf(value); + } + + private static String fact(String value) { + return value == null || value.trim().isEmpty() + ? "UNKNOWN" : value.trim(); + } + + public static final class Context { + private final String snapshotId; + private final String commandKey; + private final long topologyVersion; + private final long fencingToken; + private final Instant observedAt; + + public Context(String snapshotId, String commandKey, + long topologyVersion, long fencingToken, + Instant observedAt) { + this.snapshotId = requireText(snapshotId, "snapshotId"); + this.commandKey = requireText(commandKey, "commandKey"); + if (topologyVersion <= 0L || fencingToken <= 0L) { + throw new IllegalArgumentException( + "snapshot context versions are invalid"); + } + this.topologyVersion = topologyVersion; + this.fencingToken = fencingToken; + this.observedAt = Objects.requireNonNull(observedAt, + "observedAt"); + } + + 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-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 new file mode 100644 index 0000000..cda6e69 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java @@ -0,0 +1,369 @@ +package com.ruoyi.project.ft546.device.shuttle; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.project.ft546.device.Ft546DeviceRuntimeRegistry; +import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead; +import com.ruoyi.wcs.application.device.DeviceCommandEnvelope; +import com.ruoyi.wcs.application.device.SendReceipt; +import com.ruoyi.wcs.domain.execution.DeviceCommandType; +import com.ruoyi.wcs.domain.execution.DeviceSnapshot; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Ft546LegacySrgvAdapterTest { + private static final Instant NOW = Instant.parse("2026-08-11T04:00:00Z"); + + @Test + void pointToPointWritesMatchTheExistingAddressesValuesAndOrder() + throws Exception { + JsonNode fixture = fixture("srgv-point-to-point.json"); + FakePlcSrgvRead runtime = connectedRuntime("1"); + Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(), + null, deviceId -> false); + + SendReceipt receipt = adapter.send(envelope("CMD-1", + fixture.get("payload").asText())); + + assertEquals(SendReceipt.Status.ACCEPTED, receipt.getStatus()); + assertWrites(fixture.get("expectedWrites"), runtime.writes); + assertFalse(fixture.get("completionFromWriteSuccess").asBoolean()); + } + + @Test + void partialWriteAmbiguityReturnsAcceptedUnknownAndBlocksAnotherMove() + throws Exception { + JsonNode fixture = fixture("srgv-point-to-point.json"); + FakePlcSrgvRead runtime = connectedRuntime("1"); + runtime.throwAfterAddress = "4000"; + Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(), + null, deviceId -> false); + + SendReceipt first = adapter.send(envelope("CMD-1", + fixture.get("payload").asText())); + int writesAfterUnknown = runtime.writes.size(); + SendReceipt second = adapter.send(envelope("CMD-2", + fixture.get("payload").asText())); + + assertEquals(SendReceipt.Status.ACCEPTED_UNKNOWN, + first.getStatus()); + assertEquals(SendReceipt.Status.REJECTED, second.getStatus()); + assertEquals(writesAfterUnknown, runtime.writes.size()); + } + + @Test + void disconnectedRuntimeIsNotSentWithoutAnyPlcWrite() { + FakePlcSrgvRead runtime = runtime("1"); + Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(), + null, deviceId -> false); + + SendReceipt receipt = adapter.send(envelope("CMD-1", + "taskNo=30001|taskType=1|route=070501>070502")); + + assertEquals(SendReceipt.Status.NOT_SENT, receipt.getStatus()); + assertTrue(runtime.writes.isEmpty()); + } + + @Test + void failedWriteResultAfterAttemptIsUnknownNotSafeToRetry() { + FakePlcSrgvRead runtime = connectedRuntime("1"); + runtime.returnFalseAfterAddress = "4004"; + Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(), + null, deviceId -> false); + + SendReceipt receipt = adapter.send(envelope("CMD-1", + "taskNo=30001|taskType=1|route=070501>070502")); + + assertEquals(SendReceipt.Status.ACCEPTED_UNKNOWN, + receipt.getStatus()); + assertEquals(1, runtime.writes.size()); + } + + @Test + void crossFloorWritesKeepTheLegacyPostTriggerOrder() throws Exception { + 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"); + Ft546LegacySrgvAdapter adapter = adapter(runtime, profile, + null, deviceId -> false); + + SendReceipt receipt = adapter.send(envelope("CMD-XF", + fixture.get("crossFloorPayload").asText())); + + assertEquals(SendReceipt.Status.ACCEPTED, receipt.getStatus()); + assertWrites(fixture.get("crossFloorWrites"), runtime.writes); + } + + @Test + void activeAdapterUsesAnImmutableProfileSnapshot() throws Exception { + JsonNode fixture = fixture("srgv-point-to-point.json"); + FakePlcSrgvRead runtime = connectedRuntime("1"); + Ft546DeviceProfile profile = pointProfile(); + Ft546LegacySrgvAdapter adapter = adapter(runtime, profile, + null, deviceId -> false); + profile.getRegisters().put("path", "9999"); + + SendReceipt receipt = adapter.send(envelope("CMD-1", + fixture.get("payload").asText())); + + assertEquals(SendReceipt.Status.ACCEPTED, receipt.getStatus()); + assertEquals("4004", runtime.writes.get(0).address); + } + + @Test + void coreReportedUnknownCommandRejectsAParallelMove() { + FakePlcSrgvRead runtime = connectedRuntime("1"); + Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(), + null, deviceId -> true); + + SendReceipt receipt = adapter.send(envelope("CMD-1", + "taskNo=30001|taskType=1|route=070501>070502")); + + assertEquals(SendReceipt.Status.REJECTED, receipt.getStatus()); + assertTrue(runtime.writes.isEmpty()); + } + + @Test + void twoStageProfileIsRejectedUntilExactEvidencePasses() { + FakePlcSrgvRead runtime = connectedRuntime("1"); + + assertThrows(IllegalStateException.class, () -> adapter(runtime, + twoStageProfile(), null, deviceId -> false)); + assertTrue(runtime.writes.isEmpty()); + } + + @Test + void evidenceGatedTwoStageWritesMatchBothLegacyStages() + throws Exception { + JsonNode fixture = fixture("srgv-two-stage-gate.json"); + Ft546DeviceProfile profile = twoStageProfile(); + Ft546CapabilityEvidence evidence = passingEvidence(profile); + + FakePlcSrgvRead stageOneRuntime = connectedRuntime("1"); + Ft546LegacySrgvAdapter stageOneAdapter = adapter(stageOneRuntime, + profile, evidence, deviceId -> false); + SendReceipt stageOne = stageOneAdapter.send(envelope("CMD-STAGE-1", + fixture.get("stageOnePayload").asText())); + + FakePlcSrgvRead stageTwoRuntime = connectedRuntime("1"); + Ft546LegacySrgvAdapter stageTwoAdapter = adapter(stageTwoRuntime, + profile, evidence, deviceId -> false); + SendReceipt stageTwo = stageTwoAdapter.send(envelope("CMD-STAGE-2", + fixture.get("stageTwoPayload").asText())); + + assertEquals(SendReceipt.Status.ACCEPTED, stageOne.getStatus()); + assertEquals(SendReceipt.Status.ACCEPTED, stageTwo.getStatus()); + assertWrites(fixture.get("stageOneWrites"), stageOneRuntime.writes); + assertWrites(fixture.get("stageTwoWrites"), stageTwoRuntime.writes); + } + + @Test + void mapsLegacyFactsWithoutFabricatingDeviceSequenceOrBootSession() { + FakePlcSrgvRead runtime = connectedRuntime("1"); + DeviceSrgv device = runtime.getDeviceSrgv(); + device.setWorkMode(5); + device.setStatus(1); + device.setBattery(76); + device.setCurrSite("070502"); + device.setTaskNo(30001); + device.setSrcSite("070501"); + device.setTarSite("070502"); + device.setCurrFloor(7); + device.setCargoFlag(1); + device.setPath("070501,070502"); + Ft546SrgvSnapshotMapper.Context context = + new Ft546SrgvSnapshotMapper.Context("SNAP-1", "KEY-1", + 9L, 17L, NOW); + + DeviceSnapshot snapshot = new Ft546SrgvSnapshotMapper() + .map(runtime, context); + + assertEquals("5", snapshot.getFacts().get("mode")); + assertEquals("1", snapshot.getFacts().get("status")); + assertEquals("76", snapshot.getFacts().get("battery")); + assertEquals("070502", snapshot.getFacts().get("currentSite")); + assertEquals("30001", snapshot.getFacts().get("taskNumber")); + assertEquals("070501", snapshot.getFacts().get("source")); + assertEquals("070502", snapshot.getFacts().get("target")); + assertEquals("7", snapshot.getFacts().get("floor")); + assertEquals("1", snapshot.getFacts().get("load")); + assertFalse(snapshot.hasOrderedDeviceSequence()); + assertFalse(snapshot.hasDeviceBootSession()); + assertEquals(0L, snapshot.getDeviceSequence()); + assertNull(snapshot.getDeviceBootId()); + assertFalse(snapshot.isCommandCompleted()); + } + + private static Ft546LegacySrgvAdapter adapter( + FakePlcSrgvRead runtime, Ft546DeviceProfile profile, + Ft546CapabilityEvidence evidence, + Ft546LegacySrgvAdapter.UnknownCommandGuard unknownGuard) { + Ft546DeviceRuntimeRegistry registry = + new Ft546DeviceRuntimeRegistry(); + registry.register("1", runtime); + return new Ft546LegacySrgvAdapter(registry, profile, evidence, + new Ft546CapabilityGate(() -> NOW), unknownGuard, + deviceId -> new Ft546SrgvSnapshotMapper.Context( + "SNAP-" + deviceId, "KEY-" + deviceId, + 9L, 17L, NOW)); + } + + private static DeviceCommandEnvelope envelope(String commandId, + String payload) { + return new DeviceCommandEnvelope(commandId, "KEY-" + commandId, + "1", DeviceCommandType.MOVE_ROUTE, payload, 1, 9L, 17L); + } + + private static Ft546DeviceProfile pointProfile() { + Ft546DeviceProfile profile = baseProfile(); + profile.getCapabilities().add("POINT_TO_POINT_COMMAND"); + return profile; + } + + private static Ft546DeviceProfile twoStageProfile() { + Ft546DeviceProfile profile = pointProfile(); + profile.getCapabilities().add("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"); + return profile; + } + + private static Ft546DeviceProfile baseProfile() { + Ft546DeviceProfile profile = new Ft546DeviceProfile(); + profile.setDeviceId("1"); + 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"); + return profile; + } + + private static Ft546CapabilityEvidence passingEvidence( + Ft546DeviceProfile profile) { + return new Ft546CapabilityEvidence(profile.getProfileVersion(), + profile.getProtocolCorpusHash(), "RUN-1", "abc123", true, + NOW.minusSeconds(60), NOW.plusSeconds(3600)); + } + + private static FakePlcSrgvRead connectedRuntime(String deviceId) { + FakePlcSrgvRead runtime = runtime(deviceId); + runtime.setIsConnect(true); + return runtime; + } + + private static FakePlcSrgvRead runtime(String deviceId) { + DeviceSrgv device = new DeviceSrgv(); + device.setDeviceId(Integer.valueOf(deviceId)); + return new FakePlcSrgvRead(device); + } + + private static JsonNode fixture(String name) throws Exception { + try (InputStream stream = Ft546LegacySrgvAdapterTest.class + .getResourceAsStream("/ft546/plc/" + name)) { + if (stream == null) { + throw new IllegalStateException("missing fixture " + name); + } + return new ObjectMapper().readTree(stream); + } + } + + private static void assertWrites(JsonNode expected, + List actual) { + assertEquals(expected.size(), actual.size()); + for (int index = 0; index < expected.size(); index++) { + JsonNode expectedWrite = expected.get(index); + RecordedWrite actualWrite = actual.get(index); + assertEquals(expectedWrite.get("address").asText(), + actualWrite.address); + assertEquals(expectedWrite.get("kind").asText(), + actualWrite.kind); + List values = new ArrayList(); + expectedWrite.get("values").forEach( + value -> values.add(value.asInt())); + assertEquals(values, actualWrite.values); + } + } + + private static final class FakePlcSrgvRead extends PlcSrgvRead { + private final List writes = + new ArrayList(); + private String throwAfterAddress; + private String returnFalseAfterAddress; + + private FakePlcSrgvRead(DeviceSrgv device) { + super(device, new PlcConnectInfo("127.0.0.1", 502)); + } + + @Override + public synchronized boolean Write(String address, int[] value) { + record(address, "INT32", value); + maybeThrow(address); + return !address.equals(returnFalseAfterAddress); + } + + @Override + public synchronized boolean Write(String address, short[] value) { + List values = new ArrayList(); + for (short item : value) { + values.add((int) item); + } + writes.add(new RecordedWrite(address, "INT16", values)); + maybeThrow(address); + return !address.equals(returnFalseAfterAddress); + } + + private void record(String address, String kind, int[] values) { + List converted = new ArrayList(); + Arrays.stream(values).forEach(converted::add); + writes.add(new RecordedWrite(address, kind, converted)); + } + + private void maybeThrow(String address) { + if (address.equals(throwAfterAddress)) { + throw new IllegalStateException("injected disconnect"); + } + } + } + + private static final class RecordedWrite { + private final String address; + private final String kind; + private final List values; + + private RecordedWrite(String address, String kind, + List values) { + this.address = address; + this.kind = kind; + this.values = Collections.unmodifiableList( + new ArrayList(values)); + } + } +} diff --git a/ruoyi-admin/src/test/resources/ft546/plc/srgv-point-to-point.json b/ruoyi-admin/src/test/resources/ft546/plc/srgv-point-to-point.json new file mode 100644 index 0000000..d52706a --- /dev/null +++ b/ruoyi-admin/src/test/resources/ft546/plc/srgv-point-to-point.json @@ -0,0 +1,19 @@ +{ + "payload": "orderId=ORDER-1|stepId=STEP-1|vehicleId=1|taskNo=30001|taskType=1|route=070501>070502", + "expectedWrites": [ + {"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]} + ], + "crossFloorPayload": "taskNo=30001|taskType=8|route=070501>070507|crossFloorTarget=7", + "crossFloorWrites": [ + {"address": "4004", "kind": "INT32", "values": [70501, 70507, 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": [7]} + ], + "completionFromWriteSuccess": false +} diff --git a/ruoyi-admin/src/test/resources/ft546/plc/srgv-two-stage-gate.json b/ruoyi-admin/src/test/resources/ft546/plc/srgv-two-stage-gate.json new file mode 100644 index 0000000..52f0282 --- /dev/null +++ b/ruoyi-admin/src/test/resources/ft546/plc/srgv-two-stage-gate.json @@ -0,0 +1,24 @@ +{ + "profileVersion": 1, + "manifestHash": "sha256:ft546-two-stage-v1", + "stageOnePayload": "taskNo=30002|taskType=1|stage=1|route=070501>070503", + "stageOneWrites": [ + {"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": [30002]} + ], + "stageTwoPayload": "taskNo=30002|taskType=9|stage=2|route=070503>070504", + "stageTwoWrites": [ + {"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": [30002]} + ], + "disconnectBoundaries": { + "beforeFirstWrite": "NOT_SENT", + "afterAnyWriteMayHaveReachedPlc": "ACCEPTED_UNKNOWN" + }, + "prepareCommitClaimed": false +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceSnapshot.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceSnapshot.java index 5d4e3c4..0034723 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceSnapshot.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/DeviceSnapshot.java @@ -17,6 +17,8 @@ public final class DeviceSnapshot { private final Instant observedAt; private final boolean connected; private final String deviceBootId; + private final boolean orderedDeviceSequence; + private final boolean deviceBootSession; private final long topologyVersion; private final long fencingToken; private final String currentNodeId; @@ -34,7 +36,8 @@ public final class DeviceSnapshot { this(snapshotId, deviceId, commandKey, taskNumber, deviceSequence, observedAt, connected, deviceBootId, topologyVersion, fencingToken, currentNodeId, remainingRouteNodes, - commandCompleted, Collections.emptyMap()); + commandCompleted, Collections.emptyMap(), + true, true); } public DeviceSnapshot(String snapshotId, String deviceId, String commandKey, @@ -44,17 +47,38 @@ public final class DeviceSnapshot { long fencingToken, String currentNodeId, List remainingRouteNodes, boolean commandCompleted, Map facts) { + this(snapshotId, deviceId, commandKey, taskNumber, deviceSequence, + observedAt, connected, deviceBootId, topologyVersion, + fencingToken, currentNodeId, remainingRouteNodes, + commandCompleted, facts, true, true); + } + + private DeviceSnapshot(String snapshotId, String deviceId, + String commandKey, String taskNumber, + long deviceSequence, Instant observedAt, + boolean connected, String deviceBootId, + long topologyVersion, long fencingToken, + String currentNodeId, + List remainingRouteNodes, + boolean commandCompleted, Map facts, + boolean orderedDeviceSequence, + boolean deviceBootSession) { this.snapshotId = requireText(snapshotId, "snapshotId"); this.deviceId = requireText(deviceId, "deviceId"); this.commandKey = requireText(commandKey, "commandKey"); this.taskNumber = requireText(taskNumber, "taskNumber"); - if (deviceSequence < 0L || topologyVersion <= 0L || fencingToken <= 0L) { + if ((orderedDeviceSequence && deviceSequence < 0L) + || (!orderedDeviceSequence && deviceSequence != 0L) + || topologyVersion <= 0L || fencingToken <= 0L) { throw new IllegalArgumentException("snapshot versions are invalid"); } this.deviceSequence = deviceSequence; + this.orderedDeviceSequence = orderedDeviceSequence; this.observedAt = Objects.requireNonNull(observedAt, "observedAt"); this.connected = connected; - this.deviceBootId = requireText(deviceBootId, "deviceBootId"); + this.deviceBootSession = deviceBootSession; + this.deviceBootId = deviceBootSession + ? requireText(deviceBootId, "deviceBootId") : null; this.topologyVersion = topologyVersion; this.fencingToken = fencingToken; this.currentNodeId = requireText(currentNodeId, "currentNodeId"); @@ -63,6 +87,18 @@ public final class DeviceSnapshot { this.facts = immutableFacts(facts); } + public static DeviceSnapshot unorderedWithoutBootSession( + String snapshotId, String deviceId, String commandKey, + String taskNumber, Instant observedAt, boolean connected, + long topologyVersion, long fencingToken, String currentNodeId, + List remainingRouteNodes, boolean commandCompleted, + Map facts) { + return new DeviceSnapshot(snapshotId, deviceId, commandKey, + taskNumber, 0L, observedAt, connected, null, + topologyVersion, fencingToken, currentNodeId, + remainingRouteNodes, commandCompleted, facts, false, false); + } + public String getSnapshotId() { return snapshotId; } public String getDeviceId() { return deviceId; } public String getCommandKey() { return commandKey; } @@ -71,6 +107,10 @@ public final class DeviceSnapshot { public Instant getObservedAt() { return observedAt; } public boolean isConnected() { return connected; } public String getDeviceBootId() { return deviceBootId; } + public boolean hasOrderedDeviceSequence() { + return orderedDeviceSequence; + } + public boolean hasDeviceBootSession() { return deviceBootSession; } public long getTopologyVersion() { return topologyVersion; } public long getFencingToken() { return fencingToken; } public String getCurrentNodeId() { return currentNodeId; }