|
|
@@ -0,0 +1,298 @@
|
|
|
+package com.jeesite.modules.scheduling;
|
|
|
+
|
|
|
+import com.jeesite.common.config.Global;
|
|
|
+import com.jeesite.common.lang.StringUtils;
|
|
|
+import com.jeesite.modules.mes.entity.MesFsw;
|
|
|
+import com.jeesite.modules.mes.service.MesFswService;
|
|
|
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
|
|
|
+import org.eclipse.milo.opcua.sdk.client.api.config.OpcUaClientConfig;
|
|
|
+import org.eclipse.milo.opcua.sdk.client.api.identity.AnonymousProvider;
|
|
|
+import org.eclipse.milo.opcua.sdk.client.api.identity.IdentityProvider;
|
|
|
+import org.eclipse.milo.opcua.sdk.client.api.identity.UsernameProvider;
|
|
|
+import org.eclipse.milo.opcua.stack.client.DiscoveryClient;
|
|
|
+import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy;
|
|
|
+import org.eclipse.milo.opcua.stack.core.util.EndpointUtil;
|
|
|
+import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateBuilder;
|
|
|
+import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateGenerator;
|
|
|
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
|
|
|
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
|
|
|
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
|
|
|
+import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
|
|
|
+import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn;
|
|
|
+import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.context.annotation.Configuration;
|
|
|
+import org.springframework.scheduling.annotation.EnableScheduling;
|
|
|
+import org.springframework.scheduling.annotation.Scheduled;
|
|
|
+
|
|
|
+import java.security.KeyPair;
|
|
|
+import java.security.cert.X509Certificate;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
|
|
|
+
|
|
|
+/**
|
|
|
+ * FSW 设备数据采集定时任务(OPC UA点位)
|
|
|
+ * 在 application.yml 或 sys_config 中配置:
|
|
|
+ * mes.fsw.enabled 是否启用采集(默认 true)
|
|
|
+ * @author system
|
|
|
+ * @version 2026-06-08 OpcUaClient oPCuA_123
|
|
|
+ */
|
|
|
+@Configuration
|
|
|
+@EnableScheduling
|
|
|
+public class FswScheduling {
|
|
|
+
|
|
|
+ private static final Logger logger = LoggerFactory.getLogger(FswScheduling.class);
|
|
|
+
|
|
|
+ /** 默认 OPC UA 端点地址 */
|
|
|
+ private static final String DEFAULT_ENDPOINT = "opc.tcp://10.0.20.141:4840";
|
|
|
+
|
|
|
+ // ===== 采集点位地址(万宇科技采集地址)=====
|
|
|
+ private static final String NODE_SP_SPEED = "ns=2;s=/Channel/Spindle/actSpeed";
|
|
|
+ private static final String NODE_X_MCS = "ns=2;s=/Channel/MachineAxis/actToolBasePos[u1,1]";
|
|
|
+ private static final String NODE_Y_MCS = "ns=2;s=/Channel/MachineAxis/actToolBasePos[u1,2]";
|
|
|
+ private static final String NODE_Z_MCS = "ns=2;s=/Channel/MachineAxis/actToolBasePos[u1,3]";
|
|
|
+ private static final String NODE_ACTUAL_PRESSURE = "ns=2;s=/Nck/State/aDbr[12]";
|
|
|
+ private static final String NODE_AA_OFF_VAL_Z = "ns=2;s=/Channel/MachineAxis/aaOffVal[u1, 3]";
|
|
|
+ private static final String NODE_X_FEED = "ns=2;s=/Channel/GeometricAxis/actFeedRate[u1,1]";
|
|
|
+ private static final String NODE_Y_FEED = "ns=2;s=/Channel/GeometricAxis/actFeedRate[u1,2]";
|
|
|
+ private static final String NODE_Z_FEED = "ns=2;s=/Channel/GeometricAxis/actFeedRate[u1,3]";
|
|
|
+ private static final String NODE_R210 = "ns=2;s=/Channel/Parameter/rpa[u1, 210]";
|
|
|
+ private static final String NODE_R211 = "ns=2;s=/Channel/Parameter/rpa[u1, 211]";
|
|
|
+ private static final String NODE_R212 = "ns=2;s=/Channel/Parameter/rpa[u1, 212]";
|
|
|
+ private static final String NODE_R213 = "ns=2;s=/Channel/Parameter/rpa[u1, 213]";
|
|
|
+ private static final String NODE_R214 = "ns=2;s=/Channel/Parameter/rpa[u1, 214]";
|
|
|
+ private static final String NODE_R215 = "ns=2;s=/Channel/Parameter/rpa[u1, 215]";
|
|
|
+ private static final String NODE_R216 = "ns=2;s=/Channel/Parameter/rpa[u1, 216]";
|
|
|
+ private static final String NODE_R217 = "ns=2;s=/Channel/Parameter/rpa[u1, 217]";
|
|
|
+ private static final String NODE_R218 = "ns=2;s=/Channel/Parameter/rpa[u1, 218]";
|
|
|
+ private static final String NODE_R999 = "ns=2;s=/Channel/Parameter/rpa[u1, 999]";
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private MesFswService mesFswService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 每 30 秒采集一次
|
|
|
+ */
|
|
|
+ @Scheduled(fixedRate = 30000)
|
|
|
+ public void collect() {
|
|
|
+ String enabled = Global.getConfig("mes.fsw.enabled");
|
|
|
+ if (StringUtils.isNotBlank(enabled) && !"true".equalsIgnoreCase(enabled.trim())) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ String url = Global.getConfig("mes.fsw.opcua.url");
|
|
|
+ if (StringUtils.isBlank(url)) {
|
|
|
+ url = DEFAULT_ENDPOINT;
|
|
|
+ }
|
|
|
+
|
|
|
+ OpcUaClient client = null;
|
|
|
+ try {
|
|
|
+ client = createClient(url);
|
|
|
+ client.connect().get();
|
|
|
+
|
|
|
+ MesFsw mesFsw = new MesFsw();
|
|
|
+ mesFsw.setSpSpeed(readValue(client, NODE_SP_SPEED));
|
|
|
+ mesFsw.setXMcs(readValue(client, NODE_X_MCS));
|
|
|
+ mesFsw.setYMcs(readValue(client, NODE_Y_MCS));
|
|
|
+ mesFsw.setZMcs(readValue(client, NODE_Z_MCS));
|
|
|
+ mesFsw.setActualPressure(readValue(client, NODE_ACTUAL_PRESSURE));
|
|
|
+ mesFsw.setAaOffValZ(readValue(client, NODE_AA_OFF_VAL_Z));
|
|
|
+ mesFsw.setXFeed(readValue(client, NODE_X_FEED));
|
|
|
+ mesFsw.setYFeed(readValue(client, NODE_Y_FEED));
|
|
|
+ mesFsw.setZFeed(readValue(client, NODE_Z_FEED));
|
|
|
+ mesFsw.setR210(readValue(client, NODE_R210));
|
|
|
+ mesFsw.setR211(readValue(client, NODE_R211));
|
|
|
+ mesFsw.setR212(readValue(client, NODE_R212));
|
|
|
+ mesFsw.setR213(readValue(client, NODE_R213));
|
|
|
+ mesFsw.setR214(readValue(client, NODE_R214));
|
|
|
+ mesFsw.setR215(readValue(client, NODE_R215));
|
|
|
+ mesFsw.setR216(readValue(client, NODE_R216));
|
|
|
+ mesFsw.setR217(readValue(client, NODE_R217));
|
|
|
+ mesFsw.setR218(readValue(client, NODE_R218));
|
|
|
+ mesFsw.setR999(readValue(client, NODE_R999));
|
|
|
+
|
|
|
+ mesFswService.save(mesFsw);
|
|
|
+
|
|
|
+ logger.info("FSW数据采集成功[{}] 主轴转速:{} 实际压力:{}",
|
|
|
+ url, mesFsw.getSpSpeed(), mesFsw.getActualPressure());
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("FSW数据采集失败[{}]:{}", url, e.getMessage());
|
|
|
+ } finally {
|
|
|
+ if (client != null) {
|
|
|
+ try {
|
|
|
+ client.disconnect().get();
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.warn("关闭 OPC UA 连接异常:{}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 读取单个节点值,失败返回 null,不中断整体采集
|
|
|
+ */
|
|
|
+ private String readValue(OpcUaClient client, String nodeIdStr) {
|
|
|
+ try {
|
|
|
+ NodeId nodeId = NodeId.parse(nodeIdStr);
|
|
|
+ DataValue dv = client.readValue(0, TimestampsToReturn.Both, nodeId).get();
|
|
|
+ if (dv == null) {
|
|
|
+ logger.warn("读取节点[{}] 返回 null DataValue", nodeIdStr);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ // 节点不存在 / 无权限时,statusCode 通常 Bad,且 value 为空
|
|
|
+ if (dv.getValue() == null || dv.getValue().getValue() == null) {
|
|
|
+ logger.warn("读取节点[{}] 值为空,statusCode={} ({})",
|
|
|
+ nodeIdStr, dv.getStatusCode(),
|
|
|
+ dv.getStatusCode() == null ? "" : dv.getStatusCode().toString());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return formatVal(dv.getValue().getValue());
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.warn("读取节点[{}]失败:{}", nodeIdStr, e.getMessage());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 数值格式化:浮点保留 3 位小数;整数原样输出;其它类型 toString
|
|
|
+ */
|
|
|
+ private String formatVal(Object val) {
|
|
|
+ if (val == null) return null;
|
|
|
+ if (val instanceof Double || val instanceof Float) {
|
|
|
+ double d = ((Number) val).doubleValue();
|
|
|
+ // 用 BigDecimal 半进位四舍五入,避免 String.format 在科学计数法或负零上的小坑
|
|
|
+ return new java.math.BigDecimal(d)
|
|
|
+ .setScale(3, java.math.RoundingMode.HALF_UP)
|
|
|
+ .toPlainString();
|
|
|
+ }
|
|
|
+ return String.valueOf(val);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建 OpcUaClient。
|
|
|
+ * 该服务器只提供加密端点(Basic128Rsa15/Basic256/Basic256Sha256),
|
|
|
+ * 因此需要为客户端生成自签名证书 + 密钥对,并选用加密端点连接。
|
|
|
+ */
|
|
|
+ private OpcUaClient createClient(String endpointUrl) throws Exception {
|
|
|
+ List<EndpointDescription> endpoints = DiscoveryClient.getEndpoints(endpointUrl).get();
|
|
|
+
|
|
|
+ if (endpoints == null || endpoints.isEmpty()) {
|
|
|
+ throw new IllegalStateException("服务器未返回任何 Endpoint:" + endpointUrl);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (EndpointDescription ep : endpoints) {
|
|
|
+ logger.info("发现OPC UA端点: url={} securityPolicy={} securityMode={}",
|
|
|
+ ep.getEndpointUrl(), ep.getSecurityPolicyUri(), ep.getSecurityMode());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 准备客户端证书 / 密钥对(只生成一次)
|
|
|
+ ensureKeyPair();
|
|
|
+
|
|
|
+ // 端点选择优先级:Basic256Sha256(SignAndEncrypt) > 任意加密 > None > 第一个
|
|
|
+ EndpointDescription endpoint = endpoints.stream()
|
|
|
+ .filter(e -> e.getSecurityPolicyUri().equals(SecurityPolicy.Basic256Sha256.getUri())
|
|
|
+ && e.getSecurityMode() == MessageSecurityMode.SignAndEncrypt)
|
|
|
+ .findFirst()
|
|
|
+ .orElseGet(() -> endpoints.stream()
|
|
|
+ .filter(e -> !e.getSecurityPolicyUri().endsWith("#None"))
|
|
|
+ .findFirst()
|
|
|
+ .orElse(endpoints.get(0)));
|
|
|
+
|
|
|
+ // 服务器返回的端点 URL 可能是它自己的主机名,改写为实际拨号地址
|
|
|
+ endpoint = EndpointUtil.updateUrl(endpoint, parseHost(endpointUrl), parsePort(endpointUrl));
|
|
|
+
|
|
|
+ logger.info("选用OPC UA端点: url={} securityPolicy={} securityMode={}",
|
|
|
+ endpoint.getEndpointUrl(), endpoint.getSecurityPolicyUri(), endpoint.getSecurityMode());
|
|
|
+
|
|
|
+ OpcUaClientConfig config;
|
|
|
+ boolean encrypted = !endpoint.getSecurityPolicyUri().endsWith("#None")
|
|
|
+ && clientCertificate != null && clientKeyPair != null;
|
|
|
+ // 身份认证:默认用户名密码(西门子 SINUMERIK 不支持匿名);配置 mes.fsw.opcua.username 为空时回退匿名
|
|
|
+ IdentityProvider identity = buildIdentityProvider();
|
|
|
+ if (encrypted) {
|
|
|
+ config = OpcUaClientConfig.builder()
|
|
|
+ .setApplicationName(LocalizedText.english("MES FSW OPC UA Client"))
|
|
|
+ .setApplicationUri(CLIENT_APP_URI)
|
|
|
+ .setEndpoint(endpoint)
|
|
|
+ .setIdentityProvider(identity)
|
|
|
+ .setRequestTimeout(uint(10000))
|
|
|
+ .setCertificate(clientCertificate)
|
|
|
+ .setKeyPair(clientKeyPair)
|
|
|
+ .build();
|
|
|
+ } else {
|
|
|
+ config = OpcUaClientConfig.builder()
|
|
|
+ .setApplicationName(LocalizedText.english("MES FSW OPC UA Client"))
|
|
|
+ .setApplicationUri(CLIENT_APP_URI)
|
|
|
+ .setEndpoint(endpoint)
|
|
|
+ .setIdentityProvider(identity)
|
|
|
+ .setRequestTimeout(uint(10000))
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+
|
|
|
+ return OpcUaClient.create(config);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建身份认证提供者:默认匿名(服务器已开启匿名访问),
|
|
|
+ * 配置了 mes.fsw.opcua.username 才走用户名密码。
|
|
|
+ */
|
|
|
+ private IdentityProvider buildIdentityProvider() {
|
|
|
+ String user = Global.getConfig("mes.fsw.opcua.username");
|
|
|
+ if (StringUtils.isBlank(user)) {
|
|
|
+ return new AnonymousProvider();
|
|
|
+ }
|
|
|
+ String pwd = Global.getConfig("mes.fsw.opcua.password");
|
|
|
+ return new UsernameProvider(user, pwd == null ? "" : pwd);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 客户端证书 / 密钥对(静态缓存,避免每次采集都重新生成) */
|
|
|
+ private static volatile X509Certificate clientCertificate;
|
|
|
+ private static volatile KeyPair clientKeyPair;
|
|
|
+ private static final String CLIENT_APP_URI = "urn:mes:opcua:fsw:client";
|
|
|
+
|
|
|
+ /** 生成自签名证书 + 密钥对(仅首次调用时执行) */
|
|
|
+ private synchronized void ensureKeyPair() throws Exception {
|
|
|
+ if (clientCertificate != null && clientKeyPair != null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ KeyPair keyPair = SelfSignedCertificateGenerator.generateRsaKeyPair(2048);
|
|
|
+ SelfSignedCertificateBuilder certBuilder = new SelfSignedCertificateBuilder(keyPair)
|
|
|
+ .setCommonName("MES FSW OPC UA Client")
|
|
|
+ .setOrganization("MES")
|
|
|
+ .setOrganizationalUnit("dev")
|
|
|
+ .setLocalityName("CN")
|
|
|
+ .setStateName("CN")
|
|
|
+ .setCountryCode("CN")
|
|
|
+ .setApplicationUri(CLIENT_APP_URI)
|
|
|
+ .addDnsName("localhost")
|
|
|
+ .addIpAddress("127.0.0.1");
|
|
|
+ clientKeyPair = keyPair;
|
|
|
+ clientCertificate = certBuilder.build();
|
|
|
+ logger.info("已生成 OPC UA 客户端自签名证书");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从 opc.tcp://host:port 中解析主机 */
|
|
|
+ private String parseHost(String url) {
|
|
|
+ try {
|
|
|
+ String s = url.substring(url.indexOf("://") + 3);
|
|
|
+ String hostPort = s.contains("/") ? s.substring(0, s.indexOf("/")) : s;
|
|
|
+ return hostPort.contains(":") ? hostPort.substring(0, hostPort.indexOf(":")) : hostPort;
|
|
|
+ } catch (Exception e) {
|
|
|
+ return "127.0.0.1";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从 opc.tcp://host:port 中解析端口,默认 4840 */
|
|
|
+ private int parsePort(String url) {
|
|
|
+ try {
|
|
|
+ String s = url.substring(url.indexOf("://") + 3);
|
|
|
+ String hostPort = s.contains("/") ? s.substring(0, s.indexOf("/")) : s;
|
|
|
+ return hostPort.contains(":") ? Integer.parseInt(hostPort.substring(hostPort.indexOf(":") + 1)) : 4840;
|
|
|
+ } catch (Exception e) {
|
|
|
+ return 4840;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|