소스 검색

feat(runtime): 接入运行时管理框架;协议 v3 兼容 AQDW/MQDW 99 字节报文
- 参考 mesclient-okng(53a57d2、3f6496a、a20eea3)
- 新增 ClientType.CODE=RIVETING、CONFIG_FILE=config/station.yaml
- 覆盖 ClientRuntimeAgent:yaml 原文覆盖式(区别于 okng/qm 的 properties 合并),
避免解析嵌套结构,保留注释和顺序
- vbs 静默重启,config-version.txt 由 vbs 在启动新 Java 前写入,避免死循环
- App.java 调用 ClientRuntimeAgent.start 去掉 type 参数
- 协议 v3:MesMessageDecoder 加 AQDW/MQDW = 99 case;MesClientHandler.getResult
按 msgType 分支处理偏移(AQDW/MQDW 用 75-77,其他保持 74-76)
- 切换工位号 UI 已存在(switchStationItem + ProjectConfigManager),未重复搬入

wangxichen 1 일 전
부모
커밋
b0603faef4
5개의 변경된 파일252개의 추가작업 그리고 9개의 파일을 삭제
  1. 3 1
      src/com/mes/App.java
  2. 15 6
      src/com/mes/tcp/MesClientHandler.java
  3. 13 2
      src/com/mes/tcp/MesMessageDecoder.java
  4. 207 0
      src/com/mes/util/ClientRuntimeAgent.java
  5. 14 0
      src/com/mes/util/ClientType.java

+ 3 - 1
src/com/mes/App.java

@@ -66,6 +66,8 @@ public class App {
             log.info("  - 设备: {} ({})", config.isDeviceEnabled() ? "启用" : "禁用", config.getDeviceType());
             log.info("  - 当前项目: {} ({})", projectConfig.getCurrentProjectId(),
                     projectConfig.getCurrentProject() != null ? projectConfig.getCurrentProject().getName() : "?");
+            // 客户端类型由 ClientType.CODE 硬编码(此处 RIVETING),不再由此处传入
+            com.mes.util.ClientRuntimeAgent.start(config.getServerIp(), config.getStationCode(0), config.getLineSn(), "com.mes.App");
         } catch (Exception e) {
             log.error("配置加载失败: {}", e.getMessage(), e);
             JOptionPane.showMessageDialog(null,
@@ -86,7 +88,7 @@ public class App {
                 LoginFrame loginFrame = new LoginFrame(mainFrame);
                 loginFrame.setVisible(true);
 
-            } catch (Exception e) {
+        } catch (Exception e) {
                 log.error("启动失败: {}", e.getMessage(), e);
                 JOptionPane.showMessageDialog(null,
                         "程序启动失败: " + e.getMessage(),

+ 15 - 6
src/com/mes/tcp/MesClientHandler.java

@@ -118,15 +118,24 @@ public class MesClientHandler extends ChannelInboundHandlerAdapter {
     }
 
     /**
-     * 获取结果码
-     * 结果码位于固定位置74-76(v2 协议 oprno 扩到 8 后整体后移 2)
+     * 获取结果码。
+     * - v2 协议报文(长度 98):位置 74-76
+     * - v3 协议报文(AQDW/MQDW,长度 99):lx 从 2 位扩到 3 位,result 后移 1,位置 75-77
      */
     private String getResult(String msg) {
-        // 固定位置解析(v2 位置 74-76)
-        if (msg.length() >= 76) {
-            return msg.substring(74, 76).trim();
+        String msgType = getMsgType(msg);
+        // v3 扩展报文
+        if ("AQDW".equals(msgType) || "MQDW".equals(msgType)) {
+            if (msg.length() >= 77) {
+                return msg.substring(75, 77).trim();
+            }
+        } else {
+            // v2 或其他报文
+            if (msg.length() >= 76) {
+                return msg.substring(74, 76).trim();
+            }
         }
-        // 备用方案:查找RS字段
+        // 备用方案:查找 RS 字段
         int rsIndex = msg.indexOf("RS");
         if (rsIndex >= 0 && msg.length() >= rsIndex + 4) {
             return msg.substring(rsIndex + 2, rsIndex + 4).trim();

+ 13 - 2
src/com/mes/tcp/MesMessageDecoder.java

@@ -20,6 +20,9 @@ public class MesMessageDecoder extends ByteToMessageDecoder {
     private static final int PACKET_SIZE = 46;       // 最短包长度(与 okng 对齐)
     private static final int PACKET_MAX_SIZE = 1000; // 最长包长度
     private static final int FIXED_LENGTH = 98;      // 标准报文长度(v2 oprno 扩到 8 后 +2)
+    // v3(2026-07-13):AQDW / MQDW 报文 lx 字段从 2 位扩到 3 位(含 INSPXX),长度 +1
+    private static final int AQDW_LEN = 99;
+    private static final int MQDW_LEN = 99;
     private static final String SPLIT = "bbbbfffffARW";  // 报文分隔符
 
     // 用来临时保留没有处理过的请求报文
@@ -132,11 +135,19 @@ public class MesMessageDecoder extends ByteToMessageDecoder {
                     tpsize = 96;
                 }
                 break;
+            case "AQDW": // v3 扩展:lx 字段 3 位,长度 99
+                if (str.length() >= AQDW_LEN) {
+                    tpsize = AQDW_LEN;
+                }
+                break;
+            case "MQDW":
+                if (str.length() >= MQDW_LEN) {
+                    tpsize = MQDW_LEN;
+                }
+                break;
             case "MCJW":
-            case "AQDW":
             case "MBDW":
             case "MJBW":
-            case "MQDW":
             case "VLDW":
             default:
                 if (str.length() >= FIXED_LENGTH) {

+ 207 - 0
src/com/mes/util/ClientRuntimeAgent.java

@@ -0,0 +1,207 @@
+package com.mes.util;
+
+import com.alibaba.fastjson2.JSONObject;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.util.concurrent.*;
+
+/**
+ * op170(拉铆通用客户端)运行状态代理。
+ * 跟 okng/qm 的区别:
+ * - 类型 = ClientType.CODE = "RIVETING"(硬编码自报)
+ * - 本地配置文件是 config/station.yaml(YAML 嵌套结构)
+ * - 应用下发配置时不解析结构,整体覆盖 station.yaml(备份 .bak),保留注释和顺序
+ * - clientId 由服务端根据 (类型, IP) 决定,客户端不上报
+ */
+public final class ClientRuntimeAgent {
+    private static final ScheduledExecutorService EXEC = Executors.newSingleThreadScheduledExecutor(r -> {
+        Thread t = new Thread(r, "client-runtime-agent");
+        t.setDaemon(true);
+        return t;
+    });
+    private static volatile boolean updating;
+
+    private ClientRuntimeAgent() {}
+
+    public static void start(String serverIp, String station, String line) {
+        start(serverIp, station, line, "com.mes.App");
+    }
+
+    public static void start(final String serverIp, final String station, final String line, final String mainClass) {
+        if (serverIp == null || serverIp.trim().isEmpty()) return;
+        send(serverIp, station, line, mainClass);
+        EXEC.scheduleAtFixedRate(() -> send(serverIp, station, line, mainClass), 30, 30, TimeUnit.SECONDS);
+    }
+
+    private static void send(String serverIp, String station, String line, String mainClass) {
+        String type = ClientType.CODE;
+        try {
+            String body = "clientType=" + enc(type)
+                    + "&stationCode=" + enc(station)
+                    + "&lineSn=" + enc(line)
+                    + "&jarVersion=" + version()
+                    + "&configVersion=" + configVersion()
+                    + "&status=RUNNING"
+                    + "&statusMessage=" + enc("客户端运行中")
+                    + "&currentConfigJson=" + enc(readLocalConfig());
+            HttpURLConnection c = (HttpURLConnection) new URL("http://" + serverIp + ":8980/js/a/mes/clientRuntime/heartbeat").openConnection();
+            c.setRequestMethod("POST");
+            c.setConnectTimeout(5000);
+            c.setReadTimeout(10000);
+            c.setDoOutput(true);
+            c.setRequestProperty("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+            c.setFixedLengthStreamingMode(bytes.length);
+            try (OutputStream out = c.getOutputStream()) { out.write(bytes); }
+            int code = c.getResponseCode();
+            System.out.println("[ClientRuntime] heartbeat type=" + type + " code=" + code);
+            if (code == 200) {
+                String resp = read(c.getInputStream());
+                JSONObject data = JSONObject.parseObject(resp).getJSONObject("data");
+                if (data != null) {
+                    Long cv = data.getLong("desiredConfigVersion");
+                    String cfg = data.getString("desiredConfigJson");
+                    if (cv != null && cfg != null && !cfg.trim().isEmpty() && cv > configVersion()) {
+                        applyConfig(cv, cfg, mainClass);
+                        return;
+                    }
+                    Long jv = data.getLong("desiredJarVersion");
+                    if (jv != null && jv > version() && !updating) update(serverIp, type, jv, mainClass);
+                }
+            } else {
+                System.err.println("[ClientRuntime] heartbeat rejected code=" + code);
+            }
+            c.disconnect();
+        } catch (Exception e) {
+            System.err.println("[ClientRuntime] heartbeat/update failed: " + e.getMessage());
+        }
+    }
+
+    /** 读本地 config/station.yaml 原文;文件不存在返回空串 */
+    private static String readLocalConfig() {
+        Path p = Paths.get(ClientType.CONFIG_FILE);
+        if (!Files.exists(p)) return "";
+        try {
+            return new String(Files.readAllBytes(p), StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            return "";
+        }
+    }
+
+    /**
+     * 应用下发配置:整体覆盖 station.yaml(备份 .bak),保留 yaml 格式/注释/顺序,然后重启生效。
+     */
+    private static void applyConfig(long ver, String cfg, String mainClass) throws Exception {
+        if (cfg == null) return;
+        Path pfile = Paths.get(ClientType.CONFIG_FILE);
+        Path parent = pfile.getParent();
+        if (parent != null) Files.createDirectories(parent);
+        if (Files.exists(pfile)) {
+            Path bak = pfile.resolveSibling(pfile.getFileName().toString() + ".bak");
+            Files.copy(pfile, bak, StandardCopyOption.REPLACE_EXISTING);
+        }
+        Files.write(pfile, cfg.getBytes(StandardCharsets.UTF_8));
+        // config-version.txt 不在这里写,交给 restartSelf 的 vbs 在启动新 Java 前写,
+        // 避免"版本号更新但重启失败"的死循环
+        System.out.println("[ClientRuntime] config replaced v=" + ver + ", restarting");
+        restartSelf(mainClass, ver);
+    }
+
+    /** 触发升级:下载新 jar → 备份 → 替换 → 重启 */
+    private static void update(String serverIp, String type, long target, String mainClass) throws Exception {
+        updating = true;
+        JSONObject root = JSONObject.parseObject(read(new URL("http://" + serverIp + ":8980/js/a/mes/clientVersion/ver?clientType=" + enc(type) + "&version=" + target).openStream()));
+        JSONObject data = root.getJSONObject("data");
+        if (data == null) throw new IOException("target version unavailable");
+        File jar = currentJar();
+        File download = new File(jar.getParentFile(), jar.getName() + ".download");
+        download(data.getString("path"), download);
+        if (download.length() == 0) throw new IOException("empty download");
+        Files.write(Paths.get("config", "client-version.txt"), String.valueOf(target).getBytes(StandardCharsets.UTF_8));
+
+        File vbs = new File(jar.getParentFile(), "client-update-" + System.currentTimeMillis() + ".vbs");
+        String jarPath = jar.getAbsolutePath();
+        String bakPath = jarPath + ".bak";
+        String downloadPath = download.getAbsolutePath();
+        String libGlob = new File(jar.getParentFile(), "lib").getAbsolutePath() + File.separator + "*";
+        String workDir = jar.getParentFile().getAbsolutePath();
+        try (Writer w = new OutputStreamWriter(new FileOutputStream(vbs), StandardCharsets.UTF_8)) {
+            w.write(
+                "Set sh = CreateObject(\"WScript.Shell\")\r\n" +
+                "Set fso = CreateObject(\"Scripting.FileSystemObject\")\r\n" +
+                "WScript.Sleep 2000\r\n" +
+                "If fso.FileExists(\"" + jarPath + "\") Then fso.CopyFile \"" + jarPath + "\", \"" + bakPath + "\", True\r\n" +
+                "fso.MoveFile \"" + downloadPath + "\", \"" + jarPath + "\"\r\n" +
+                "sh.CurrentDirectory = \"" + workDir + "\"\r\n" +
+                "sh.Run \"javaw -cp \"\"" + jarPath + ";" + libGlob + "\"\" " + mainClass + "\", 0, False\r\n" +
+                "fso.DeleteFile WScript.ScriptFullName\r\n"
+            );
+        }
+        new ProcessBuilder("wscript.exe", vbs.getAbsolutePath()).start();
+        System.exit(0);
+    }
+
+    /** 仅重启当前 jar(配置生效用);vbs 在启动新 Java 前把 verToWrite 写入 config-version.txt */
+    private static void restartSelf(String mainClass, long verToWrite) throws Exception {
+        File jar = currentJar();
+        File vbs = new File(jar.getParentFile(), "client-restart-" + System.currentTimeMillis() + ".vbs");
+        String jarPath = jar.getAbsolutePath();
+        String libGlob = new File(jar.getParentFile(), "lib").getAbsolutePath() + File.separator + "*";
+        String workDir = jar.getParentFile().getAbsolutePath();
+        String verFile = new File(new File(workDir, "config"), "config-version.txt").getAbsolutePath();
+        try (Writer w = new OutputStreamWriter(new FileOutputStream(vbs), StandardCharsets.UTF_8)) {
+            w.write(
+                "Set sh = CreateObject(\"WScript.Shell\")\r\n" +
+                "Set fso = CreateObject(\"Scripting.FileSystemObject\")\r\n" +
+                "WScript.Sleep 2000\r\n" +
+                "Set f = fso.CreateTextFile(\"" + verFile + "\", True)\r\n" +
+                "f.Write \"" + verToWrite + "\"\r\n" +
+                "f.Close\r\n" +
+                "sh.CurrentDirectory = \"" + workDir + "\"\r\n" +
+                "sh.Run \"javaw -cp \"\"" + jarPath + ";" + libGlob + "\"\" " + mainClass + "\", 0, False\r\n" +
+                "fso.DeleteFile WScript.ScriptFullName\r\n"
+            );
+        }
+        new ProcessBuilder("wscript.exe", vbs.getAbsolutePath()).start();
+        System.exit(0);
+    }
+
+    private static void download(String u, File f) throws IOException {
+        HttpURLConnection c = (HttpURLConnection) new URL(u).openConnection();
+        c.setConnectTimeout(10000);
+        c.setReadTimeout(120000);
+        if (c.getResponseCode() != 200) throw new IOException("download failed");
+        try (InputStream in = c.getInputStream(); OutputStream out = new FileOutputStream(f)) {
+            byte[] b = new byte[8192];
+            int n;
+            while ((n = in.read(b)) != -1) out.write(b, 0, n);
+        } finally { c.disconnect(); }
+    }
+
+    private static File currentJar() throws Exception {
+        File f = new File(ClientRuntimeAgent.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+        if (!f.isFile()) throw new IOException("not running from jar");
+        return f;
+    }
+
+    private static long version() { return readLong("client-version.txt", 1); }
+    private static long configVersion() { return readLong("config-version.txt", 0); }
+    private static long readLong(String n, long d) {
+        try { return Long.parseLong(new String(Files.readAllBytes(Paths.get("config", n)), StandardCharsets.UTF_8).trim()); }
+        catch (Exception e) { return d; }
+    }
+    private static String enc(String s) {
+        try { return URLEncoder.encode(s == null ? "" : s, "UTF-8"); } catch (Exception e) { return ""; }
+    }
+    private static String read(InputStream in) throws IOException {
+        try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+            StringBuilder b = new StringBuilder();
+            String l;
+            while ((l = r.readLine()) != null) b.append(l);
+            return b.toString();
+        }
+    }
+}

+ 14 - 0
src/com/mes/util/ClientType.java

@@ -0,0 +1,14 @@
+package com.mes.util;
+
+/**
+ * 客户端类型常量。
+ *   mesclient-okng     → MANUAL
+ *   mesclient-qm       → QIMI
+ *   mesclient-op170    → RIVETING
+ */
+public final class ClientType {
+    public static final String CODE = "RIVETING";
+    /** op170 本地配置文件路径(yaml 格式,与 okng/qm 的 properties 不同) */
+    public static final String CONFIG_FILE = "config/station.yaml";
+    private ClientType() {}
+}