feat(ft546): add WCS adapter configuration profiles

This commit is contained in:
zgw
2026-08-11 11:41:50 +08:00
parent f9ebbcf51c
commit 11a21e0688
9 changed files with 605 additions and 0 deletions

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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"

View File

@@ -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);
}
}