feat(ft546): adapt legacy shuttle PLC protocol
This commit is contained in:
@@ -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<RecordedWrite> 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<Integer> values = new ArrayList<Integer>();
|
||||
expectedWrite.get("values").forEach(
|
||||
value -> values.add(value.asInt()));
|
||||
assertEquals(values, actualWrite.values);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakePlcSrgvRead extends PlcSrgvRead {
|
||||
private final List<RecordedWrite> writes =
|
||||
new ArrayList<RecordedWrite>();
|
||||
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<Integer> values = new ArrayList<Integer>();
|
||||
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<Integer> converted = new ArrayList<Integer>();
|
||||
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<Integer> values;
|
||||
|
||||
private RecordedWrite(String address, String kind,
|
||||
List<Integer> values) {
|
||||
this.address = address;
|
||||
this.kind = kind;
|
||||
this.values = Collections.unmodifiableList(
|
||||
new ArrayList<Integer>(values));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user