Przeglądaj źródła

feat(runtime): 客户端类型硬编码自报与本地配置远程接管;同步 INSPXXX 质量校验协议
- 参考 mesclient-okng(53a57d2、3f6496a、a20eea3)
- 新增 ClientType.CODE=QIMI,心跳时由 JAR 自报,clientId 服务端拼装
- 覆盖 ClientRuntimeAgent 为 vbs 静默重启版本,config.properties 合并式下发
- 心跳上报本地 config.properties 内容(currentConfigJson),支持服务端可视化编辑
- 协议 v3:AQDW/MQDW 长度 99,lx 位置 72-75 (3 位) 支持 INSPXXX 前道工位号
- ErrorMsg 加 getOprnoName + CS/AL 分支,保留气密特有的 QR(连续 NG 间隔)
- MesClient.main 调用 ClientRuntimeAgent.start 改成 3 参数

wangxichen 8 godzin temu
rodzic
commit
db4c9c26dd

+ 17 - 3
src/com/mes/netty/MesMsgUtils.java

@@ -7,10 +7,10 @@ public class MesMsgUtils {
     public static int AXTW_LEN = 48;
     public static int ACLW_LEN = 48;
     public static int MCJW_LEN = 98;
-    public static int AQDW_LEN = 98;
+    public static int AQDW_LEN = 99;
     public static int MBDW_LEN = 98;
     public static int MJBW_LEN = 98;
-    public static int MQDW_LEN = 98;
+    public static int MQDW_LEN = 99;
     public static int MKSW_LEN = 96;
     public static int MSBW_LEN = 96;
     public static int MCSW_LEN = 96;
@@ -51,13 +51,27 @@ public class MesMsgUtils {
                     ret = 2;
                 }
                 break;
-            default:
+            case "AQDW":
                 if(len==AQDW_LEN) {
                     ret = 0;
                 }else {
                     ret = 2;
                 }
                 break;
+            case "MQDW":
+                if(len==MQDW_LEN) {
+                    ret = 0;
+                }else {
+                    ret = 2;
+                }
+                break;
+            default:
+                if(len==MCJW_LEN) {
+                    ret = 0;
+                }else {
+                    ret = 2;
+                }
+                break;
         }
         return ret;
     }

+ 10 - 14
src/com/mes/netty/ProtocolParam.java

@@ -1,19 +1,15 @@
 package com.mes.netty;
 
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 // 固定格式报文各参数获取方法
 // 协议 v2(2026-06-29 修订):oprno 字段从 6 字符扩到 8 字符,后续字段偏移整体 +2
 public class ProtocolParam {
-    public static final Logger log =  LoggerFactory.getLogger(ProtocolParam.class);
     // bbbbfffffARWAQDWGWOP100   GY100000ID151245P00000106200123062900001      RSOKDA2023-09-07ZT10:16:58
-    public static Integer fixedLength = 98; // 固定长度(v1=96,v2 扩 oprno 后 +2)
+    public static Integer fixedLength = 99; // 固定长度(响应报文,v1=96,v2 扩 oprno 后 +2)
 
     // 获取消息类型  所有报文都可使用
     public static String getMsgType(String msg){
-        log.info(msg);
+        System.out.print(msg);
         if(msg.length() < 16){
             return "";
         }
@@ -45,34 +41,34 @@ public class ProtocolParam {
     }
 
     public static String getLx(String msg){
-        if(msg.length() < 74){
+        if(msg.length() < 75){
             return "";
         }
-        return msg.substring(72,74);
+        return msg.substring(72,75).trim();
     }
 
     // 获取结果
     public static String getResult(String msg){
-        if(msg.length() < 76){
+        if(msg.length() < 77){
             return "";
         }
-        return msg.substring(74,76);
+        return msg.substring(75,77);
     }
 
     // 获取日期
     public static String getDay(String msg){
-        if(msg.length() < 88){
+        if(msg.length() < 89){
             return "";
         }
-        return msg.substring(78,88);
+        return msg.substring(79,89);
     }
 
     // 获取时间
     public static String getTime(String msg){
-        if(msg.length() < 98){
+        if(msg.length() < 99){
             return "";
         }
-        return msg.substring(90,98);
+        return msg.substring(91,99);
     }
 
 }

+ 19 - 15
src/com/mes/netty/XDecoder.java

@@ -5,14 +5,10 @@ import io.netty.buffer.ByteBufUtil;
 import io.netty.buffer.Unpooled;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.handler.codec.ByteToMessageDecoder;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import java.util.List;
 
 public class XDecoder extends ByteToMessageDecoder {
-
-    public static final Logger log =  LoggerFactory.getLogger(XDecoder.class);
     static final int PACKET_SIZE = 46; // 最短包长度
     static final int PACKET_MAX_SIZE = 1000; // 最长包长度
 
@@ -27,9 +23,9 @@ public class XDecoder extends ByteToMessageDecoder {
      */
     @Override
     protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
-        log.info(Thread.currentThread() + "收到了一次数据包,长度是:" + in.readableBytes());
+        System.out.println(Thread.currentThread() + "收到了一次数据包,长度是:" + in.readableBytes());
         String content = hexStringToAscii(ByteBufUtil.hexDump(in));
-        log.info("接收包内容:" + hexStringToAscii(ByteBufUtil.hexDump(in)));
+        System.out.println("接收包内容:" + hexStringToAscii(ByteBufUtil.hexDump(in)));
 
         // 合并报文
         ByteBuf message = null;
@@ -39,13 +35,13 @@ public class XDecoder extends ByteToMessageDecoder {
             message = Unpooled.buffer();
             message.writeBytes(tempMsg);
             message.writeBytes(in);
-            log.info("合并:上一数据包余下的长度为:" + tmpMsgSize + ",合并后长度为:" + message.readableBytes());
-            log.info("合并后包内容:" + hexStringToAscii(ByteBufUtil.hexDump(message)));
+            System.out.println("合并:上一数据包余下的长度为:" + tmpMsgSize + ",合并后长度为:" + message.readableBytes());
+            System.out.println("合并后包内容:" + hexStringToAscii(ByteBufUtil.hexDump(message)));
         } else {
             message = in;
         }
 
-//        log.info(Thread.currentThread() + "收到了一次数据包,长度是:" + message.readableBytes());
+//        System.out.println(Thread.currentThread() + "收到了一次数据包,长度是:" + message.readableBytes());
         int size = 0;
         String split = "bbbbfffffARW";
 
@@ -112,7 +108,7 @@ public class XDecoder extends ByteToMessageDecoder {
         // 第二个报文: 1 与第一次
         size = message.readableBytes();
         if (size != 0) {
-            log.info("多余的数据长度:" + size);
+            System.out.println("多余的数据长度:" + size);
             // 剩下来的数据放到tempMsg暂存
             tempMsg.clear();
             tempMsg.writeBytes(message.readBytes(size));
@@ -139,18 +135,26 @@ public class XDecoder extends ByteToMessageDecoder {
                     tpsize = MesMsgUtils.ACLW_LEN;
                 }
                 break;
-            case "MCJW":
             case "AQDW":
+                if(str.length() >= MesMsgUtils.AQDW_LEN){
+                    tpsize = MesMsgUtils.AQDW_LEN;
+                }
+                break;
+            case "MQDW":
+                if(str.length() >= MesMsgUtils.MQDW_LEN){
+                    tpsize = MesMsgUtils.MQDW_LEN;
+                }
+                break;
+            case "MCJW":
             case "MBDW":
             case "MJBW":
-            case "MQDW":
             case "MKSW":
             case "MSBW":
             case "MCSW":
             case "AQRW":
-            default: // 默认新报文
-                if(str.length() >= ProtocolParam.fixedLength){ // 大于固定长度
-                    tpsize = ProtocolParam.fixedLength;
+            default: // default new packet
+                if(str.length() >= MesMsgUtils.MCJW_LEN){
+                    tpsize = MesMsgUtils.MCJW_LEN;
                 }
                 break;
         }

+ 106 - 2
src/com/mes/ui/MesClient.java

@@ -133,6 +133,8 @@ public class MesClient extends JFrame {
                 try{
                     //读文件配置
                     readProperty();
+                    // 客户端类型由 ClientType.CODE 硬编码(此处 QIMI),不再由此处传入
+                    ClientRuntimeAgent.start(mes_server_ip, mes_gw, mes_line_sn);
 
                     // 显示界面
                     mesClientFrame = new MesClient();
@@ -338,6 +340,8 @@ public class MesClient extends JFrame {
     public static JLabel result;
     public static JTextField programNo;
     public static JTextField serialPort;
+    public static JButton serialPortSelectBtn;
+    public static JButton serialPortTestBtn;
     public static JTextField baudRate;
     public static JTextField printName;
     public static JTextField labelName;
@@ -362,9 +366,80 @@ public class MesClient extends JFrame {
     public static JTextField oprnoTitle;
     public static JTextField lineSn;
 
+    /**
+     * 测试当前配置界面上填写的串口参数是否可用
+     * 若主串口正在使用同一端口,会临时关闭再恢复
+     */
+    public static void testSerialPort() {
+        String portName = serialPort.getText().trim();
+        if (portName.isEmpty()) {
+            JOptionPane.showMessageDialog(mesClientFrame, "请先填写串口号");
+            return;
+        }
+        int baud;
+        try { baud = Integer.parseInt(baudRate.getText().trim()); }
+        catch (NumberFormatException ex) {
+            JOptionPane.showMessageDialog(mesClientFrame, "波特率格式错误:" + baudRate.getText());
+            return;
+        }
+        int db;
+        try { db = Integer.parseInt(dataBits.getSelectedItem().toString()); }
+        catch (Exception ex) { db = 8; }
+        String sbStr = stopBit.getSelectedItem().toString();
+        String pStr = checkBit.getSelectedItem().toString();
+
+        // 若主串口在用同一端口,先临时关掉,测完再恢复
+        boolean needRestore = serialPortUtils != null && serialPortUtils.isOpen
+                && portName.equalsIgnoreCase(serialPortUtils.portName);
+        if (needRestore) {
+            serialPortUtils.closePort();
+        }
+
+        SerialDiagnosticUtil.TestResult r =
+                SerialDiagnosticUtil.testConnection(portName, baud, db, sbStr, pStr, 1500);
+
+        if (needRestore) {
+            try { Thread.sleep(200); } catch (InterruptedException ignore) {}
+            serialPortUtils.openPort();
+        }
+
+        // 组装结果消息
+        StringBuilder sb = new StringBuilder();
+        sb.append("端口: ").append(portName);
+        if (r.description != null && !r.description.isEmpty()) {
+            sb.append("  (").append(r.description).append(")");
+        }
+        sb.append("\n");
+        if (r.chipHint != null && !r.chipHint.isEmpty()) {
+            sb.append("推测芯片: ").append(r.chipHint).append("\n");
+        }
+        sb.append("参数: ").append(baud).append(", ").append(db)
+          .append(", ").append(pStr).append(", ").append(sbStr).append("\n\n");
+
+        if (!r.opened) {
+            sb.append("[失败] 端口打开失败\n").append(r.errorMsg == null ? "" : r.errorMsg);
+            JOptionPane.showMessageDialog(mesClientFrame, sb.toString(), "测试失败", JOptionPane.ERROR_MESSAGE);
+            return;
+        }
+        sb.append("[OK] 端口打开成功\n\n");
+        if (r.received) {
+            sb.append("[OK] 收到设备数据(").append(r.elapsedMs).append(" ms)\n");
+            sb.append("内容预览: ").append(r.response == null ? "" : r.response);
+        } else {
+            sb.append("[提示] 端口能打开但未收到数据\n可能原因:\n")
+              .append("· 设备未上电或不主动上报\n")
+              .append("· 波特率/校验位不匹配\n")
+              .append("· RS232 接线 TX/RX 反了\n")
+              .append("· 设备需先发指令才回复(此项目对 innomatec 是被动接收,对 ateq 是主动查询)");
+        }
+        JOptionPane.showMessageDialog(mesClientFrame, sb.toString(), "测试结果", JOptionPane.INFORMATION_MESSAGE);
+    }
+
     public static void formatConfigEdit(Boolean ret){
         programNo.setEnabled(ret);
         serialPort.setEnabled(ret);
+        if (serialPortSelectBtn != null) serialPortSelectBtn.setEnabled(ret);
+        if (serialPortTestBtn != null) serialPortTestBtn.setEnabled(ret);
         startMode.setEnabled(ret);
         baudRate.setEnabled(ret);
         printName.setEnabled(ret);
@@ -1225,9 +1300,38 @@ public class MesClient extends JFrame {
         serialPort = new JTextField();
         serialPort.setFont(new Font("微软雅黑", Font.PLAIN, 16));
         serialPort.setColumns(10);
-        serialPort.setBounds(140, 123, 200, 30);
+        serialPort.setBounds(140, 123, 100, 30);
         indexPanelD.add(serialPort);
-        
+
+        // 串口选择按钮:弹对话框列出所有 COM 口,支持拔插自动识别
+        serialPortSelectBtn = new JButton("选择");
+        serialPortSelectBtn.setFont(new Font("微软雅黑", Font.PLAIN, 14));
+        serialPortSelectBtn.setBounds(245, 123, 60, 30);
+        serialPortSelectBtn.setToolTipText("列出所有可用串口,支持拔插自动识别");
+        indexPanelD.add(serialPortSelectBtn);
+        serialPortSelectBtn.addActionListener(new ActionListener() {
+            public void actionPerformed(ActionEvent e) {
+                SerialSelectDialog dlg = new SerialSelectDialog(mesClientFrame);
+                dlg.setVisible(true);
+                String picked = dlg.getSelectedPortName();
+                if (picked != null && !picked.isEmpty()) {
+                    serialPort.setText(picked);
+                }
+            }
+        });
+
+        // 串口测试按钮:按当前配置的波特率/数据位/校验位测试端口连通性
+        serialPortTestBtn = new JButton("测试");
+        serialPortTestBtn.setFont(new Font("微软雅黑", Font.PLAIN, 14));
+        serialPortTestBtn.setBounds(310, 123, 60, 30);
+        serialPortTestBtn.setToolTipText("按当前波特率/数据位/校验位测试端口连通性");
+        indexPanelD.add(serialPortTestBtn);
+        serialPortTestBtn.addActionListener(new ActionListener() {
+            public void actionPerformed(ActionEvent e) {
+                testSerialPort();
+            }
+        });
+
         JLabel lblNewLabel_3_1_1_1 = new JLabel("波特率");
         lblNewLabel_3_1_1_1.setHorizontalAlignment(SwingConstants.RIGHT);
         lblNewLabel_3_1_1_1.setFont(new Font("微软雅黑", Font.PLAIN, 16));

+ 155 - 0
src/com/mes/ui/SerialSelectDialog.java

@@ -0,0 +1,155 @@
+package com.mes.ui;
+
+import com.mes.util.SerialDiagnosticUtil;
+import com.mes.util.SerialDiagnosticUtil.PortInfo;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 串口选择对话框:列出所有 COM 口 + 刷新 + 拔插自动识别
+ */
+public class SerialSelectDialog extends JDialog {
+
+    private final DefaultListModel<PortInfo> model = new DefaultListModel<>();
+    private final JList<PortInfo> list = new JList<>(model);
+    private String selectedPortName;
+
+    public SerialSelectDialog(Frame owner) {
+        super(owner, "选择串口", true);
+        setLayout(new BorderLayout(8, 8));
+        ((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+        // 顶部提示
+        JLabel tip = new JLabel("当前可用串口(含描述,方便识别是哪个设备):");
+        tip.setFont(new Font("微软雅黑", Font.PLAIN, 14));
+        add(tip, BorderLayout.NORTH);
+
+        // 中部列表
+        list.setFont(new Font("微软雅黑", Font.PLAIN, 14));
+        list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+        list.addMouseListener(new MouseAdapter() {
+            @Override
+            public void mouseClicked(MouseEvent e) {
+                if (e.getClickCount() == 2) confirm();
+            }
+        });
+        add(new JScrollPane(list), BorderLayout.CENTER);
+
+        // 底部按钮区
+        JPanel south = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
+        JButton refreshBtn = new JButton("刷新");
+        JButton detectBtn = new JButton("拔插识别");
+        JButton okBtn = new JButton("确定");
+        JButton cancelBtn = new JButton("取消");
+        Font btnFont = new Font("微软雅黑", Font.PLAIN, 14);
+        refreshBtn.setFont(btnFont);
+        detectBtn.setFont(btnFont);
+        okBtn.setFont(btnFont);
+        cancelBtn.setFont(btnFont);
+        south.add(refreshBtn);
+        south.add(detectBtn);
+        south.add(okBtn);
+        south.add(cancelBtn);
+        add(south, BorderLayout.SOUTH);
+
+        refreshBtn.addActionListener(e -> reload());
+        detectBtn.addActionListener(e -> autoDetect());
+        okBtn.addActionListener(e -> confirm());
+        cancelBtn.addActionListener(e -> {
+            selectedPortName = null;
+            dispose();
+        });
+
+        setSize(560, 360);
+        setLocationRelativeTo(owner);
+        reload();
+    }
+
+    /** 刷新端口列表 */
+    private void reload() {
+        model.clear();
+        List<PortInfo> ports = SerialDiagnosticUtil.listPorts();
+        if (ports.isEmpty()) {
+            JOptionPane.showMessageDialog(this,
+                    "未检测到任何串口。请检查:\n" +
+                    "1. USB 线是否插好\n" +
+                    "2. 设备管理器里驱动是否安装(有黄色感叹号则驱动未装)",
+                    "提示", JOptionPane.WARNING_MESSAGE);
+            return;
+        }
+        for (PortInfo p : ports) model.addElement(p);
+        list.setSelectedIndex(0);
+    }
+
+    /** 拔插识别:拔前快照 → 插后快照 → 新增即为目标 */
+    private void autoDetect() {
+        int r1 = JOptionPane.showConfirmDialog(this,
+                "识别步骤 1/2:\n请【拔掉】USB 转串口线\n拔掉后点【确定】继续",
+                "自动识别", JOptionPane.OK_CANCEL_OPTION);
+        if (r1 != JOptionPane.OK_OPTION) return;
+        Set<String> baseline = snapshot();
+
+        int r2 = JOptionPane.showConfirmDialog(this,
+                "识别步骤 2/2:\n请【插回】USB 转串口线\n插回并等 2 秒后点【确定】继续",
+                "自动识别", JOptionPane.OK_CANCEL_OPTION);
+        if (r2 != JOptionPane.OK_OPTION) return;
+
+        // 找新增端口
+        List<PortInfo> ports = SerialDiagnosticUtil.listPorts();
+        PortInfo target = null;
+        for (PortInfo p : ports) {
+            if (!baseline.contains(p.name)) { target = p; break; }
+        }
+
+        reload();
+
+        if (target == null) {
+            JOptionPane.showMessageDialog(this,
+                    "没有检测到新增端口。可能原因:\n" +
+                    "1. 拔线时端口没消失(可能不是通过 USB 转串口)\n" +
+                    "2. 插回后 Windows 还在识别,稍等几秒再重试\n" +
+                    "3. 驱动未正确安装",
+                    "识别失败", JOptionPane.WARNING_MESSAGE);
+            return;
+        }
+
+        // 在列表中选中
+        for (int i = 0; i < model.size(); i++) {
+            if (model.get(i).name.equals(target.name)) {
+                list.setSelectedIndex(i);
+                list.ensureIndexIsVisible(i);
+                break;
+            }
+        }
+        JOptionPane.showMessageDialog(this,
+                "识别成功!新插入的端口:\n" + target,
+                "识别成功", JOptionPane.INFORMATION_MESSAGE);
+    }
+
+    private Set<String> snapshot() {
+        Set<String> s = new HashSet<>();
+        for (PortInfo p : SerialDiagnosticUtil.listPorts()) s.add(p.name);
+        return s;
+    }
+
+    private void confirm() {
+        PortInfo p = list.getSelectedValue();
+        if (p == null) {
+            JOptionPane.showMessageDialog(this, "请先选中一个端口");
+            return;
+        }
+        selectedPortName = p.name;
+        dispose();
+    }
+
+    /** 用户确认返回选中的端口名,取消返回 null */
+    public String getSelectedPortName() {
+        return selectedPortName;
+    }
+}

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

@@ -0,0 +1,235 @@
+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.Properties;
+import java.util.concurrent.*;
+
+/**
+ * 客户端运行状态代理。
+ * - 类型由 ClientType.CODE 硬编码自报
+ * - clientId 由服务端根据 (类型, IP) 决定
+ * - 心跳上报本地 config.properties 当前内容(currentConfigJson)
+ * - 服务端下发 desiredConfigJson 直接合并写回 config.properties(备份 .bak)→ 重启生效
+ * - 服务端下发 desiredJarVersion 与本地不一致 → 下载对应 jar → 备份 .bak → 替换 → 重启
+ */
+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.ui.MesClient");
+    }
+
+    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(readLocalConfigAsJson());
+            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());
+                System.out.println("[ClientRuntime] response=" + resp);
+                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; // applyConfig 会重启,不用继续处理 jar
+                    }
+                    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/config.properties 转为 JSON 字符串
+    private static String readLocalConfigAsJson() {
+        Path p = Paths.get("config", "config.properties");
+        if (!Files.exists(p)) return "";
+        try {
+            Properties props = new Properties();
+            try (InputStream in = Files.newInputStream(p)) { props.load(in); }
+            StringBuilder sb = new StringBuilder("{");
+            boolean first = true;
+            for (String key : props.stringPropertyNames()) {
+                if (!first) sb.append(",");
+                first = false;
+                sb.append('"').append(esc(key)).append("\":\"").append(esc(props.getProperty(key))).append('"');
+            }
+            sb.append('}');
+            return sb.toString();
+        } catch (Exception e) {
+            return "";
+        }
+    }
+
+    /**
+     * 应用服务端下发的配置:合并到 config.properties(保留未下发的 key),备份 .bak,然后重启生效。
+     */
+    private static void applyConfig(long ver, String cfg, String mainClass) throws Exception {
+        JSONObject json;
+        try { json = JSONObject.parseObject(cfg); } catch (Exception e) { System.err.println("[ClientRuntime] invalid config json"); return; }
+        if (json == null || json.isEmpty()) return;
+
+        Path dir = Paths.get("config");
+        Files.createDirectories(dir);
+        Path pfile = dir.resolve("config.properties");
+        Properties props = new Properties();
+        if (Files.exists(pfile)) {
+            try (InputStream in = Files.newInputStream(pfile)) { props.load(in); }
+            Files.copy(pfile, dir.resolve("config.properties.bak"), StandardCopyOption.REPLACE_EXISTING);
+        }
+        // 合并:JSON 里的 key 覆盖到 properties
+        for (String key : json.keySet()) {
+            String val = json.getString(key);
+            if (val == null) continue;
+            props.setProperty(key, val);
+        }
+        try (OutputStream out = Files.newOutputStream(pfile)) {
+            props.store(out, "Updated by ClientRuntimeAgent " + new java.util.Date());
+        }
+        // 注意:config-version.txt 不在这里写,交给 restartSelf 的 vbs 在启动新 Java 前写
+        // 这样即使 restart 失败,version 也不会被更新,下次心跳仍会触发同一个 applyConfig
+        System.out.println("[ClientRuntime] config applied 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 esc(String s) {
+        if (s == null) return "";
+        return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
+    }
+    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();
+        }
+    }
+}

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

@@ -0,0 +1,13 @@
+package com.mes.util;
+
+/**
+ * 客户端类型常量。
+ * 每个客户端项目值不同,由 JAR 自报,不从本地配置读,防止串味。
+ *   mesclient-okng     → MANUAL
+ *   mesclient-qm       → QIMI
+ *   mesclient-op170    → RIVETING
+ */
+public final class ClientType {
+    public static final String CODE = "QIMI";
+    private ClientType() {}
+}

+ 23 - 3
src/com/mes/util/ErrorMsg.java

@@ -11,9 +11,9 @@ public class ErrorMsg {
             }else if(processMsgRet.equalsIgnoreCase("NN")) {
                 lmsg = "该工件跳过该工位";
             }else if(processMsgRet.equalsIgnoreCase("QN")) {
-                lmsg = "该工件OP"+ lx+"0加工NG";
+                lmsg = "该工件"+ getOprnoName(lx)+"加工NG";
             }else if(processMsgRet.equalsIgnoreCase("QD")) {
-                lmsg = "该工件OP"+ lx+"0未加工";
+                lmsg = "该工件"+ getOprnoName(lx)+"未加工";
             }else if(processMsgRet.equalsIgnoreCase("NF")) {
                 lmsg = "该工件已合格下线";
             }else if(processMsgRet.equalsIgnoreCase("NR")) {
@@ -26,6 +26,8 @@ public class ErrorMsg {
                 lmsg = "首件检查工件不合格停机";
             }else if(processMsgRet.equalsIgnoreCase("GN")) {
                 lmsg = "更换配件首件检查不合格停机";
+            }else if(processMsgRet.equalsIgnoreCase("CS")) {
+                lmsg = "首件检查两小时内未检查,超时停机";
             }else if(processMsgRet.equalsIgnoreCase("DJ")) {
                 lmsg = "未进行开班点检";
             }else if(processMsgRet.equalsIgnoreCase("BM")) {
@@ -35,14 +37,32 @@ public class ErrorMsg {
             }else if(processMsgRet.equalsIgnoreCase("QM")) {
                 lmsg = "两次气密必须间隔15分钟";
             }else if(processMsgRet.equalsIgnoreCase("QR")) {
-                // 连续NG后重测需间隔,具体分钟数由服务端控制(此处为通用提示
+                // qm 特有:连续两次NG后重测需间隔(服务端控制分钟数
                 lmsg = "连续两次NG,需间隔指定分钟后再测试";
             }else if(processMsgRet.equalsIgnoreCase("GS")) {
                 lmsg = "工件码格式不正确";
             }else if(processMsgRet.equalsIgnoreCase("CF")) {
                 lmsg = "工件码重复";
+            }else if(processMsgRet.equalsIgnoreCase("AL")) {
+                lmsg = "该工件存在未处理的报警信息";
             }
         }catch (Exception e){ }
         return lmsg;
     }
+
+    private static String getOprnoName(String lx) {
+        if (lx == null) {
+            return "OP";
+        }
+        lx = lx.trim();
+        if (lx.startsWith("I")) {
+            try {
+                int num = Integer.parseInt(lx.substring(1)) * 10;
+                return String.format("INSP%03d", num);
+            } catch (Exception e) {
+                return "INSP" + lx.substring(1);
+            }
+        }
+        return "OP" + lx + "0";
+    }
 }

+ 132 - 0
src/com/mes/util/SerialDiagnosticUtil.java

@@ -0,0 +1,132 @@
+package com.mes.util;
+
+import com.fazecast.jSerialComm.SerialPort;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 串口诊断工具:枚举端口、测试连通性、推测芯片型号
+ */
+public class SerialDiagnosticUtil {
+
+    /** 端口信息 */
+    public static class PortInfo {
+        public String name;         // COM7
+        public String description;  // USB Serial Port
+        public String chipHint;     // FTDI FT232 等推测
+
+        @Override
+        public String toString() {
+            String tail = (chipHint == null || chipHint.isEmpty()) ? "" : "  [" + chipHint + "]";
+            String desc = (description == null || description.isEmpty()) ? "" : "  " + description;
+            return name + desc + tail;
+        }
+    }
+
+    /** 测试结果 */
+    public static class TestResult {
+        public boolean opened;       // 端口是否成功打开
+        public boolean received;     // 是否收到数据
+        public String response;      // 收到内容(截取)
+        public long elapsedMs;       // 等待耗时
+        public String errorMsg;      // 错误信息
+        public String description;   // 端口描述
+        public String chipHint;      // 芯片提示
+    }
+
+    /** 枚举当前所有串口 */
+    public static List<PortInfo> listPorts() {
+        SerialPort[] ports = SerialPort.getCommPorts();
+        List<PortInfo> result = new ArrayList<>();
+        for (SerialPort p : ports) {
+            PortInfo info = new PortInfo();
+            info.name = p.getSystemPortName();
+            info.description = p.getPortDescription();
+            info.chipHint = guessChip(info.description + " " + p.getDescriptivePortName());
+            result.add(info);
+        }
+        return result;
+    }
+
+    /**
+     * 测试端口连通性:以指定参数打开端口,等待读取一小段数据,再关闭
+     * @param waitMs 读取等待时间毫秒
+     */
+    public static TestResult testConnection(String portName, int baud, int dataBits,
+                                            String stopBitStr, String parityStr, int waitMs) {
+        TestResult r = new TestResult();
+        if (portName == null || portName.trim().isEmpty()) {
+            r.errorMsg = "串口号为空";
+            return r;
+        }
+
+        SerialPort port = SerialPort.getCommPort(portName.trim());
+        r.description = port.getPortDescription();
+        r.chipHint = guessChip(r.description + " " + port.getDescriptivePortName());
+
+        try {
+            port.setBaudRate(baud);
+            port.setNumDataBits(dataBits);
+            port.setNumStopBits(stopBitFromString(stopBitStr));
+            port.setParity(parityFromString(parityStr));
+        } catch (Exception e) {
+            r.errorMsg = "参数设置失败:" + e.getMessage();
+            return r;
+        }
+
+        // 半阻塞读,超时 waitMs 后返回已读字节
+        port.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, waitMs, 0);
+        if (!port.openPort()) {
+            r.errorMsg = "端口打开失败:不存在、驱动未装、或已被其它程序占用";
+            return r;
+        }
+        r.opened = true;
+
+        try {
+            long start = System.currentTimeMillis();
+            byte[] buf = new byte[512];
+            int n = port.readBytes(buf, buf.length);
+            r.elapsedMs = System.currentTimeMillis() - start;
+            if (n > 0) {
+                r.received = true;
+                String s = new String(buf, 0, n).trim();
+                r.response = s.length() > 200 ? s.substring(0, 200) + "..." : s;
+            }
+        } catch (Exception e) {
+            r.errorMsg = "读取异常:" + e.getMessage();
+        } finally {
+            try { port.closePort(); } catch (Exception ignore) {}
+        }
+        return r;
+    }
+
+    private static int parityFromString(String s) {
+        if (s == null) return SerialPort.NO_PARITY;
+        switch (s) {
+            case "ODD":   return SerialPort.ODD_PARITY;
+            case "EVEN":  return SerialPort.EVEN_PARITY;
+            case "MARK":  return SerialPort.MARK_PARITY;
+            case "SPACE": return SerialPort.SPACE_PARITY;
+            default:      return SerialPort.NO_PARITY;
+        }
+    }
+
+    private static int stopBitFromString(String s) {
+        if ("1.5".equals(s)) return SerialPort.ONE_POINT_FIVE_STOP_BITS;
+        if ("2".equals(s))   return SerialPort.TWO_STOP_BITS;
+        return SerialPort.ONE_STOP_BIT;
+    }
+
+    /** 根据描述文本推测芯片型号 */
+    private static String guessChip(String desc) {
+        if (desc == null) return "";
+        String d = desc.toLowerCase();
+        if (d.contains("ch340") || d.contains("ch341")) return "沁恒 CH340/341";
+        if (d.contains("cp210")) return "Silicon Labs CP210x";
+        if (d.contains("ft232") || d.contains("ftdi")) return "FTDI FT232";
+        if (d.contains("pl2303") || d.contains("prolific")) return "Prolific PL2303";
+        if (d.contains("usb serial port")) return "FTDI 或兼容"; // FTDI 默认描述
+        return "";
+    }
+}

+ 3 - 0
src/resources/META-INF/MANIFEST.MF

@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Main-Class: com.mes.ui.MesClient
+