feat(ft546): adapt legacy shuttle PLC protocol
This commit is contained in:
@@ -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<String> locallyUnknownDevices =
|
||||||
|
Collections.newSetFromMap(
|
||||||
|
new ConcurrentHashMap<String, Boolean>());
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> values = parse(envelope.getPayload());
|
||||||
|
int taskNo = positiveInt(required(values, "taskNo"), "taskNo");
|
||||||
|
short taskType = shortValue(required(values, "taskType"),
|
||||||
|
"taskType");
|
||||||
|
int[] route = route(required(values, "route"));
|
||||||
|
String stageValue = values.get("stage");
|
||||||
|
if (stageValue == null) {
|
||||||
|
return main(profile, values, taskNo, taskType, route);
|
||||||
|
}
|
||||||
|
if (!capabilities.isEnabled("TWO_STAGE_ATOMIC_TASK")) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"TWO_STAGE_ATOMIC_TASK is not enabled");
|
||||||
|
}
|
||||||
|
int stage = positiveInt(stageValue, "stage");
|
||||||
|
if (stage == 1) {
|
||||||
|
return secondStageOne(profile, taskNo, taskType, route);
|
||||||
|
}
|
||||||
|
if (stage == 2) {
|
||||||
|
return secondStageTwo(profile, taskNo, route);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("stage must be 1 or 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EncodedCommand main(Ft546DeviceProfile profile,
|
||||||
|
Map<String, String> values,
|
||||||
|
int taskNo, short taskType,
|
||||||
|
int[] route) {
|
||||||
|
List<PlcWrite> writes = new ArrayList<PlcWrite>();
|
||||||
|
writes.add(PlcWrite.int32(register(profile, "path"), route));
|
||||||
|
writes.add(PlcWrite.int32(register(profile, "taskNo"), taskNo));
|
||||||
|
writes.add(PlcWrite.int16(register(profile, "taskType"), taskType));
|
||||||
|
writes.add(PlcWrite.int16(register(profile, "trigger"), (short) 1));
|
||||||
|
if (taskType == 8) {
|
||||||
|
short targetFloor = shortValue(required(values,
|
||||||
|
"crossFloorTarget"), "crossFloorTarget");
|
||||||
|
writes.add(PlcWrite.int16(register(profile, "crossFloorFlag"),
|
||||||
|
(short) 1));
|
||||||
|
writes.add(PlcWrite.int16(register(profile,
|
||||||
|
"crossFloorTarget"), targetFloor));
|
||||||
|
}
|
||||||
|
return new EncodedCommand(writes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EncodedCommand secondStageOne(Ft546DeviceProfile profile,
|
||||||
|
int taskNo,
|
||||||
|
short taskType,
|
||||||
|
int[] route) {
|
||||||
|
List<PlcWrite> writes = new ArrayList<PlcWrite>();
|
||||||
|
writes.add(PlcWrite.int32(register(profile, "secondPath"), route));
|
||||||
|
writes.add(PlcWrite.int16(register(profile, "secondStageFlag"),
|
||||||
|
(short) 1));
|
||||||
|
writes.add(PlcWrite.int16(register(profile, "secondTaskType"),
|
||||||
|
taskType));
|
||||||
|
writes.add(PlcWrite.int32(register(profile, "secondTaskNo"), taskNo));
|
||||||
|
return new EncodedCommand(writes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EncodedCommand secondStageTwo(Ft546DeviceProfile profile,
|
||||||
|
int taskNo, int[] route) {
|
||||||
|
List<PlcWrite> writes = new ArrayList<PlcWrite>();
|
||||||
|
writes.add(PlcWrite.int32(register(profile, "secondPath"), route));
|
||||||
|
writes.add(PlcWrite.int16(register(profile, "secondStageFlag"),
|
||||||
|
(short) 2));
|
||||||
|
writes.add(PlcWrite.int16(register(profile, "secondComplete"),
|
||||||
|
(short) 99));
|
||||||
|
writes.add(PlcWrite.int16(register(profile, "secondTaskType"),
|
||||||
|
(short) 9));
|
||||||
|
writes.add(PlcWrite.int32(register(profile, "secondTaskNo"), taskNo));
|
||||||
|
return new EncodedCommand(writes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> parse(String payload) {
|
||||||
|
Map<String, String> values = new LinkedHashMap<String, String>();
|
||||||
|
for (String segment : payload.split("\\|")) {
|
||||||
|
int separator = segment.indexOf('=');
|
||||||
|
if (separator <= 0 || separator == segment.length() - 1) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"invalid command payload segment: " + segment);
|
||||||
|
}
|
||||||
|
String key = segment.substring(0, separator).trim();
|
||||||
|
String value = segment.substring(separator + 1).trim();
|
||||||
|
if (values.put(key, value) != null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"duplicate command payload field: " + key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] route(String value) {
|
||||||
|
String[] nodes = value.split(">");
|
||||||
|
if (nodes.length < 2 || nodes.length > 8) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"legacy route must contain between 2 and 8 command points");
|
||||||
|
}
|
||||||
|
int[] result = new int[8];
|
||||||
|
for (int index = 0; index < nodes.length; index++) {
|
||||||
|
int node = positiveInt(nodes[index], "route node");
|
||||||
|
if (node > 999999) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"route node exceeds six digits");
|
||||||
|
}
|
||||||
|
result[index] = node;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int positiveInt(String value, String field) {
|
||||||
|
try {
|
||||||
|
int parsed = Integer.parseInt(value);
|
||||||
|
if (parsed <= 0) {
|
||||||
|
throw new IllegalArgumentException(field + " must be positive");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (NumberFormatException invalid) {
|
||||||
|
throw new IllegalArgumentException(field + " is not an integer",
|
||||||
|
invalid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short shortValue(String value, String field) {
|
||||||
|
try {
|
||||||
|
return Short.parseShort(value);
|
||||||
|
} catch (NumberFormatException invalid) {
|
||||||
|
throw new IllegalArgumentException(field + " is not an int16",
|
||||||
|
invalid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(Map<String, String> values, String field) {
|
||||||
|
String value = values.get(field);
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(field + " is required");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String register(Ft546DeviceProfile profile, String name) {
|
||||||
|
String address = profile.getRegisters().get(name);
|
||||||
|
if (address == null || address.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"profile register is missing: " + name);
|
||||||
|
}
|
||||||
|
return address.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class EncodedCommand {
|
||||||
|
private final List<PlcWrite> writes;
|
||||||
|
|
||||||
|
private EncodedCommand(List<PlcWrite> writes) {
|
||||||
|
this.writes = Collections.unmodifiableList(
|
||||||
|
new ArrayList<PlcWrite>(writes));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PlcWrite> getWrites() { return writes; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class PlcWrite {
|
||||||
|
private final String address;
|
||||||
|
private final int[] int32;
|
||||||
|
private final short[] int16;
|
||||||
|
|
||||||
|
private PlcWrite(String address, int[] int32, short[] int16) {
|
||||||
|
this.address = address;
|
||||||
|
this.int32 = int32;
|
||||||
|
this.int16 = int16;
|
||||||
|
}
|
||||||
|
|
||||||
|
static PlcWrite int32(String address, int... values) {
|
||||||
|
return new PlcWrite(address, values.clone(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static PlcWrite int16(String address, short... values) {
|
||||||
|
return new PlcWrite(address, null, values.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean writeTo(PlcSrgvRead runtime) {
|
||||||
|
return int32 != null
|
||||||
|
? runtime.Write(address, int32.clone())
|
||||||
|
: runtime.Write(address, int16.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> facts = new LinkedHashMap<String, String>();
|
||||||
|
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<String> remainingRoute(DeviceSrgv device,
|
||||||
|
String currentSite) {
|
||||||
|
if (device.getPath() == null || device.getPath().trim().isEmpty()) {
|
||||||
|
return Collections.singletonList(currentSite);
|
||||||
|
}
|
||||||
|
List<String> nodes = new ArrayList<String>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ public final class DeviceSnapshot {
|
|||||||
private final Instant observedAt;
|
private final Instant observedAt;
|
||||||
private final boolean connected;
|
private final boolean connected;
|
||||||
private final String deviceBootId;
|
private final String deviceBootId;
|
||||||
|
private final boolean orderedDeviceSequence;
|
||||||
|
private final boolean deviceBootSession;
|
||||||
private final long topologyVersion;
|
private final long topologyVersion;
|
||||||
private final long fencingToken;
|
private final long fencingToken;
|
||||||
private final String currentNodeId;
|
private final String currentNodeId;
|
||||||
@@ -34,7 +36,8 @@ public final class DeviceSnapshot {
|
|||||||
this(snapshotId, deviceId, commandKey, taskNumber, deviceSequence,
|
this(snapshotId, deviceId, commandKey, taskNumber, deviceSequence,
|
||||||
observedAt, connected, deviceBootId, topologyVersion,
|
observedAt, connected, deviceBootId, topologyVersion,
|
||||||
fencingToken, currentNodeId, remainingRouteNodes,
|
fencingToken, currentNodeId, remainingRouteNodes,
|
||||||
commandCompleted, Collections.<String, String>emptyMap());
|
commandCompleted, Collections.<String, String>emptyMap(),
|
||||||
|
true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public DeviceSnapshot(String snapshotId, String deviceId, String commandKey,
|
public DeviceSnapshot(String snapshotId, String deviceId, String commandKey,
|
||||||
@@ -44,17 +47,38 @@ public final class DeviceSnapshot {
|
|||||||
long fencingToken, String currentNodeId,
|
long fencingToken, String currentNodeId,
|
||||||
List<String> remainingRouteNodes,
|
List<String> remainingRouteNodes,
|
||||||
boolean commandCompleted, Map<String, String> facts) {
|
boolean commandCompleted, Map<String, String> 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<String> remainingRouteNodes,
|
||||||
|
boolean commandCompleted, Map<String, String> facts,
|
||||||
|
boolean orderedDeviceSequence,
|
||||||
|
boolean deviceBootSession) {
|
||||||
this.snapshotId = requireText(snapshotId, "snapshotId");
|
this.snapshotId = requireText(snapshotId, "snapshotId");
|
||||||
this.deviceId = requireText(deviceId, "deviceId");
|
this.deviceId = requireText(deviceId, "deviceId");
|
||||||
this.commandKey = requireText(commandKey, "commandKey");
|
this.commandKey = requireText(commandKey, "commandKey");
|
||||||
this.taskNumber = requireText(taskNumber, "taskNumber");
|
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");
|
throw new IllegalArgumentException("snapshot versions are invalid");
|
||||||
}
|
}
|
||||||
this.deviceSequence = deviceSequence;
|
this.deviceSequence = deviceSequence;
|
||||||
|
this.orderedDeviceSequence = orderedDeviceSequence;
|
||||||
this.observedAt = Objects.requireNonNull(observedAt, "observedAt");
|
this.observedAt = Objects.requireNonNull(observedAt, "observedAt");
|
||||||
this.connected = connected;
|
this.connected = connected;
|
||||||
this.deviceBootId = requireText(deviceBootId, "deviceBootId");
|
this.deviceBootSession = deviceBootSession;
|
||||||
|
this.deviceBootId = deviceBootSession
|
||||||
|
? requireText(deviceBootId, "deviceBootId") : null;
|
||||||
this.topologyVersion = topologyVersion;
|
this.topologyVersion = topologyVersion;
|
||||||
this.fencingToken = fencingToken;
|
this.fencingToken = fencingToken;
|
||||||
this.currentNodeId = requireText(currentNodeId, "currentNodeId");
|
this.currentNodeId = requireText(currentNodeId, "currentNodeId");
|
||||||
@@ -63,6 +87,18 @@ public final class DeviceSnapshot {
|
|||||||
this.facts = immutableFacts(facts);
|
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<String> remainingRouteNodes, boolean commandCompleted,
|
||||||
|
Map<String, String> 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 getSnapshotId() { return snapshotId; }
|
||||||
public String getDeviceId() { return deviceId; }
|
public String getDeviceId() { return deviceId; }
|
||||||
public String getCommandKey() { return commandKey; }
|
public String getCommandKey() { return commandKey; }
|
||||||
@@ -71,6 +107,10 @@ public final class DeviceSnapshot {
|
|||||||
public Instant getObservedAt() { return observedAt; }
|
public Instant getObservedAt() { return observedAt; }
|
||||||
public boolean isConnected() { return connected; }
|
public boolean isConnected() { return connected; }
|
||||||
public String getDeviceBootId() { return deviceBootId; }
|
public String getDeviceBootId() { return deviceBootId; }
|
||||||
|
public boolean hasOrderedDeviceSequence() {
|
||||||
|
return orderedDeviceSequence;
|
||||||
|
}
|
||||||
|
public boolean hasDeviceBootSession() { return deviceBootSession; }
|
||||||
public long getTopologyVersion() { return topologyVersion; }
|
public long getTopologyVersion() { return topologyVersion; }
|
||||||
public long getFencingToken() { return fencingToken; }
|
public long getFencingToken() { return fencingToken; }
|
||||||
public String getCurrentNodeId() { return currentNodeId; }
|
public String getCurrentNodeId() { return currentNodeId; }
|
||||||
|
|||||||
Reference in New Issue
Block a user