fix(ft546): enforce shuttle motion command gate

This commit is contained in:
zgw
2026-08-11 13:01:21 +08:00
parent de4d166b42
commit ff384cc019
3 changed files with 55 additions and 28 deletions

View File

@@ -22,7 +22,7 @@ public final class Ft546LegacySrgvAdapter
private final Ft546DeviceRuntimeRegistry registry; private final Ft546DeviceRuntimeRegistry registry;
private final Ft546DeviceProfile profile; private final Ft546DeviceProfile profile;
private final CapabilityGateResult capabilities; private final CapabilityGateResult capabilities;
private final UnknownCommandGuard unknownCommandGuard; private final MotionCommandGuard motionCommandGuard;
private final SnapshotContextProvider snapshotContextProvider; private final SnapshotContextProvider snapshotContextProvider;
private final Ft546SrgvCommandEncoder encoder = private final Ft546SrgvCommandEncoder encoder =
new Ft546SrgvCommandEncoder(); new Ft546SrgvCommandEncoder();
@@ -36,7 +36,7 @@ public final class Ft546LegacySrgvAdapter
Ft546DeviceProfile profile, Ft546DeviceProfile profile,
Ft546CapabilityEvidence evidence, Ft546CapabilityEvidence evidence,
Ft546CapabilityGate capabilityGate, Ft546CapabilityGate capabilityGate,
UnknownCommandGuard unknownCommandGuard, MotionCommandGuard motionCommandGuard,
SnapshotContextProvider contextProvider) { SnapshotContextProvider contextProvider) {
this.registry = Objects.requireNonNull(registry, "registry"); this.registry = Objects.requireNonNull(registry, "registry");
this.profile = copyProfile(Objects.requireNonNull(profile, this.profile = copyProfile(Objects.requireNonNull(profile,
@@ -46,12 +46,13 @@ public final class Ft546LegacySrgvAdapter
if (!capabilities.isPublishable()) { if (!capabilities.isPublishable()) {
throw new IllegalStateException(capabilities.getReasonCode()); throw new IllegalStateException(capabilities.getReasonCode());
} }
if (!capabilities.isEnabled("POINT_TO_POINT_COMMAND")) { if (!capabilities.isEnabled("POINT_TO_POINT_COMMAND")
&& !capabilities.isEnabled("TWO_STAGE_ATOMIC_TASK")) {
throw new IllegalStateException( throw new IllegalStateException(
"POINT_TO_POINT_COMMAND is required"); "a legacy shuttle movement capability is required");
} }
this.unknownCommandGuard = Objects.requireNonNull( this.motionCommandGuard = Objects.requireNonNull(
unknownCommandGuard, "unknownCommandGuard"); motionCommandGuard, "motionCommandGuard");
this.snapshotContextProvider = Objects.requireNonNull(contextProvider, this.snapshotContextProvider = Objects.requireNonNull(contextProvider,
"contextProvider"); "contextProvider");
} }
@@ -65,10 +66,11 @@ public final class Ft546LegacySrgvAdapter
"profile belongs to another device"); "profile belongs to another device");
} }
if (locallyUnknownDevices.contains(envelope.getDeviceId()) if (locallyUnknownDevices.contains(envelope.getDeviceId())
|| unknownCommandGuard.hasActiveUnknownCommand( || motionCommandGuard.hasBlockingMotionCommand(
envelope.getDeviceId())) { envelope.getDeviceId(), envelope.getCommandId())) {
return SendReceipt.rejected(receiptId, return SendReceipt.rejected(receiptId,
"device has an active UNKNOWN command"); "device has another active motion command or an "
+ "unresolved UNKNOWN command");
} }
final Ft546SrgvCommandEncoder.EncodedCommand command; final Ft546SrgvCommandEncoder.EncodedCommand command;
@@ -147,8 +149,8 @@ public final class Ft546LegacySrgvAdapter
} }
@FunctionalInterface @FunctionalInterface
public interface UnknownCommandGuard { public interface MotionCommandGuard {
boolean hasActiveUnknownCommand(String deviceId); boolean hasBlockingMotionCommand(String deviceId, String commandId);
} }
@FunctionalInterface @FunctionalInterface

View File

@@ -20,10 +20,6 @@ final class Ft546SrgvCommandEncoder {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"legacy shuttle supports MOVE_ROUTE only"); "legacy shuttle supports MOVE_ROUTE only");
} }
if (!capabilities.isEnabled("POINT_TO_POINT_COMMAND")) {
throw new IllegalStateException(
"POINT_TO_POINT_COMMAND is not enabled");
}
Map<String, String> values = parse(envelope.getPayload()); Map<String, String> values = parse(envelope.getPayload());
int taskNo = positiveInt(required(values, "taskNo"), "taskNo"); int taskNo = positiveInt(required(values, "taskNo"), "taskNo");
short taskType = shortValue(required(values, "taskType"), short taskType = shortValue(required(values, "taskType"),
@@ -31,6 +27,10 @@ final class Ft546SrgvCommandEncoder {
int[] route = route(required(values, "route")); int[] route = route(required(values, "route"));
String stageValue = values.get("stage"); String stageValue = values.get("stage");
if (stageValue == null) { 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); return main(profile, values, taskNo, taskType, route);
} }
if (!capabilities.isEnabled("TWO_STAGE_ATOMIC_TASK")) { if (!capabilities.isEnabled("TWO_STAGE_ATOMIC_TASK")) {

View File

@@ -37,7 +37,7 @@ class Ft546LegacySrgvAdapterTest {
JsonNode fixture = fixture("srgv-point-to-point.json"); JsonNode fixture = fixture("srgv-point-to-point.json");
FakePlcSrgvRead runtime = connectedRuntime("1"); FakePlcSrgvRead runtime = connectedRuntime("1");
Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(), Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(),
null, deviceId -> false); null, (deviceId, commandId) -> false);
SendReceipt receipt = adapter.send(envelope("CMD-1", SendReceipt receipt = adapter.send(envelope("CMD-1",
fixture.get("payload").asText())); fixture.get("payload").asText()));
@@ -54,7 +54,7 @@ class Ft546LegacySrgvAdapterTest {
FakePlcSrgvRead runtime = connectedRuntime("1"); FakePlcSrgvRead runtime = connectedRuntime("1");
runtime.throwAfterAddress = "4000"; runtime.throwAfterAddress = "4000";
Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(), Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(),
null, deviceId -> false); null, (deviceId, commandId) -> false);
SendReceipt first = adapter.send(envelope("CMD-1", SendReceipt first = adapter.send(envelope("CMD-1",
fixture.get("payload").asText())); fixture.get("payload").asText()));
@@ -72,7 +72,7 @@ class Ft546LegacySrgvAdapterTest {
void disconnectedRuntimeIsNotSentWithoutAnyPlcWrite() { void disconnectedRuntimeIsNotSentWithoutAnyPlcWrite() {
FakePlcSrgvRead runtime = runtime("1"); FakePlcSrgvRead runtime = runtime("1");
Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(), Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(),
null, deviceId -> false); null, (deviceId, commandId) -> false);
SendReceipt receipt = adapter.send(envelope("CMD-1", SendReceipt receipt = adapter.send(envelope("CMD-1",
"taskNo=30001|taskType=1|route=070501>070502")); "taskNo=30001|taskType=1|route=070501>070502"));
@@ -86,7 +86,7 @@ class Ft546LegacySrgvAdapterTest {
FakePlcSrgvRead runtime = connectedRuntime("1"); FakePlcSrgvRead runtime = connectedRuntime("1");
runtime.returnFalseAfterAddress = "4004"; runtime.returnFalseAfterAddress = "4004";
Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(), Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(),
null, deviceId -> false); null, (deviceId, commandId) -> false);
SendReceipt receipt = adapter.send(envelope("CMD-1", SendReceipt receipt = adapter.send(envelope("CMD-1",
"taskNo=30001|taskType=1|route=070501>070502")); "taskNo=30001|taskType=1|route=070501>070502"));
@@ -104,7 +104,7 @@ class Ft546LegacySrgvAdapterTest {
profile.getRegisters().put("crossFloorFlag", "4020"); profile.getRegisters().put("crossFloorFlag", "4020");
profile.getRegisters().put("crossFloorTarget", "4022"); profile.getRegisters().put("crossFloorTarget", "4022");
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile, Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
null, deviceId -> false); null, (deviceId, commandId) -> false);
SendReceipt receipt = adapter.send(envelope("CMD-XF", SendReceipt receipt = adapter.send(envelope("CMD-XF",
fixture.get("crossFloorPayload").asText())); fixture.get("crossFloorPayload").asText()));
@@ -119,7 +119,7 @@ class Ft546LegacySrgvAdapterTest {
FakePlcSrgvRead runtime = connectedRuntime("1"); FakePlcSrgvRead runtime = connectedRuntime("1");
Ft546DeviceProfile profile = pointProfile(); Ft546DeviceProfile profile = pointProfile();
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile, Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
null, deviceId -> false); null, (deviceId, commandId) -> false);
profile.getRegisters().put("path", "9999"); profile.getRegisters().put("path", "9999");
SendReceipt receipt = adapter.send(envelope("CMD-1", SendReceipt receipt = adapter.send(envelope("CMD-1",
@@ -130,15 +130,21 @@ class Ft546LegacySrgvAdapterTest {
} }
@Test @Test
void coreReportedUnknownCommandRejectsAParallelMove() { void coreReportedActiveCommandRejectsAParallelMove() {
FakePlcSrgvRead runtime = connectedRuntime("1"); FakePlcSrgvRead runtime = connectedRuntime("1");
List<String> checkedCommands = new ArrayList<String>();
Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(), Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(),
null, deviceId -> true); null, (deviceId, commandId) -> {
checkedCommands.add(deviceId + ":" + commandId);
return true;
});
SendReceipt receipt = adapter.send(envelope("CMD-1", SendReceipt receipt = adapter.send(envelope("CMD-1",
"taskNo=30001|taskType=1|route=070501>070502")); "taskNo=30001|taskType=1|route=070501>070502"));
assertEquals(SendReceipt.Status.REJECTED, receipt.getStatus()); assertEquals(SendReceipt.Status.REJECTED, receipt.getStatus());
assertEquals(Collections.singletonList("1:CMD-1"),
checkedCommands);
assertTrue(runtime.writes.isEmpty()); assertTrue(runtime.writes.isEmpty());
} }
@@ -147,7 +153,8 @@ class Ft546LegacySrgvAdapterTest {
FakePlcSrgvRead runtime = connectedRuntime("1"); FakePlcSrgvRead runtime = connectedRuntime("1");
assertThrows(IllegalStateException.class, () -> adapter(runtime, assertThrows(IllegalStateException.class, () -> adapter(runtime,
twoStageProfile(), null, deviceId -> false)); twoStageProfile(), null,
(deviceId, commandId) -> false));
assertTrue(runtime.writes.isEmpty()); assertTrue(runtime.writes.isEmpty());
} }
@@ -160,13 +167,13 @@ class Ft546LegacySrgvAdapterTest {
FakePlcSrgvRead stageOneRuntime = connectedRuntime("1"); FakePlcSrgvRead stageOneRuntime = connectedRuntime("1");
Ft546LegacySrgvAdapter stageOneAdapter = adapter(stageOneRuntime, Ft546LegacySrgvAdapter stageOneAdapter = adapter(stageOneRuntime,
profile, evidence, deviceId -> false); profile, evidence, (deviceId, commandId) -> false);
SendReceipt stageOne = stageOneAdapter.send(envelope("CMD-STAGE-1", SendReceipt stageOne = stageOneAdapter.send(envelope("CMD-STAGE-1",
fixture.get("stageOnePayload").asText())); fixture.get("stageOnePayload").asText()));
FakePlcSrgvRead stageTwoRuntime = connectedRuntime("1"); FakePlcSrgvRead stageTwoRuntime = connectedRuntime("1");
Ft546LegacySrgvAdapter stageTwoAdapter = adapter(stageTwoRuntime, Ft546LegacySrgvAdapter stageTwoAdapter = adapter(stageTwoRuntime,
profile, evidence, deviceId -> false); profile, evidence, (deviceId, commandId) -> false);
SendReceipt stageTwo = stageTwoAdapter.send(envelope("CMD-STAGE-2", SendReceipt stageTwo = stageTwoAdapter.send(envelope("CMD-STAGE-2",
fixture.get("stageTwoPayload").asText())); fixture.get("stageTwoPayload").asText()));
@@ -176,6 +183,24 @@ class Ft546LegacySrgvAdapterTest {
assertWrites(fixture.get("stageTwoWrites"), stageTwoRuntime.writes); assertWrites(fixture.get("stageTwoWrites"), stageTwoRuntime.writes);
} }
@Test
void evidenceGatedTwoStageProfileDoesNotRequirePointToPointCapability()
throws Exception {
JsonNode fixture = fixture("srgv-two-stage-gate.json");
Ft546DeviceProfile profile = twoStageProfile();
profile.getCapabilities().remove("POINT_TO_POINT_COMMAND");
FakePlcSrgvRead runtime = connectedRuntime("1");
Ft546LegacySrgvAdapter adapter = adapter(runtime, profile,
passingEvidence(profile),
(deviceId, commandId) -> false);
SendReceipt receipt = adapter.send(envelope("CMD-STAGE-ONLY",
fixture.get("stageOnePayload").asText()));
assertEquals(SendReceipt.Status.ACCEPTED, receipt.getStatus());
assertWrites(fixture.get("stageOneWrites"), runtime.writes);
}
@Test @Test
void mapsLegacyFactsWithoutFabricatingDeviceSequenceOrBootSession() { void mapsLegacyFactsWithoutFabricatingDeviceSequenceOrBootSession() {
FakePlcSrgvRead runtime = connectedRuntime("1"); FakePlcSrgvRead runtime = connectedRuntime("1");
@@ -216,12 +241,12 @@ class Ft546LegacySrgvAdapterTest {
private static Ft546LegacySrgvAdapter adapter( private static Ft546LegacySrgvAdapter adapter(
FakePlcSrgvRead runtime, Ft546DeviceProfile profile, FakePlcSrgvRead runtime, Ft546DeviceProfile profile,
Ft546CapabilityEvidence evidence, Ft546CapabilityEvidence evidence,
Ft546LegacySrgvAdapter.UnknownCommandGuard unknownGuard) { Ft546LegacySrgvAdapter.MotionCommandGuard motionGuard) {
Ft546DeviceRuntimeRegistry registry = Ft546DeviceRuntimeRegistry registry =
new Ft546DeviceRuntimeRegistry(); new Ft546DeviceRuntimeRegistry();
registry.register("1", runtime); registry.register("1", runtime);
return new Ft546LegacySrgvAdapter(registry, profile, evidence, return new Ft546LegacySrgvAdapter(registry, profile, evidence,
new Ft546CapabilityGate(() -> NOW), unknownGuard, new Ft546CapabilityGate(() -> NOW), motionGuard,
deviceId -> new Ft546SrgvSnapshotMapper.Context( deviceId -> new Ft546SrgvSnapshotMapper.Context(
"SNAP-" + deviceId, "KEY-" + deviceId, "SNAP-" + deviceId, "KEY-" + deviceId,
9L, 17L, NOW)); 9L, 17L, NOW));