From 14a5a6174eb689ed13f9106178135c18a9f04fff Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Tue, 11 Aug 2026 13:23:53 +0800 Subject: [PATCH] feat(ft546): adapt lift conveyor and charger protocols --- .../device/AbstractFt546EquipmentAdapter.java | 164 ++++++++++ .../ft546/device/Ft546LegacyPayloadCodec.java | 98 ++++++ .../ft546/device/Ft546MotionCommandGuard.java | 6 + .../ft546/device/Ft546SnapshotContext.java | 39 +++ .../device/Ft546SnapshotContextProvider.java | 6 + .../device/charger/Ft546ChargerAdapter.java | 70 ++++ .../device/conveyor/Ft546ConveyorAdapter.java | 74 +++++ .../ft546/device/lift/Ft546LiftAdapter.java | 86 +++++ .../shuttle/Ft546LegacySrgvAdapter.java | 10 +- .../schedule/plcRead/cdz/PlcCdzRead.java | 23 +- .../schedule/plcRead/srgv/PlcSrgvRead.java | 9 +- .../schedule/plcRead/ssj/PlcSsjRead.java | 23 +- .../schedule/plcRead/tsj/PlcTsjRead.java | 23 +- .../device/Ft546EquipmentAdaptersTest.java | 309 ++++++++++++++++++ .../shuttle/Ft546LegacySrgvAdapterTest.java | 3 +- 15 files changed, 907 insertions(+), 36 deletions(-) create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/AbstractFt546EquipmentAdapter.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546LegacyPayloadCodec.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546MotionCommandGuard.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546SnapshotContext.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546SnapshotContextProvider.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/charger/Ft546ChargerAdapter.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/conveyor/Ft546ConveyorAdapter.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/lift/Ft546LiftAdapter.java create mode 100644 ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546EquipmentAdaptersTest.java diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/AbstractFt546EquipmentAdapter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/AbstractFt546EquipmentAdapter.java new file mode 100644 index 0000000..7ecea7f --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/AbstractFt546EquipmentAdapter.java @@ -0,0 +1,164 @@ +package com.ruoyi.project.ft546.device; + +import com.ruoyi.project.ft546.config.Ft546ConfigurationValidator; +import com.ruoyi.project.ft546.config.Ft546DeviceProfile; +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 com.ruoyi.wcs.port.out.DeviceCommandGateway; +import com.ruoyi.wcs.port.out.DeviceSnapshotGateway; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public abstract class AbstractFt546EquipmentAdapter + implements DeviceCommandGateway, DeviceSnapshotGateway { + private final Ft546DeviceProfile profile; + private final DeviceCommandType commandType; + private final Ft546MotionCommandGuard motionCommandGuard; + private final Ft546SnapshotContextProvider contextProvider; + private final Set locallyUnknownDevices = + Collections.newSetFromMap( + new ConcurrentHashMap()); + + protected AbstractFt546EquipmentAdapter( + Ft546DeviceProfile sourceProfile, String expectedDeviceType, + String requiredCapability, DeviceCommandType commandType, + Ft546MotionCommandGuard motionCommandGuard, + Ft546SnapshotContextProvider contextProvider) { + this.profile = copy(Objects.requireNonNull(sourceProfile, + "profile")); + new Ft546ConfigurationValidator().validate(profile); + if (!expectedDeviceType.equals(profile.getDeviceType())) { + throw new IllegalArgumentException( + "profile device type must be " + expectedDeviceType); + } + if (!profile.getCapabilities().contains(requiredCapability)) { + throw new IllegalStateException( + requiredCapability + " is not enabled"); + } + commandAddress(); + this.commandType = Objects.requireNonNull(commandType, "commandType"); + this.motionCommandGuard = Objects.requireNonNull(motionCommandGuard, + "motionCommandGuard"); + this.contextProvider = Objects.requireNonNull(contextProvider, + "contextProvider"); + } + + @Override + public final 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 (envelope.getType() != commandType) { + return SendReceipt.rejected(receiptId, + "command type is not supported by this adapter"); + } + if (locallyUnknownDevices.contains(envelope.getDeviceId()) + || motionCommandGuard.hasBlockingMotionCommand( + envelope.getDeviceId(), envelope.getCommandId())) { + return SendReceipt.rejected(receiptId, + "device has another active command or unresolved UNKNOWN"); + } + final byte[] payload; + try { + payload = encode(envelope.getPayload()); + } catch (IllegalArgumentException rejected) { + return SendReceipt.rejected(receiptId, rejected.getMessage()); + } + if (!isConnected()) { + return SendReceipt.notSent("equipment PLC is disconnected"); + } + try { + int result = write(commandAddress(), payload); + if (result <= 0) { + return markUnknown(envelope.getDeviceId(), receiptId, + "equipment write result is ambiguous"); + } + return SendReceipt.accepted(receiptId); + } catch (RuntimeException ambiguous) { + return markUnknown(envelope.getDeviceId(), receiptId, + "equipment write raised " + + ambiguous.getClass().getSimpleName()); + } + } + + @Override + public final DeviceSnapshot readSnapshot(String deviceId) { + if (!profile.getDeviceId().equals(deviceId)) { + throw new IllegalArgumentException( + "profile belongs to another device"); + } + return snapshot(deviceId); + } + + protected abstract boolean isConnected(); + protected abstract int write(String address, byte[] payload); + protected abstract byte[] encode(String payload); + protected abstract DeviceSnapshot snapshot(String deviceId); + + protected final DeviceSnapshot unorderedSnapshot( + String deviceId, String taskNumber, String currentNode, + Map facts) { + Ft546SnapshotContext context = Objects.requireNonNull( + contextProvider.contextFor(deviceId), "snapshot context"); + return DeviceSnapshot.unorderedWithoutBootSession( + context.getSnapshotId(), deviceId, context.getCommandKey(), + fact(taskNumber), context.getObservedAt(), isConnected(), + context.getTopologyVersion(), context.getFencingToken(), + fact(currentNode), Collections.singletonList(fact(currentNode)), + false, new LinkedHashMap(facts)); + } + + protected final String commandAddress() { + String address = profile.getRegisters().get("command"); + if (address == null || address.trim().isEmpty()) { + throw new IllegalArgumentException( + "profile register is missing: command"); + } + return address.trim(); + } + + protected static String value(Object value) { + return value == null ? "UNKNOWN" : String.valueOf(value); + } + + protected static String fact(String value) { + return value == null || value.trim().isEmpty() + ? "UNKNOWN" : value.trim(); + } + + 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 copy(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; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546LegacyPayloadCodec.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546LegacyPayloadCodec.java new file mode 100644 index 0000000..59f9328 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546LegacyPayloadCodec.java @@ -0,0 +1,98 @@ +package com.ruoyi.project.ft546.device; + +import java.io.ByteArrayOutputStream; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class Ft546LegacyPayloadCodec { + private Ft546LegacyPayloadCodec() { } + + public static Map parse(String payload) { + if (payload == null || payload.trim().isEmpty()) { + throw new IllegalArgumentException("payload must not be blank"); + } + 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; + } + + public static int positiveInt(Map values, String field) { + int value = integer(values, field); + if (value <= 0) { + throw new IllegalArgumentException(field + " must be positive"); + } + return value; + } + + public static int binaryFlag(Map values, String field) { + int value = integer(values, field); + if (value != 0 && value != 1) { + throw new IllegalArgumentException(field + " must be 0 or 1"); + } + return value; + } + + public static int integer(Map values, String field) { + String value = values.get(field); + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(field + " is required"); + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException invalid) { + throw new IllegalArgumentException(field + " is not an integer", + invalid); + } + } + + public static byte[] fields(Field... fields) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + for (Field field : fields) { + for (int shift = (field.width - 1) * 8; shift >= 0; shift -= 8) { + output.write((field.value >>> shift) & 0xff); + } + } + return output.toByteArray(); + } + + public static int checksum(int... values) { + int sum = 0; + for (int value : values) { + sum += (value >>> 24) & 0xff; + sum += (value >>> 16) & 0xff; + sum += (value >>> 8) & 0xff; + sum += value & 0xff; + } + return sum; + } + + public static Field int32(int value) { return new Field(4, value); } + public static Field int16(int value) { + if (value < Short.MIN_VALUE || value > 0xffff) { + throw new IllegalArgumentException("int16 value is out of range"); + } + return new Field(2, value); + } + + public static final class Field { + private final int width; + private final int value; + + private Field(int width, int value) { + this.width = width; + this.value = value; + } + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546MotionCommandGuard.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546MotionCommandGuard.java new file mode 100644 index 0000000..8a345d5 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546MotionCommandGuard.java @@ -0,0 +1,6 @@ +package com.ruoyi.project.ft546.device; + +@FunctionalInterface +public interface Ft546MotionCommandGuard { + boolean hasBlockingMotionCommand(String deviceId, String commandId); +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546SnapshotContext.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546SnapshotContext.java new file mode 100644 index 0000000..65c8b43 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546SnapshotContext.java @@ -0,0 +1,39 @@ +package com.ruoyi.project.ft546.device; + +import java.time.Instant; +import java.util.Objects; + +public final class Ft546SnapshotContext { + private final String snapshotId; + private final String commandKey; + private final long topologyVersion; + private final long fencingToken; + private final Instant observedAt; + + public Ft546SnapshotContext(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"); + } + + public String getSnapshotId() { return snapshotId; } + public String getCommandKey() { return commandKey; } + public long getTopologyVersion() { return topologyVersion; } + public long getFencingToken() { return fencingToken; } + public Instant getObservedAt() { return 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/main/java/com/ruoyi/project/ft546/device/Ft546SnapshotContextProvider.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546SnapshotContextProvider.java new file mode 100644 index 0000000..ff3fa5a --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/Ft546SnapshotContextProvider.java @@ -0,0 +1,6 @@ +package com.ruoyi.project.ft546.device; + +@FunctionalInterface +public interface Ft546SnapshotContextProvider { + Ft546SnapshotContext contextFor(String deviceId); +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/charger/Ft546ChargerAdapter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/charger/Ft546ChargerAdapter.java new file mode 100644 index 0000000..df0410b --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/charger/Ft546ChargerAdapter.java @@ -0,0 +1,70 @@ +package com.ruoyi.project.ft546.device.charger; + +import com.ruoyi.device.cdz.domain.DeviceCdz; +import com.ruoyi.project.ft546.config.Ft546DeviceProfile; +import com.ruoyi.project.ft546.device.AbstractFt546EquipmentAdapter; +import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec; +import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard; +import com.ruoyi.project.ft546.device.Ft546SnapshotContextProvider; +import com.ruoyi.schedule.plcRead.cdz.PlcCdzRead; +import com.ruoyi.wcs.domain.execution.DeviceCommandType; +import com.ruoyi.wcs.domain.execution.DeviceSnapshot; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public final class Ft546ChargerAdapter + extends AbstractFt546EquipmentAdapter { + private final PlcCdzRead runtime; + + public Ft546ChargerAdapter(PlcCdzRead runtime, + Ft546DeviceProfile profile, + Ft546MotionCommandGuard motionCommandGuard, + Ft546SnapshotContextProvider contextProvider) { + super(profile, "CHARGER", "CHARGE", DeviceCommandType.CHARGE, + motionCommandGuard, contextProvider); + this.runtime = Objects.requireNonNull(runtime, "runtime"); + runtime.requireDevice(profile.getDeviceId()); + } + + @Override + protected boolean isConnected() { return runtime.IsConnect(); } + + @Override + protected int write(String address, byte[] payload) { + return runtime.write(address, payload); + } + + @Override + protected byte[] encode(String payload) { + Map values = Ft546LegacyPayloadCodec.parse(payload); + int enabled = Ft546LegacyPayloadCodec.binaryFlag(values, "enabled"); + return Ft546LegacyPayloadCodec.fields( + Ft546LegacyPayloadCodec.int16(enabled)); + } + + @Override + protected DeviceSnapshot snapshot(String deviceId) { + DeviceCdz device = runtime.requireDevice(deviceId); + Map facts = new LinkedHashMap(); + facts.put("connection", isConnected() ? "ONLINE" : "OFFLINE"); + facts.put("occupancy", isBlank(device.getSpare1()) + ? "FREE" : "OCCUPIED"); + facts.put("occupyingVehicle", fact(device.getSpare1())); + facts.put("charging", Integer.valueOf(1).equals(device.getStatus()) + ? "ON" : "OFF"); + facts.put("fault", "UNAVAILABLE"); + facts.put("status", value(device.getStatus())); + facts.put("mode", value(device.getModel())); + facts.put("site", fact(device.getSiteId())); + facts.put("floor", value(device.getSzcs())); + facts.put("taskNumber", value(device.getRwh())); + return unorderedSnapshot(deviceId, value(device.getRwh()), + fact(device.getSiteId()), facts); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/conveyor/Ft546ConveyorAdapter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/conveyor/Ft546ConveyorAdapter.java new file mode 100644 index 0000000..efd1ce4 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/conveyor/Ft546ConveyorAdapter.java @@ -0,0 +1,74 @@ +package com.ruoyi.project.ft546.device.conveyor; + +import com.ruoyi.device.ssj.domain.DeviceSsj; +import com.ruoyi.project.ft546.config.Ft546DeviceProfile; +import com.ruoyi.project.ft546.device.AbstractFt546EquipmentAdapter; +import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec; +import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard; +import com.ruoyi.project.ft546.device.Ft546SnapshotContextProvider; +import com.ruoyi.schedule.plcRead.ssj.PlcSsjRead; +import com.ruoyi.wcs.domain.execution.DeviceCommandType; +import com.ruoyi.wcs.domain.execution.DeviceSnapshot; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public final class Ft546ConveyorAdapter + extends AbstractFt546EquipmentAdapter { + private final PlcSsjRead runtime; + + public Ft546ConveyorAdapter(PlcSsjRead runtime, + Ft546DeviceProfile profile, + Ft546MotionCommandGuard motionCommandGuard, + Ft546SnapshotContextProvider contextProvider) { + super(profile, "CONVEYOR", "CONVEYOR_TRANSFER", + DeviceCommandType.CONVEYOR_TRANSFER, motionCommandGuard, + contextProvider); + this.runtime = Objects.requireNonNull(runtime, "runtime"); + runtime.requireDevice(profile.getDeviceId()); + } + + @Override + protected boolean isConnected() { return runtime.IsConnect(); } + + @Override + protected int write(String address, byte[] payload) { + return runtime.write(address, payload); + } + + @Override + protected byte[] encode(String payload) { + Map values = Ft546LegacyPayloadCodec.parse(payload); + int taskNo = Ft546LegacyPayloadCodec.positiveInt(values, "taskNo"); + int destination = Ft546LegacyPayloadCodec.positiveInt(values, + "destination"); + int completed = Ft546LegacyPayloadCodec.binaryFlag(values, + "completed"); + int checksum = Ft546LegacyPayloadCodec.checksum(taskNo, destination, + completed); + return Ft546LegacyPayloadCodec.fields( + Ft546LegacyPayloadCodec.int32(taskNo), + Ft546LegacyPayloadCodec.int16(destination), + Ft546LegacyPayloadCodec.int16(completed), + Ft546LegacyPayloadCodec.int32(checksum)); + } + + @Override + protected DeviceSnapshot snapshot(String deviceId) { + DeviceSsj device = runtime.requireDevice(deviceId); + Map facts = new LinkedHashMap(); + facts.put("connection", isConnected() ? "ONLINE" : "OFFLINE"); + facts.put("goodsState", device.getDw() == 1 + ? "PRESENT" : "ABSENT"); + facts.put("mode", value(device.getModel())); + facts.put("readyState", device.getKx() == 1 + ? "READY" : "BLOCKED"); + facts.put("trayCode", fact(device.getTrayCode())); + facts.put("taskNumber", value(device.getTaskNo())); + facts.put("request", value(device.getRkqq())); + facts.put("faultCode", value(device.getErrorCode())); + return unorderedSnapshot(deviceId, value(device.getTaskNo()), + fact(device.getDeviceCode()), facts); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/lift/Ft546LiftAdapter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/lift/Ft546LiftAdapter.java new file mode 100644 index 0000000..df09908 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/lift/Ft546LiftAdapter.java @@ -0,0 +1,86 @@ +package com.ruoyi.project.ft546.device.lift; + +import com.ruoyi.device.tsj.domain.DeviceTsj; +import com.ruoyi.project.ft546.config.Ft546DeviceProfile; +import com.ruoyi.project.ft546.device.AbstractFt546EquipmentAdapter; +import com.ruoyi.project.ft546.device.Ft546LegacyPayloadCodec; +import com.ruoyi.project.ft546.device.Ft546MotionCommandGuard; +import com.ruoyi.project.ft546.device.Ft546SnapshotContextProvider; +import com.ruoyi.schedule.plcRead.tsj.PlcTsjRead; +import com.ruoyi.wcs.domain.execution.DeviceCommandType; +import com.ruoyi.wcs.domain.execution.DeviceSnapshot; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public final class Ft546LiftAdapter extends AbstractFt546EquipmentAdapter { + private final PlcTsjRead runtime; + + public Ft546LiftAdapter(PlcTsjRead runtime, Ft546DeviceProfile profile, + Ft546MotionCommandGuard motionCommandGuard, + Ft546SnapshotContextProvider contextProvider) { + super(profile, "LIFT", "LIFT_TRANSFER", + DeviceCommandType.LIFT_TRANSFER, motionCommandGuard, + contextProvider); + this.runtime = Objects.requireNonNull(runtime, "runtime"); + runtime.requireDevice(profile.getDeviceId()); + } + + @Override + protected boolean isConnected() { return runtime.IsConnect(); } + + @Override + protected int write(String address, byte[] payload) { + return runtime.write(address, payload); + } + + @Override + protected byte[] encode(String payload) { + Map values = Ft546LegacyPayloadCodec.parse(payload); + int taskNo = Ft546LegacyPayloadCodec.positiveInt(values, "taskNo"); + int taskType = Ft546LegacyPayloadCodec.positiveInt(values, + "taskType"); + int sourceFloor = Ft546LegacyPayloadCodec.positiveInt(values, + "sourceFloor"); + int targetFloor = Ft546LegacyPayloadCodec.positiveInt(values, + "targetFloor"); + int completed = 1; + int checksum = Ft546LegacyPayloadCodec.checksum(taskNo, taskType, + sourceFloor, targetFloor, completed); + return Ft546LegacyPayloadCodec.fields( + Ft546LegacyPayloadCodec.int32(taskNo), + Ft546LegacyPayloadCodec.int16(taskType), + Ft546LegacyPayloadCodec.int16(sourceFloor), + Ft546LegacyPayloadCodec.int16(targetFloor), + Ft546LegacyPayloadCodec.int16(completed), + Ft546LegacyPayloadCodec.int16(checksum)); + } + + @Override + protected DeviceSnapshot snapshot(String deviceId) { + DeviceTsj device = runtime.requireDevice(deviceId); + Map facts = new LinkedHashMap(); + facts.put("connection", isConnected() ? "ONLINE" : "OFFLINE"); + facts.put("floor", value(device.getDqcs())); + facts.put("doorState", doorState(device.getJdbz())); + facts.put("doorStateSource", "EXECUTION_PHASE_DERIVED"); + facts.put("portState", Integer.valueOf(1).equals(device.getYxxf()) + ? "READY" : "BLOCKED"); + facts.put("load", value(device.getHczt())); + facts.put("status", value(device.getSbzt())); + facts.put("mode", value(device.getCzfs())); + facts.put("executionStage", value(device.getJdbz())); + facts.put("taskNumber", value(device.getRwh())); + return unorderedSnapshot(deviceId, value(device.getRwh()), + value(device.getDqcs()), facts); + } + + private static String doorState(Integer executionStage) { + if (executionStage == null) { + return "UNKNOWN"; + } + return executionStage == 2 || executionStage == 4 + ? "OPEN" : "CLOSED"; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java index 71e5655..5a0b46d 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java @@ -5,6 +5,7 @@ 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.project.ft546.device.Ft546MotionCommandGuard; import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead; import com.ruoyi.wcs.application.device.DeviceCommandEnvelope; import com.ruoyi.wcs.application.device.SendReceipt; @@ -22,7 +23,7 @@ public final class Ft546LegacySrgvAdapter private final Ft546DeviceRuntimeRegistry registry; private final Ft546DeviceProfile profile; private final CapabilityGateResult capabilities; - private final MotionCommandGuard motionCommandGuard; + private final Ft546MotionCommandGuard motionCommandGuard; private final SnapshotContextProvider snapshotContextProvider; private final Ft546SrgvCommandEncoder encoder = new Ft546SrgvCommandEncoder(); @@ -36,7 +37,7 @@ public final class Ft546LegacySrgvAdapter Ft546DeviceProfile profile, Ft546CapabilityEvidence evidence, Ft546CapabilityGate capabilityGate, - MotionCommandGuard motionCommandGuard, + Ft546MotionCommandGuard motionCommandGuard, SnapshotContextProvider contextProvider) { this.registry = Objects.requireNonNull(registry, "registry"); this.profile = copyProfile(Objects.requireNonNull(profile, @@ -148,11 +149,6 @@ public final class Ft546LegacySrgvAdapter return copy; } - @FunctionalInterface - public interface MotionCommandGuard { - boolean hasBlockingMotionCommand(String deviceId, String commandId); - } - @FunctionalInterface public interface SnapshotContextProvider { Ft546SrgvSnapshotMapper.Context contextFor(String deviceId); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/cdz/PlcCdzRead.java b/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/cdz/PlcCdzRead.java index b57189b..0a7e73e 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/cdz/PlcCdzRead.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/cdz/PlcCdzRead.java @@ -42,6 +42,20 @@ public class PlcCdzRead implements Runnable { return a; } + public int write(String address, byte[] data) { + return WriteToCdz(address, data); + } + + public DeviceCdz requireDevice(String deviceId) { + DeviceCdz device = deviceCdzMap == null + ? null : deviceCdzMap.get(deviceId); + if (device == null) { + throw new IllegalArgumentException( + "charger device not found: " + deviceId); + } + return device; + } + @Override public void run() { while (isRun) { @@ -99,16 +113,11 @@ public class PlcCdzRead implements Runnable { } public void setIsConnect(boolean bflag) { - if (!success) { - success = bflag; - } + success = bflag; } public boolean IsConnect() { - if (success) { - return success; - } - return false; + return success; } void reConnect() { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/srgv/PlcSrgvRead.java b/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/srgv/PlcSrgvRead.java index 7d3d4f2..25888be 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/srgv/PlcSrgvRead.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/srgv/PlcSrgvRead.java @@ -237,16 +237,11 @@ public class PlcSrgvRead implements Runnable { } public void setIsConnect(boolean bflag) { - if (!success) { - success = bflag; - } + success = bflag; } public boolean IsConnect() { - if (success) { - return success; - } - return false; + return success; } void reConnect() { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/ssj/PlcSsjRead.java b/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/ssj/PlcSsjRead.java index 82980b3..9702e38 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/ssj/PlcSsjRead.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/ssj/PlcSsjRead.java @@ -47,6 +47,20 @@ public class PlcSsjRead implements Runnable { return a; } + public int write(String address, byte[] data) { + return WriteToSSJ(address, data); + } + + public DeviceSsj requireDevice(String deviceId) { + DeviceSsj device = deviceSsjMap == null + ? null : deviceSsjMap.get(deviceId); + if (device == null) { + throw new IllegalArgumentException( + "conveyor device not found: " + deviceId); + } + return device; + } + @Override public void run() { while (isRun) { @@ -223,16 +237,11 @@ public class PlcSsjRead implements Runnable { } public void setIsConnect(boolean bflag) { - if (!success) { - success = bflag; - } + success = bflag; } public boolean IsConnect() { - if (success) { - return success; - } - return false; + return success; } void reConnect() { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/tsj/PlcTsjRead.java b/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/tsj/PlcTsjRead.java index e287bdd..ebf9fa6 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/tsj/PlcTsjRead.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/schedule/plcRead/tsj/PlcTsjRead.java @@ -43,6 +43,20 @@ public class PlcTsjRead implements Runnable { return a; } + public int write(String address, byte[] data) { + return WriteToTSJ(address, data); + } + + public DeviceTsj requireDevice(String deviceId) { + DeviceTsj device = deviceTsjMap == null + ? null : deviceTsjMap.get(deviceId); + if (device == null) { + throw new IllegalArgumentException( + "lift device not found: " + deviceId); + } + return device; + } + @Override public void run() { while (isRun) { @@ -107,16 +121,11 @@ public class PlcTsjRead implements Runnable { } public void setIsConnect(boolean bflag) { - if (!success) { - success = bflag; - } + success = bflag; } public boolean IsConnect() { - if (success) { - return success; - } - return false; + return success; } void reConnect() { diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546EquipmentAdaptersTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546EquipmentAdaptersTest.java new file mode 100644 index 0000000..386a452 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/Ft546EquipmentAdaptersTest.java @@ -0,0 +1,309 @@ +package com.ruoyi.project.ft546.device; + +import com.ruoyi.common.domain.PlcConnectInfo; +import com.ruoyi.device.cdz.domain.DeviceCdz; +import com.ruoyi.device.srgv.domain.DeviceSrgv; +import com.ruoyi.device.ssj.domain.DeviceSsj; +import com.ruoyi.device.tsj.domain.DeviceTsj; +import com.ruoyi.project.ft546.config.Ft546DeviceProfile; +import com.ruoyi.project.ft546.device.charger.Ft546ChargerAdapter; +import com.ruoyi.project.ft546.device.conveyor.Ft546ConveyorAdapter; +import com.ruoyi.project.ft546.device.lift.Ft546LiftAdapter; +import com.ruoyi.schedule.plcRead.cdz.PlcCdzRead; +import com.ruoyi.schedule.plcRead.ssj.PlcSsjRead; +import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead; +import com.ruoyi.schedule.plcRead.tsj.PlcTsjRead; +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.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class Ft546EquipmentAdaptersTest { + private static final Instant NOW = Instant.parse("2026-08-11T05:00:00Z"); + + @Test + void liftAdapterReportsFactsWithoutReleasingCoreResources() { + DeviceTsj device = liftDevice(); + device.setDqcs(3); + device.setJdbz(2); + device.setHczt(1); + device.setYxxf(1); + device.setSbzt(2); + device.setCzfs(2); + device.setRwh(30001L); + FakeTsjRead runtime = new FakeTsjRead("1", device); + Ft546LiftAdapter adapter = new Ft546LiftAdapter(runtime, + profile("1", "LIFT", "LIFT_TRANSFER", "DB602.0"), + (deviceId, commandId) -> false, contextProvider()); + + DeviceSnapshot snapshot = adapter.readSnapshot("1"); + + assertEquals("3", snapshot.getFacts().get("floor")); + assertEquals("OPEN", snapshot.getFacts().get("doorState")); + assertEquals("READY", snapshot.getFacts().get("portState")); + assertEquals("1", snapshot.getFacts().get("load")); + assertEquals("2", snapshot.getFacts().get("status")); + assertEquals("2", snapshot.getFacts().get("mode")); + assertFalse(snapshot.getFacts().containsKey("releaseLiftResource")); + assertFalse(snapshot.hasOrderedDeviceSequence()); + assertFalse(snapshot.hasDeviceBootSession()); + assertFalse(snapshot.isCommandCompleted()); + } + + @Test + void liftCommandKeepsTheLegacyByteLayout() { + FakeTsjRead runtime = new FakeTsjRead("1", liftDevice()); + Ft546LiftAdapter adapter = new Ft546LiftAdapter(runtime, + profile("1", "LIFT", "LIFT_TRANSFER", "DB602.0"), + (deviceId, commandId) -> false, contextProvider()); + + SendReceipt receipt = adapter.send(envelope("LIFT-1", "1", + DeviceCommandType.LIFT_TRANSFER, + "taskNo=30001|taskType=1|sourceFloor=3|targetFloor=7")); + + assertEquals(SendReceipt.Status.ACCEPTED, receipt.getStatus()); + assertEquals("DB602.0", runtime.addresses.get(0)); + assertArrayEquals(new byte[] {0, 0, 117, 49, 0, 1, 0, 3, + 0, 7, 0, 1, 0, (byte) 178}, runtime.payloads.get(0)); + } + + @Test + void conveyorAdapterMapsGoodsModeAndReadyFactsAndLegacyCommand() { + DeviceSsj device = conveyorDevice(); + device.setTaskNo(30002); + device.setDw(1); + device.setKx(1); + device.setModel(1); + device.setTrayCode("TRAY-001"); + device.setErrorCode(0); + FakeSsjRead runtime = new FakeSsjRead("GS1002", device); + Ft546ConveyorAdapter adapter = new Ft546ConveyorAdapter(runtime, + profile("GS1002", "CONVEYOR", "CONVEYOR_TRANSFER", + "DB41.0"), + (deviceId, commandId) -> false, contextProvider()); + + DeviceSnapshot snapshot = adapter.readSnapshot("GS1002"); + SendReceipt receipt = adapter.send(envelope("CONV-1", "GS1002", + DeviceCommandType.CONVEYOR_TRANSFER, + "taskNo=30001|destination=2|completed=1")); + + assertEquals("PRESENT", snapshot.getFacts().get("goodsState")); + assertEquals("1", snapshot.getFacts().get("mode")); + assertEquals("READY", snapshot.getFacts().get("readyState")); + assertEquals("TRAY-001", snapshot.getFacts().get("trayCode")); + assertEquals("0", snapshot.getFacts().get("faultCode")); + assertEquals(SendReceipt.Status.ACCEPTED, receipt.getStatus()); + assertEquals("DB41.0", runtime.addresses.get(0)); + assertArrayEquals(new byte[] {0, 0, 117, 49, 0, 2, 0, 1, + 0, 0, 0, (byte) 169}, runtime.payloads.get(0)); + } + + @Test + void chargerAdapterMapsOccupancyChargingAndFaultFactsAndLegacyCommand() { + DeviceCdz device = chargerDevice(); + device.setRwh(30003); + device.setStatus(1); + device.setModel(1); + device.setSpare1("1"); + FakeCdzRead runtime = new FakeCdzRead("1", device); + Ft546ChargerAdapter adapter = new Ft546ChargerAdapter(runtime, + profile("1", "CHARGER", "CHARGE", "DB701.0"), + (deviceId, commandId) -> false, contextProvider()); + + DeviceSnapshot snapshot = adapter.readSnapshot("1"); + SendReceipt receipt = adapter.send(envelope("CHARGE-1", "1", + DeviceCommandType.CHARGE, "enabled=1")); + + assertEquals("OCCUPIED", snapshot.getFacts().get("occupancy")); + assertEquals("ON", snapshot.getFacts().get("charging")); + assertEquals("UNAVAILABLE", snapshot.getFacts().get("fault")); + assertEquals("070501", snapshot.getFacts().get("site")); + assertEquals(SendReceipt.Status.ACCEPTED, receipt.getStatus()); + assertEquals("DB701.0", runtime.addresses.get(0)); + assertArrayEquals(new byte[] {0, 1}, runtime.payloads.get(0)); + } + + @Test + void attemptedEquipmentWriteFailureIsUnknownAndBlocksRetry() { + FakeCdzRead runtime = new FakeCdzRead("1", chargerDevice()); + runtime.writeResult = 0; + Ft546ChargerAdapter adapter = new Ft546ChargerAdapter(runtime, + profile("1", "CHARGER", "CHARGE", "DB701.0"), + (deviceId, commandId) -> false, contextProvider()); + + SendReceipt first = adapter.send(envelope("CHARGE-1", "1", + DeviceCommandType.CHARGE, "enabled=1")); + SendReceipt retry = adapter.send(envelope("CHARGE-2", "1", + DeviceCommandType.CHARGE, "enabled=1")); + + assertEquals(SendReceipt.Status.ACCEPTED_UNKNOWN, + first.getStatus()); + assertEquals(SendReceipt.Status.REJECTED, retry.getStatus()); + assertEquals(1, runtime.payloads.size()); + } + + @Test + void factReadersPublishAConnectionDropAfterTheyWereConnected() { + FakeTsjRead lift = new FakeTsjRead("1", liftDevice()); + FakeSsjRead conveyor = new FakeSsjRead("GS1002", conveyorDevice()); + FakeCdzRead charger = new FakeCdzRead("1", chargerDevice()); + DeviceSrgv shuttleDevice = new DeviceSrgv(); + shuttleDevice.setDeviceId(1); + PlcSrgvRead shuttle = new PlcSrgvRead(shuttleDevice, + new PlcConnectInfo("127.0.0.1", 502)); + shuttle.setIsConnect(true); + + lift.setIsConnect(false); + conveyor.setIsConnect(false); + charger.setIsConnect(false); + shuttle.setIsConnect(false); + + assertFalse(lift.IsConnect()); + assertFalse(conveyor.IsConnect()); + assertFalse(charger.IsConnect()); + assertFalse(shuttle.IsConnect()); + } + + private static Ft546DeviceProfile profile(String deviceId, + String deviceType, + String capability, + String commandAddress) { + Ft546DeviceProfile profile = new Ft546DeviceProfile(); + profile.setDeviceId(deviceId); + profile.setDeviceType(deviceType); + profile.setAdapterType("FT546_LEGACY_" + deviceType); + profile.setProfileVersion(1L); + profile.getCapabilities().add(capability); + profile.getRegisters().put("command", commandAddress); + return profile; + } + + private static Ft546SnapshotContextProvider contextProvider() { + return deviceId -> new Ft546SnapshotContext("SNAP-" + deviceId, + "KEY-" + deviceId, 9L, 17L, NOW); + } + + private static DeviceCommandEnvelope envelope(String commandId, + String deviceId, + DeviceCommandType type, + String payload) { + return new DeviceCommandEnvelope(commandId, "KEY-" + commandId, + deviceId, type, payload, 1, 9L, 17L); + } + + private static DeviceTsj liftDevice() { + DeviceTsj device = new DeviceTsj(); + device.setId(1L); + return device; + } + + private static DeviceSsj conveyorDevice() { + DeviceSsj device = new DeviceSsj(); + device.setDeviceCode("GS1002"); + return device; + } + + private static DeviceCdz chargerDevice() { + DeviceCdz device = new DeviceCdz(); + device.setId(1); + device.setSiteId("070501"); + return device; + } + + private abstract static class RecordingRuntime { + private final List addresses = new ArrayList(); + private final List payloads = new ArrayList(); + private int writeResult = 1; + + final int record(String address, byte[] payload) { + addresses.add(address); + payloads.add(payload.clone()); + return writeResult; + } + } + + private static final class FakeTsjRead extends PlcTsjRead { + private final RecordingRuntime recording = new RecordingRuntime() { }; + private final List addresses = recording.addresses; + private final List payloads = recording.payloads; + + private FakeTsjRead(String id, DeviceTsj device) { + super(map(id, device), new PlcConnectInfo("127.0.0.1", 102)); + setIsConnect(true); + } + + @Override + public int write(String address, byte[] payload) { + return recording.record(address, payload); + } + } + + private static final class FakeSsjRead extends PlcSsjRead { + private final RecordingRuntime recording = new RecordingRuntime() { }; + private final List addresses = recording.addresses; + private final List payloads = recording.payloads; + + private FakeSsjRead(String id, DeviceSsj device) { + super(map(id, device), new PlcConnectInfo("127.0.0.1", 102)); + setIsConnect(true); + } + + @Override + public int write(String address, byte[] payload) { + return recording.record(address, payload); + } + } + + private static final class FakeCdzRead extends PlcCdzRead { + private final List addresses = new ArrayList(); + private final List payloads = new ArrayList(); + private int writeResult = 1; + + private FakeCdzRead(String id, DeviceCdz device) { + super(map(id, device), new PlcConnectInfo("127.0.0.1", 102)); + setIsConnect(true); + } + + @Override + public int write(String address, byte[] payload) { + addresses.add(address); + payloads.add(payload.clone()); + return writeResult; + } + } + + private static ConcurrentHashMap map( + String id, DeviceTsj device) { + ConcurrentHashMap map = + new ConcurrentHashMap(); + map.put(id, device); + return map; + } + + private static ConcurrentHashMap map( + String id, DeviceSsj device) { + ConcurrentHashMap map = + new ConcurrentHashMap(); + map.put(id, device); + return map; + } + + private static ConcurrentHashMap map( + String id, DeviceCdz device) { + ConcurrentHashMap map = + new ConcurrentHashMap(); + map.put(id, device); + return map; + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java index 18519b2..0365219 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java @@ -8,6 +8,7 @@ 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.project.ft546.device.Ft546MotionCommandGuard; import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead; import com.ruoyi.wcs.application.device.DeviceCommandEnvelope; import com.ruoyi.wcs.application.device.SendReceipt; @@ -241,7 +242,7 @@ class Ft546LegacySrgvAdapterTest { private static Ft546LegacySrgvAdapter adapter( FakePlcSrgvRead runtime, Ft546DeviceProfile profile, Ft546CapabilityEvidence evidence, - Ft546LegacySrgvAdapter.MotionCommandGuard motionGuard) { + Ft546MotionCommandGuard motionGuard) { Ft546DeviceRuntimeRegistry registry = new Ft546DeviceRuntimeRegistry(); registry.register("1", runtime);