feat(ft546): add WCS adapter configuration profiles
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
package com.ruoyi.project.ft546.config;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public final class CapabilityGateResult {
|
||||
private final boolean publishable;
|
||||
private final String reasonCode;
|
||||
private final Set<String> enabledCapabilities;
|
||||
|
||||
CapabilityGateResult(boolean publishable, String reasonCode,
|
||||
Set<String> enabledCapabilities) {
|
||||
this.publishable = publishable;
|
||||
this.reasonCode = reasonCode;
|
||||
this.enabledCapabilities = Collections.unmodifiableSet(
|
||||
new LinkedHashSet<String>(enabledCapabilities));
|
||||
}
|
||||
|
||||
public boolean isPublishable() { return publishable; }
|
||||
public String getReasonCode() { return reasonCode; }
|
||||
public boolean isEnabled(String capability) {
|
||||
return enabledCapabilities.contains(capability);
|
||||
}
|
||||
public Set<String> getEnabledCapabilities() {
|
||||
return enabledCapabilities;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ruoyi.project.ft546.config;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class Ft546CapabilityEvidence {
|
||||
private final long profileVersion;
|
||||
private final String corpusManifestHash;
|
||||
private final String passingTestRunId;
|
||||
private final String gitCommit;
|
||||
private final boolean protocolRegressionPassed;
|
||||
private final Instant approvedAt;
|
||||
private final Instant expiresAt;
|
||||
|
||||
public Ft546CapabilityEvidence(long profileVersion,
|
||||
String corpusManifestHash,
|
||||
String passingTestRunId,
|
||||
String gitCommit,
|
||||
boolean protocolRegressionPassed,
|
||||
Instant approvedAt, Instant expiresAt) {
|
||||
if (profileVersion <= 0L) {
|
||||
throw new IllegalArgumentException(
|
||||
"profileVersion must be positive");
|
||||
}
|
||||
this.profileVersion = profileVersion;
|
||||
this.corpusManifestHash = requireText(corpusManifestHash,
|
||||
"corpusManifestHash");
|
||||
this.passingTestRunId = requireText(passingTestRunId,
|
||||
"passingTestRunId");
|
||||
this.gitCommit = requireText(gitCommit, "gitCommit");
|
||||
this.protocolRegressionPassed = protocolRegressionPassed;
|
||||
this.approvedAt = Objects.requireNonNull(approvedAt, "approvedAt");
|
||||
this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
if (!expiresAt.isAfter(approvedAt)) {
|
||||
throw new IllegalArgumentException("evidence expiry is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
public long getProfileVersion() { return profileVersion; }
|
||||
public String getCorpusManifestHash() { return corpusManifestHash; }
|
||||
public String getPassingTestRunId() { return passingTestRunId; }
|
||||
public String getGitCommit() { return gitCommit; }
|
||||
public boolean isProtocolRegressionPassed() {
|
||||
return protocolRegressionPassed;
|
||||
}
|
||||
public Instant getApprovedAt() { return approvedAt; }
|
||||
public Instant getExpiresAt() { return expiresAt; }
|
||||
|
||||
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,50 @@
|
||||
package com.ruoyi.project.ft546.config;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class Ft546CapabilityGate {
|
||||
private final Supplier<Instant> clock;
|
||||
private final Ft546ConfigurationValidator validator =
|
||||
new Ft546ConfigurationValidator();
|
||||
|
||||
public Ft546CapabilityGate(Supplier<Instant> clock) {
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
public CapabilityGateResult evaluate(Ft546DeviceProfile profile,
|
||||
Ft546CapabilityEvidence evidence) {
|
||||
validator.validate(profile);
|
||||
Set<String> enabled = new LinkedHashSet<String>(
|
||||
profile.getCapabilities());
|
||||
enabled.remove("TWO_STAGE_ATOMIC_TASK");
|
||||
if (!profile.getCapabilities().contains("TWO_STAGE_ATOMIC_TASK")) {
|
||||
return new CapabilityGateResult(true, "PUBLISHABLE", enabled);
|
||||
}
|
||||
if (!matches(profile, evidence, now())) {
|
||||
return new CapabilityGateResult(false,
|
||||
"TWO_STAGE_EVIDENCE_REQUIRED", enabled);
|
||||
}
|
||||
enabled.add("TWO_STAGE_ATOMIC_TASK");
|
||||
return new CapabilityGateResult(true, "PUBLISHABLE", enabled);
|
||||
}
|
||||
|
||||
private static boolean matches(Ft546DeviceProfile profile,
|
||||
Ft546CapabilityEvidence evidence,
|
||||
Instant now) {
|
||||
return evidence != null
|
||||
&& evidence.isProtocolRegressionPassed()
|
||||
&& evidence.getProfileVersion() == profile.getProfileVersion()
|
||||
&& evidence.getCorpusManifestHash().equals(
|
||||
profile.getProtocolCorpusHash())
|
||||
&& !evidence.getApprovedAt().isAfter(now)
|
||||
&& evidence.getExpiresAt().isAfter(now);
|
||||
}
|
||||
|
||||
private Instant now() {
|
||||
return Objects.requireNonNull(clock.get(), "clock value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.ruoyi.project.ft546.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public final class Ft546ConfigurationValidator {
|
||||
private static final Set<String> LEGACY_FORBIDDEN = new HashSet<String>(
|
||||
Arrays.asList("COMMAND_RESULT_QUERY", "ORDERED_DEVICE_SNAPSHOT",
|
||||
"CANDIDATE_BUFFER", "PREPARE_COMMIT_SEGMENT",
|
||||
"IDEMPOTENT_COMMAND_KEY", "BOOT_SESSION_ID"));
|
||||
private static final Set<String> LEGACY_REQUIRED_REGISTERS =
|
||||
new LinkedHashSet<String>(Arrays.asList("taskNo", "taskType",
|
||||
"path", "trigger", "status", "execution"));
|
||||
|
||||
public void validate(Ft546WcsProperties properties) {
|
||||
if (properties == null || properties.getRuntimeMode() == null) {
|
||||
throw new IllegalArgumentException("runtimeMode must be configured");
|
||||
}
|
||||
if (properties.getDispatchIntervalMs() <= 0L
|
||||
|| properties.getCommandPollIntervalMs() <= 0L) {
|
||||
throw new IllegalArgumentException(
|
||||
"runtime intervals must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public void validate(Ft546DeviceProfile profile) {
|
||||
if (profile == null) {
|
||||
throw new IllegalArgumentException("profile must not be null");
|
||||
}
|
||||
requireText(profile.getDeviceId(), "deviceId");
|
||||
requireText(profile.getDeviceType(), "deviceType");
|
||||
String adapterType = requireText(profile.getAdapterType(),
|
||||
"adapterType");
|
||||
if (profile.getProfileVersion() <= 0L) {
|
||||
throw new IllegalArgumentException(
|
||||
"profileVersion must be positive");
|
||||
}
|
||||
if (profile.getPort() != null
|
||||
&& (profile.getPort() <= 0 || profile.getPort() > 65535)) {
|
||||
throw new IllegalArgumentException("port is invalid");
|
||||
}
|
||||
if (profile.getStation() != null && profile.getStation() < 0) {
|
||||
throw new IllegalArgumentException("station is invalid");
|
||||
}
|
||||
validateUniqueRegisterOwnership(profile.getRegisters());
|
||||
if ("FT546_LEGACY_SRGV".equals(adapterType)) {
|
||||
if (!profile.getRegisters().keySet().containsAll(
|
||||
LEGACY_REQUIRED_REGISTERS)) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy shuttle profile is missing required registers");
|
||||
}
|
||||
for (String forbidden : LEGACY_FORBIDDEN) {
|
||||
if (profile.getCapabilities().contains(forbidden)) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy adapter cannot claim " + forbidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (profile.getCapabilities().contains("TWO_STAGE_ATOMIC_TASK")) {
|
||||
requireText(profile.getProtocolCorpusHash(),
|
||||
"protocolCorpusHash");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateUniqueRegisterOwnership(
|
||||
Map<String, String> registers) {
|
||||
if (registers == null || registers.isEmpty()) {
|
||||
throw new IllegalArgumentException("registers must not be empty");
|
||||
}
|
||||
Set<String> addresses = new HashSet<String>();
|
||||
for (Map.Entry<String, String> register : registers.entrySet()) {
|
||||
requireText(register.getKey(), "register name");
|
||||
String address = requireText(register.getValue(),
|
||||
"register address");
|
||||
if (!addresses.add(address)) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate register ownership: " + address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,53 @@
|
||||
package com.ruoyi.project.ft546.config;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class Ft546DeviceProfile {
|
||||
private String deviceId;
|
||||
private String deviceType;
|
||||
private String adapterType;
|
||||
private String ip;
|
||||
private Integer port;
|
||||
private Integer station;
|
||||
private Set<String> capabilities = new LinkedHashSet<String>();
|
||||
private Map<String, String> registers =
|
||||
new LinkedHashMap<String, String>();
|
||||
private long profileVersion;
|
||||
private String protocolCorpusHash;
|
||||
|
||||
public String getDeviceId() { return deviceId; }
|
||||
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
|
||||
public String getDeviceType() { return deviceType; }
|
||||
public void setDeviceType(String deviceType) { this.deviceType = deviceType; }
|
||||
public String getAdapterType() { return adapterType; }
|
||||
public void setAdapterType(String adapterType) { this.adapterType = adapterType; }
|
||||
public String getIp() { return ip; }
|
||||
public void setIp(String ip) { this.ip = ip; }
|
||||
public Integer getPort() { return port; }
|
||||
public void setPort(Integer port) { this.port = port; }
|
||||
public Integer getStation() { return station; }
|
||||
public void setStation(Integer station) { this.station = station; }
|
||||
public Set<String> getCapabilities() { return capabilities; }
|
||||
public void setCapabilities(Set<String> capabilities) {
|
||||
this.capabilities = capabilities == null
|
||||
? new LinkedHashSet<String>()
|
||||
: new LinkedHashSet<String>(capabilities);
|
||||
}
|
||||
public Map<String, String> getRegisters() { return registers; }
|
||||
public void setRegisters(Map<String, String> registers) {
|
||||
this.registers = registers == null
|
||||
? new LinkedHashMap<String, String>()
|
||||
: new LinkedHashMap<String, String>(registers);
|
||||
}
|
||||
public long getProfileVersion() { return profileVersion; }
|
||||
public void setProfileVersion(long profileVersion) {
|
||||
this.profileVersion = profileVersion;
|
||||
}
|
||||
public String getProtocolCorpusHash() { return protocolCorpusHash; }
|
||||
public void setProtocolCorpusHash(String protocolCorpusHash) {
|
||||
this.protocolCorpusHash = protocolCorpusHash;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ruoyi.project.ft546.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "ft546.wcs")
|
||||
public class Ft546WcsProperties {
|
||||
public enum RuntimeMode {
|
||||
LEGACY,
|
||||
SHADOW,
|
||||
STANDARD
|
||||
}
|
||||
|
||||
private RuntimeMode runtimeMode = RuntimeMode.LEGACY;
|
||||
private long dispatchIntervalMs = 1000L;
|
||||
private long commandPollIntervalMs = 200L;
|
||||
|
||||
public RuntimeMode getRuntimeMode() { return runtimeMode; }
|
||||
public void setRuntimeMode(RuntimeMode runtimeMode) {
|
||||
this.runtimeMode = runtimeMode;
|
||||
}
|
||||
public long getDispatchIntervalMs() { return dispatchIntervalMs; }
|
||||
public void setDispatchIntervalMs(long dispatchIntervalMs) {
|
||||
this.dispatchIntervalMs = dispatchIntervalMs;
|
||||
}
|
||||
public long getCommandPollIntervalMs() { return commandPollIntervalMs; }
|
||||
public void setCommandPollIntervalMs(long commandPollIntervalMs) {
|
||||
this.commandPollIntervalMs = commandPollIntervalMs;
|
||||
}
|
||||
}
|
||||
15
ruoyi-admin/src/main/resources/ft546/wcs-device-profiles.yml
Normal file
15
ruoyi-admin/src/main/resources/ft546/wcs-device-profiles.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
devices:
|
||||
- deviceId: "1"
|
||||
deviceType: SHUTTLE
|
||||
adapterType: FT546_LEGACY_SRGV
|
||||
profileVersion: 1
|
||||
protocolCorpusHash: "UNAPPROVED_TWO_STAGE_CORPUS"
|
||||
capabilities:
|
||||
- POINT_TO_POINT_COMMAND
|
||||
registers:
|
||||
taskNo: "4000"
|
||||
taskType: "4002"
|
||||
path: "4004"
|
||||
trigger: "4021"
|
||||
status: "4068"
|
||||
execution: "4060"
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.ruoyi.project.ft546.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class Ft546ConfigurationValidatorTest {
|
||||
|
||||
@Test
|
||||
void defaultsToLegacySoDeploymentDoesNotTakeSchedulingOwnership() {
|
||||
Ft546WcsProperties properties = new Ft546WcsProperties();
|
||||
|
||||
assertEquals(Ft546WcsProperties.RuntimeMode.LEGACY,
|
||||
properties.getRuntimeMode());
|
||||
assertTrue(properties.getDispatchIntervalMs() > 0L);
|
||||
assertTrue(properties.getCommandPollIntervalMs() > 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsLegacyProfileThatClaimsPrepareCommit() {
|
||||
Ft546DeviceProfile profile = legacySrgvProfile();
|
||||
profile.getCapabilities().add("PREPARE_COMMIT_SEGMENT");
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new Ft546ConfigurationValidator().validate(profile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateRegisterOwnershipWithinOneDevice() {
|
||||
Ft546DeviceProfile profile = legacySrgvProfile();
|
||||
profile.getRegisters().put("taskType", "4000");
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new Ft546ConfigurationValidator().validate(profile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTwoStageCapabilityWithoutPassingEvidenceForExactProfile() {
|
||||
Ft546DeviceProfile profile = legacySrgvProfile();
|
||||
profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK");
|
||||
Ft546CapabilityGate gate = gate();
|
||||
|
||||
CapabilityGateResult result = gate.evaluate(profile, null);
|
||||
|
||||
assertFalse(result.isPublishable());
|
||||
assertEquals("TWO_STAGE_EVIDENCE_REQUIRED", result.getReasonCode());
|
||||
assertFalse(result.isEnabled("TWO_STAGE_ATOMIC_TASK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enablesTwoStageOnlyForPassingUnexpiredEvidenceBoundToProfile() {
|
||||
Ft546DeviceProfile profile = legacySrgvProfile();
|
||||
profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK");
|
||||
Ft546CapabilityEvidence evidence = new Ft546CapabilityEvidence(
|
||||
1L, "CORPUS-SHA256", "RUN-1", "COMMIT-1", true,
|
||||
Instant.parse("2026-08-10T00:00:00Z"),
|
||||
Instant.parse("2026-09-10T00:00:00Z"));
|
||||
|
||||
CapabilityGateResult result = gate().evaluate(profile, evidence);
|
||||
|
||||
assertTrue(result.isPublishable());
|
||||
assertTrue(result.isEnabled("TWO_STAGE_ATOMIC_TASK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyProfileKeepsUnsupportedCapabilitiesDisabled() {
|
||||
CapabilityGateResult result = gate().evaluate(legacySrgvProfile(), null);
|
||||
|
||||
assertFalse(result.isEnabled("COMMAND_RESULT_QUERY"));
|
||||
assertFalse(result.isEnabled("ORDERED_DEVICE_SNAPSHOT"));
|
||||
assertFalse(result.isEnabled("CANDIDATE_BUFFER"));
|
||||
assertFalse(result.isEnabled("PREPARE_COMMIT_SEGMENT"));
|
||||
assertFalse(result.isEnabled("IDEMPOTENT_COMMAND_KEY"));
|
||||
assertFalse(result.isEnabled("BOOT_SESSION_ID"));
|
||||
assertTrue(result.isEnabled("POINT_TO_POINT_COMMAND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkedInProfileKeepsEndpointsOutAndLegacyAddressesExact()
|
||||
throws Exception {
|
||||
String yaml = readProjectFile("ruoyi-admin/src/main/resources/ft546/"
|
||||
+ "wcs-device-profiles.yml");
|
||||
|
||||
assertFalse(yaml.contains("192.168."));
|
||||
assertFalse(yaml.toLowerCase().contains("password"));
|
||||
assertTrue(yaml.contains("taskNo: \"4000\""));
|
||||
assertTrue(yaml.contains("taskType: \"4002\""));
|
||||
assertTrue(yaml.contains("path: \"4004\""));
|
||||
assertTrue(yaml.contains("trigger: \"4021\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagesAdminOwnedAdapterConfigurationMigration() throws Exception {
|
||||
String sql = readProjectFile(
|
||||
"sql/wcs/V004__ft546_project_adapter_config.sql");
|
||||
|
||||
assertTrue(sql.contains("project_device_endpoint"));
|
||||
assertTrue(sql.contains("project_plc_address"));
|
||||
assertTrue(sql.contains("project_protocol_profile"));
|
||||
assertTrue(sql.contains("project_protocol_capability_evidence"));
|
||||
assertTrue(sql.contains("project_wms_mapping"));
|
||||
}
|
||||
|
||||
private static Ft546CapabilityGate gate() {
|
||||
return new Ft546CapabilityGate(() ->
|
||||
Instant.parse("2026-08-11T00:00:00Z"));
|
||||
}
|
||||
|
||||
private static Ft546DeviceProfile legacySrgvProfile() {
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
profile.setDeviceId("1");
|
||||
profile.setDeviceType("SHUTTLE");
|
||||
profile.setAdapterType("FT546_LEGACY_SRGV");
|
||||
profile.setProfileVersion(1L);
|
||||
profile.setProtocolCorpusHash("CORPUS-SHA256");
|
||||
profile.setCapabilities(new LinkedHashSet<String>(
|
||||
Collections.singletonList("POINT_TO_POINT_COMMAND")));
|
||||
LinkedHashMap<String, String> registers = new LinkedHashMap<String, String>();
|
||||
registers.put("taskNo", "4000");
|
||||
registers.put("taskType", "4002");
|
||||
registers.put("path", "4004");
|
||||
registers.put("trigger", "4021");
|
||||
registers.put("status", "4068");
|
||||
registers.put("execution", "4060");
|
||||
profile.setRegisters(registers);
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static String readProjectFile(String relative) throws Exception {
|
||||
Path current = Paths.get(System.getProperty(
|
||||
"maven.multiModuleProjectDirectory", "."))
|
||||
.toAbsolutePath().normalize();
|
||||
while (current != null) {
|
||||
Path file = current.resolve(relative);
|
||||
if (Files.isRegularFile(file)) {
|
||||
return new String(Files.readAllBytes(file),
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
throw new java.nio.file.NoSuchFileException(relative);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user