Browse Source

总成气密完成

liuwei 2 tháng trước cách đây
mục cha
commit
e6027c28e5

+ 129 - 80
src/main/java/com/jeesite/modules/scheduling/FrameworkQmScheduling.java

@@ -29,28 +29,22 @@ import java.util.concurrent.atomic.AtomicBoolean;
 /**
  * 框架气密 S7 采集与质量握手(OP360)。
  * <pre>
- * DB9100 控制点位:
- *  48.0   液冷板判定结果 Bool
- *  48.1   框架判定结果 Bool
- *  48.2   过程数据采集触发 Bool
- * 112.0   质量校验通过反馈(MES 写 true)
- * 112.1   扫码反馈信号(校验通过后 MES 写 false 复位)
- * 114.0   扫码框架码 String[60]
- *  50.0   气密框架码 String[60](提交数据及工位结果使用)
+ * DB9100 进站:
+ * 112.0   A1允许进站 Bool(MES 仅写 true;由 PLC 自行清零,MES 绝不写 false)
+ * 112.1   A1扫码反馈 Bool(校验通过后 MES 写 false 复位)
+ * 114.0   进站框架码 String[60]
  *
- * DB9100 过程数据(Real):
- *   0.0   液冷板充气时间
- *   4.0   液冷板保压时间
- *   8.0   液冷板测试时间
- *  12.0   液冷板充气压力
- *  16.0   液冷板稳定压力
- *  20.0   液冷板泄漏量
- *  24.0   框架充气时间
- *  28.0   框架保压时间
- *  32.0   框架测试时间
- *  36.0   框架充气压力
- *  40.0   框架稳定压力
- *  44.0   框架泄漏量
+ * DB9100 ST01(A)完成采集:
+ * 240.0   气密ST01工作完成 Bool(成功后 MES 复位 false)
+ * 178.0   气密ST01框架码 String[60]
+ *   0.0~20.0  液冷板过程 Real;24.0~44.0 框架过程 Real
+ *  48.0 / 48.1 液冷板/框架判定 Bool
+ *
+ * DB9100 ST02(B)完成采集:
+ * 304.0   气密ST02工作完成 Bool(成功后 MES 复位 false)
+ * 242.0   气密ST02框架码 String[60]
+ * 306.0~326.0 液冷板过程 Real;330.0~350.0 框架过程 Real
+ * 354.0 / 354.1 液冷板/框架判定 Bool
  *
  * 配置(application.yml):
  *   mes.framework.qm.enabled   是否启用(默认 false)
@@ -65,28 +59,32 @@ public class FrameworkQmScheduling {
 
 	private static final Logger logger = LoggerFactory.getLogger(FrameworkQmScheduling.class);
 
-	private static final String ADDR_QUALITY_PASS = "DB9100.112.0";
+	private static final String ADDR_ALLOW_ENTRY = "DB9100.112.0";
+	private static final String ADDR_SCAN_FEEDBACK = "DB9100.112.1";
 	private static final String ADDR_SCAN_SN = "DB9100.114.0";
-	private static final String ADDR_QM_SN = "DB9100.50.0";
 	private static final int SN_LENGTH = 60;
-	private static final String ADDR_SCAN_FEEDBACK = "DB9100.112.1";
 
-	private static final String ADDR_YLB_INFLATION_TIME = "DB9100.0.0";
-	private static final String ADDR_YLB_HOLD_TIME = "DB9100.4.0";
-	private static final String ADDR_YLB_TEST_TIME = "DB9100.8.0";
-	private static final String ADDR_YLB_INFLATION_PRESSURE = "DB9100.12.0";
-	private static final String ADDR_YLB_STABLE_PRESSURE = "DB9100.16.0";
-	private static final String ADDR_YLB_LEAK = "DB9100.20.0";
-
-	private static final String ADDR_INFLATION_TIME = "DB9100.24.0";
-	private static final String ADDR_HOLD_TIME = "DB9100.28.0";
-	private static final String ADDR_TEST_TIME = "DB9100.32.0";
-	private static final String ADDR_INFLATION_PRESSURE = "DB9100.36.0";
-	private static final String ADDR_STABLE_PRESSURE = "DB9100.40.0";
-	private static final String ADDR_LEAK_AMOUNT = "DB9100.44.0";
-	private static final String ADDR_COLLECT_FLAG = "DB9100.48.2";
-	private static final String ADDR_YLB_JUDGE_RESULT = "DB9100.48.0";
-	private static final String ADDR_JUDGE_RESULT = "DB9100.48.1";
+	private static final StationChannel ST01 = new StationChannel(
+			"ST01",
+			"DB9100.240.0",
+			"DB9100.178.0",
+			"DB9100.0.0", "DB9100.4.0", "DB9100.8.0",
+			"DB9100.12.0", "DB9100.16.0", "DB9100.20.0",
+			"DB9100.24.0", "DB9100.28.0", "DB9100.32.0",
+			"DB9100.36.0", "DB9100.40.0", "DB9100.44.0",
+			"DB9100.48.0", "DB9100.48.1"
+	);
+
+	private static final StationChannel ST02 = new StationChannel(
+			"ST02",
+			"DB9100.304.0",
+			"DB9100.242.0",
+			"DB9100.306.0", "DB9100.310.0", "DB9100.314.0",
+			"DB9100.318.0", "DB9100.322.0", "DB9100.326.0",
+			"DB9100.330.0", "DB9100.334.0", "DB9100.338.0",
+			"DB9100.342.0", "DB9100.346.0", "DB9100.350.0",
+			"DB9100.354.0", "DB9100.354.1"
+	);
 
 	private static final String DEFAULT_PLC_IP = "192.168.1.100";
 	private static final String DEFAULT_OPRNO = "OP360";
@@ -128,7 +126,8 @@ public class FrameworkQmScheduling {
 		try {
 			s7PLC = new S7PLC(EPlcType.S1200, ip);
 			handleScanFeedback(s7PLC);
-			handleCollectTrigger(s7PLC);
+			handleStationComplete(s7PLC, ST01);
+			handleStationComplete(s7PLC, ST02);
 		} catch (Exception e) {
 			logger.error("框架气密S7任务失败[{}]:{}", ip, e.getMessage());
 		} finally {
@@ -143,7 +142,8 @@ public class FrameworkQmScheduling {
 	}
 
 	/**
-	 * 扫码反馈为 true:读扫码框架码 → 质量校验 → 通过则写 DB9100.112.0=true,并将 DB9100.112.1 复位为 false
+	 * 扫码反馈为 true:读进站框架码 → 质量校验 → 通过则写 112.0=true,并将 112.1 复位为 false。
+	 * 注意:112.0(允许进站)仅写 true,由 PLC 自行清零,MES 绝不写 false。
 	 */
 	private void handleScanFeedback(S7PLC s7PLC) {
 		if (!s7PLC.readBoolean(ADDR_SCAN_FEEDBACK)) {
@@ -160,10 +160,10 @@ public class FrameworkQmScheduling {
 
 		boolean pass = doQualityCheck(sn);
 		if (pass) {
-			s7PLC.writeBoolean(ADDR_QUALITY_PASS, true);
+			s7PLC.writeBoolean(ADDR_ALLOW_ENTRY, true);
 			s7PLC.writeBoolean(ADDR_SCAN_FEEDBACK, false);
-			logger.info("框架气密质量校验通过 sn={},已写 {}=true,已将 {} 复位为 false",
-					sn, ADDR_QUALITY_PASS, ADDR_SCAN_FEEDBACK);
+			logger.info("框架气密质量校验通过 sn={},已写 {}=true(PLC自清),已将 {} 复位为 false",
+					sn, ADDR_ALLOW_ENTRY, ADDR_SCAN_FEEDBACK);
 		} else {
 			logger.warn("框架气密质量校验未通过 sn={}", sn);
 		}
@@ -171,58 +171,61 @@ public class FrameworkQmScheduling {
 	}
 
 	/**
-	 * 采集触发为 true:读过程参数 → 落库 mes_product_qmzc → 提交 OP360 工位结果 → 清触发位
+	 * 通道完成信号为 true:读该通道框架码与过程数据 → 落库 → 报工 → 成功后清完成位。
+	 * ST01 / ST02 互不影响。
 	 */
-	private void handleCollectTrigger(S7PLC s7PLC) {
-		if (!s7PLC.readBoolean(ADDR_COLLECT_FLAG)) {
+	private void handleStationComplete(S7PLC s7PLC, StationChannel ch) {
+		if (!s7PLC.readBoolean(ch.completeFlag)) {
 			return;
 		}
 
-		String qmSn = readWorkpieceCode(s7PLC, ADDR_QM_SN);
+		String qmSn = readWorkpieceCode(s7PLC, ch.qmSn);
 		if (StringUtils.isBlank(qmSn)) {
-			logger.error("框架气密采集失败:无气密框架码,保持 {}=true 待重试", ADDR_COLLECT_FLAG);
+			logger.error("框架气密采集[{}]失败:无气密框架码,保持 {}=true 待重试", ch.name, ch.completeFlag);
 			return;
 		}
 
-		float ylbInflationTime = s7PLC.readFloat32(ADDR_YLB_INFLATION_TIME);
-		float ylbTestTime = s7PLC.readFloat32(ADDR_YLB_TEST_TIME);
-		float ylbInflationPressure = s7PLC.readFloat32(ADDR_YLB_INFLATION_PRESSURE);
-		float ylbStablePressure = s7PLC.readFloat32(ADDR_YLB_STABLE_PRESSURE);
-		float ylbLeak = s7PLC.readFloat32(ADDR_YLB_LEAK);
-		float ylbHoldTime = s7PLC.readFloat32(ADDR_YLB_HOLD_TIME);
-
-		float inflationTime = s7PLC.readFloat32(ADDR_INFLATION_TIME);
-		float testTime = s7PLC.readFloat32(ADDR_TEST_TIME);
-		float inflationPressure = s7PLC.readFloat32(ADDR_INFLATION_PRESSURE);
-		float stablePressure = s7PLC.readFloat32(ADDR_STABLE_PRESSURE);
-		float leakAmount = s7PLC.readFloat32(ADDR_LEAK_AMOUNT);
-		float holdTime = s7PLC.readFloat32(ADDR_HOLD_TIME);
-		boolean ylbJudgePass = s7PLC.readBoolean(ADDR_YLB_JUDGE_RESULT);
-		boolean judgePass = s7PLC.readBoolean(ADDR_JUDGE_RESULT);
+		float ylbInflationTime = s7PLC.readFloat32(ch.ylbInflationTime);
+		float ylbHoldTime = s7PLC.readFloat32(ch.ylbHoldTime);
+		float ylbTestTime = s7PLC.readFloat32(ch.ylbTestTime);
+		float ylbInflationPressure = s7PLC.readFloat32(ch.ylbInflationPressure);
+		float ylbStablePressure = s7PLC.readFloat32(ch.ylbStablePressure);
+		float ylbLeak = s7PLC.readFloat32(ch.ylbLeak);
+
+		float inflationTime = s7PLC.readFloat32(ch.inflationTime);
+		float holdTime = s7PLC.readFloat32(ch.holdTime);
+		float testTime = s7PLC.readFloat32(ch.testTime);
+		float inflationPressure = s7PLC.readFloat32(ch.inflationPressure);
+		float stablePressure = s7PLC.readFloat32(ch.stablePressure);
+		float leakAmount = s7PLC.readFloat32(ch.leakAmount);
+
+		boolean ylbJudgePass = s7PLC.readBoolean(ch.ylbJudge);
+		boolean judgePass = s7PLC.readBoolean(ch.frameJudge);
 		String ylbStationResult = ylbJudgePass ? "OK" : "NG";
 		String stationResult = judgePass ? "OK" : "NG";
 
-		logger.info("框架气密采集 qmSn={} 液冷板[充气时间={} 测试时间={} 充气压力={} 稳定压力={} 泄漏量={} 保压时间={} 判定={}] "
+		logger.info("框架气密采集[{}] qmSn={} 液冷板[充气时间={} 测试时间={} 充气压力={} 稳定压力={} 泄漏量={} 保压时间={} 判定={}] "
 						+ "框架[充气时间={} 测试时间={} 充气压力={} 稳定压力={} 泄露量={} 保压时间={} 判定={}]",
-				qmSn, ylbInflationTime, ylbTestTime, ylbInflationPressure, ylbStablePressure, ylbLeak, ylbHoldTime, ylbStationResult,
+				ch.name, qmSn, ylbInflationTime, ylbTestTime, ylbInflationPressure, ylbStablePressure, ylbLeak, ylbHoldTime, ylbStationResult,
 				inflationTime, testTime, inflationPressure, stablePressure, leakAmount, holdTime, stationResult);
 
-		if (!saveQmzcData(qmSn, ylbInflationTime, ylbTestTime, ylbInflationPressure, ylbStablePressure, ylbLeak, ylbHoldTime,
+		if (!saveQmzcData(ch.name, qmSn, ylbInflationTime, ylbTestTime, ylbInflationPressure, ylbStablePressure, ylbLeak, ylbHoldTime,
 				inflationTime, testTime, inflationPressure, stablePressure, leakAmount, holdTime,
 				ylbStationResult, stationResult)) {
-			logger.error("框架气密数据保存失败,保持 {}=true 待重试 qmSn={}", ADDR_COLLECT_FLAG, qmSn);
+			logger.error("框架气密数据保存失败[{}],保持 {}=true 待重试 qmSn={}", ch.name, ch.completeFlag, qmSn);
 			return;
 		}
-		if (!submitStationResult(qmSn, stationResult)) {
-			logger.error("框架气密工位结果提交失败,保持 {}=true 待重试 qmSn={}", ADDR_COLLECT_FLAG, qmSn);
+		if (!submitStationResult(ch.name, qmSn, stationResult)) {
+			logger.error("框架气密工位结果提交失败[{}],保持 {}=true 待重试 qmSn={}", ch.name, ch.completeFlag, qmSn);
 			return;
 		}
 
-		s7PLC.writeBoolean(ADDR_COLLECT_FLAG, false);
-		logger.info("框架气密采集完成 qmSn={} result={},已将 {} 置 false", qmSn, stationResult, ADDR_COLLECT_FLAG);
+		s7PLC.writeBoolean(ch.completeFlag, false);
+		logger.info("框架气密采集完成[{}] qmSn={} result={},已将 {} 置 false",
+				ch.name, qmSn, stationResult, ch.completeFlag);
 	}
 
-	private boolean saveQmzcData(String qmSn, float ylbInflationTime, float ylbTestTime, float ylbInflationPressure,
+	private boolean saveQmzcData(String channel, String qmSn, float ylbInflationTime, float ylbTestTime, float ylbInflationPressure,
 			float ylbStablePressure, float ylbLeak, float ylbHoldTime,
 			float inflationTime, float testTime, float inflationPressure, float stablePressure,
 			float leakAmount, float holdTime, String ylbStationResult, String stationResult) {
@@ -243,15 +246,15 @@ public class FrameworkQmScheduling {
 			entity.setBysj(fmt(holdTime));
 			entity.setResult(stationResult);
 			mesProductQmzcService.save(entity);
-			logger.info("框架气密数据已保存 qmSn={}", qmSn);
+			logger.info("框架气密数据已保存[{}] qmSn={}", channel, qmSn);
 			return true;
 		} catch (Exception e) {
-			logger.error("框架气密数据保存异常 qmSn={}:{}", qmSn, e.getMessage());
+			logger.error("框架气密数据保存异常[{}] qmSn={}:{}", channel, qmSn, e.getMessage());
 			return false;
 		}
 	}
 
-	private boolean submitStationResult(String sn, String result) {
+	private boolean submitStationResult(String channel, String sn, String result) {
 		try {
 			String oprno = getOprno();
 			if (oprno.length() == 5) {
@@ -259,17 +262,17 @@ public class FrameworkQmScheduling {
 			}
 			String lineSn = getLineSn(sn);
 			if (StringUtils.isBlank(lineSn)) {
-				logger.warn("框架气密工位结果提交失败:无法解析产线编号 sn={}", sn);
+				logger.warn("框架气密工位结果提交失败[{}]:无法解析产线编号 sn={}", channel, sn);
 				return false;
 			}
 			List<ParamsResp> params = new ArrayList<>();
 			List<BindMaterialResp> bmlists = new ArrayList<>();
 			mesProductRecordService.updateRecordResult(sn, oprno, "100000", "", result,
 					"", params, lineSn, true, bmlists, null);
-			logger.info("框架气密工位结果已提交 sn={} oprno={} result={}", sn, oprno, result);
+			logger.info("框架气密工位结果已提交[{}] sn={} oprno={} result={}", channel, sn, oprno, result);
 			return true;
 		} catch (Exception e) {
-			logger.error("框架气密工位结果提交异常 sn={}:{}", sn, e.getMessage());
+			logger.error("框架气密工位结果提交异常[{}] sn={}:{}", channel, sn, e.getMessage());
 			return false;
 		}
 	}
@@ -344,4 +347,50 @@ public class FrameworkQmScheduling {
 	private static String fmt(float val) {
 		return String.format("%.3f", val);
 	}
+
+	/** 单通道气密完成采集点位 */
+	private static final class StationChannel {
+		final String name;
+		final String completeFlag;
+		final String qmSn;
+		final String ylbInflationTime;
+		final String ylbHoldTime;
+		final String ylbTestTime;
+		final String ylbInflationPressure;
+		final String ylbStablePressure;
+		final String ylbLeak;
+		final String inflationTime;
+		final String holdTime;
+		final String testTime;
+		final String inflationPressure;
+		final String stablePressure;
+		final String leakAmount;
+		final String ylbJudge;
+		final String frameJudge;
+
+		StationChannel(String name, String completeFlag, String qmSn,
+				String ylbInflationTime, String ylbHoldTime, String ylbTestTime,
+				String ylbInflationPressure, String ylbStablePressure, String ylbLeak,
+				String inflationTime, String holdTime, String testTime,
+				String inflationPressure, String stablePressure, String leakAmount,
+				String ylbJudge, String frameJudge) {
+			this.name = name;
+			this.completeFlag = completeFlag;
+			this.qmSn = qmSn;
+			this.ylbInflationTime = ylbInflationTime;
+			this.ylbHoldTime = ylbHoldTime;
+			this.ylbTestTime = ylbTestTime;
+			this.ylbInflationPressure = ylbInflationPressure;
+			this.ylbStablePressure = ylbStablePressure;
+			this.ylbLeak = ylbLeak;
+			this.inflationTime = inflationTime;
+			this.holdTime = holdTime;
+			this.testTime = testTime;
+			this.inflationPressure = inflationPressure;
+			this.stablePressure = stablePressure;
+			this.leakAmount = leakAmount;
+			this.ylbJudge = ylbJudge;
+			this.frameJudge = frameJudge;
+		}
+	}
 }

+ 425 - 0
src/test/java/com/jeesite/test/FrameworkQmPointMonitorTest.java

@@ -0,0 +1,425 @@
+package com.jeesite.test;
+
+import com.github.xingshuangs.iot.protocol.s7.enums.EPlcType;
+import com.github.xingshuangs.iot.protocol.s7.service.S7PLC;
+
+import javax.swing.JButton;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTable;
+import javax.swing.JTextField;
+import javax.swing.SwingUtilities;
+import javax.swing.UIManager;
+import javax.swing.WindowConstants;
+import javax.swing.border.EmptyBorder;
+import javax.swing.border.TitledBorder;
+import javax.swing.table.AbstractTableModel;
+import javax.swing.table.DefaultTableCellRenderer;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.FlowLayout;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * 框架气密(OP360 / FrameworkQmScheduling)PLC 点位实时监视工具。
+ * <p>
+ * 用法:在 IDE 中右键运行本类 main(不要启动 Spring / Application)。
+ * 界面可修改 PLC IP、启停刷新,实时显示 DB9100 全部控制/过程点位。
+ */
+public class FrameworkQmPointMonitorTest {
+
+	private static final String DEFAULT_PLC_IP = "192.168.1.1";
+	private static final int DEFAULT_INTERVAL_MS = 500;
+	private static final int SN_LENGTH = 60;
+
+	private static final PointDef[] POINTS = {
+			// 进站
+			new PointDef("进站", "DB9100.112.0", "A1允许进站", PointType.BOOL),
+			new PointDef("进站", "DB9100.112.1", "A1扫码反馈", PointType.BOOL),
+			new PointDef("进站", "DB9100.114.0", "进站框架码 String[60]", PointType.STRING60),
+			new PointDef("进站", "DB9100.176.0", "A1扫码结果", PointType.BOOL),
+			// ST01
+			new PointDef("ST01", "DB9100.240.0", "气密ST01工作完成", PointType.BOOL),
+			new PointDef("ST01", "DB9100.178.0", "气密ST01框架码 String[60]", PointType.STRING60),
+			new PointDef("ST01", "DB9100.0.0", "液冷板充气时间", PointType.REAL),
+			new PointDef("ST01", "DB9100.4.0", "液冷板保压时间", PointType.REAL),
+			new PointDef("ST01", "DB9100.8.0", "液冷板测试时间", PointType.REAL),
+			new PointDef("ST01", "DB9100.12.0", "液冷板充气压力", PointType.REAL),
+			new PointDef("ST01", "DB9100.16.0", "液冷板稳定压力", PointType.REAL),
+			new PointDef("ST01", "DB9100.20.0", "液冷板泄漏量", PointType.REAL),
+			new PointDef("ST01", "DB9100.24.0", "框架充气时间", PointType.REAL),
+			new PointDef("ST01", "DB9100.28.0", "框架保压时间", PointType.REAL),
+			new PointDef("ST01", "DB9100.32.0", "框架测试时间", PointType.REAL),
+			new PointDef("ST01", "DB9100.36.0", "框架充气压力", PointType.REAL),
+			new PointDef("ST01", "DB9100.40.0", "框架稳定压力", PointType.REAL),
+			new PointDef("ST01", "DB9100.44.0", "框架泄漏量", PointType.REAL),
+			new PointDef("ST01", "DB9100.48.0", "液冷板判定结果", PointType.BOOL),
+			new PointDef("ST01", "DB9100.48.1", "框架判定结果", PointType.BOOL),
+			new PointDef("ST01", "DB9100.48.2", "MES全部测试完成(只读)", PointType.BOOL),
+			// ST02
+			new PointDef("ST02", "DB9100.304.0", "气密ST02工作完成", PointType.BOOL),
+			new PointDef("ST02", "DB9100.242.0", "气密ST02框架码 String[60]", PointType.STRING60),
+			new PointDef("ST02", "DB9100.306.0", "液冷板充气时间ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.310.0", "液冷板保压时间ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.314.0", "液冷板测试时间ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.318.0", "液冷板充气压力ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.322.0", "液冷板稳定压力ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.326.0", "液冷板泄漏量ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.330.0", "框架充气时间ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.334.0", "框架保压时间ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.338.0", "框架测试时间ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.342.0", "框架充气压力ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.346.0", "框架稳定压力ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.350.0", "框架泄漏量ST02", PointType.REAL),
+			new PointDef("ST02", "DB9100.354.0", "液冷板判定结果ST02", PointType.BOOL),
+			new PointDef("ST02", "DB9100.354.1", "框架判定结果ST02", PointType.BOOL),
+	};
+
+	private enum PointType { BOOL, REAL, STRING60 }
+
+	private static final class PointDef {
+		final String group;
+		final String address;
+		final String title;
+		final PointType type;
+
+		PointDef(String group, String address, String title, PointType type) {
+			this.group = group;
+			this.address = address;
+			this.title = title;
+			this.type = type;
+		}
+	}
+
+	private static final class PointRow {
+		final PointDef def;
+		volatile String value = "-";
+		volatile boolean changed;
+
+		PointRow(PointDef def) {
+			this.def = def;
+		}
+	}
+
+	private final List<PointRow> rows = new ArrayList<>();
+	private final AtomicBoolean running = new AtomicBoolean(false);
+	private volatile String currentIp = DEFAULT_PLC_IP;
+	private volatile int intervalMs = DEFAULT_INTERVAL_MS;
+	private volatile long lastOkMs;
+	private Thread pollThread;
+
+	private JFrame frame;
+	private JTextField ipField;
+	private JTextField intervalField;
+	private JLabel statusLabel;
+	private JButton startBtn;
+	private JButton stopBtn;
+	private PointTableModel tableModel;
+	private final SimpleDateFormat timeFmt = new SimpleDateFormat("HH:mm:ss.SSS");
+
+	public static void main(String[] args) {
+		// 仅启动 Swing,不启动 Spring / 不连数据库
+		System.setProperty("java.awt.headless", "false");
+		SwingUtilities.invokeLater(() -> {
+			try {
+				UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
+			} catch (Exception ignore) {
+			}
+			new FrameworkQmPointMonitorTest().createAndShow();
+		});
+	}
+
+	private void createAndShow() {
+		for (PointDef def : POINTS) {
+			rows.add(new PointRow(def));
+		}
+
+		frame = new JFrame("框架气密 OP360 点位监视 (FrameworkQmScheduling)");
+		frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
+		frame.setSize(920, 640);
+		frame.setLocationRelativeTo(null);
+
+		JPanel root = new JPanel(new BorderLayout(8, 8));
+		root.setBorder(new EmptyBorder(10, 10, 10, 10));
+		root.add(buildToolbar(), BorderLayout.NORTH);
+		root.add(buildTable(), BorderLayout.CENTER);
+		root.add(buildFooter(), BorderLayout.SOUTH);
+
+		frame.setContentPane(root);
+		frame.addWindowListener(new WindowAdapter() {
+			@Override
+			public void windowClosing(WindowEvent e) {
+				stopPolling();
+			}
+		});
+		frame.setVisible(true);
+		System.out.println("[FrameworkQmPointMonitor] UI started, default IP=" + DEFAULT_PLC_IP);
+	}
+
+	private JPanel buildToolbar() {
+		JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 4));
+		panel.setBorder(new TitledBorder("连接"));
+
+		panel.add(new JLabel("PLC IP:"));
+		ipField = new JTextField(currentIp, 14);
+		panel.add(ipField);
+
+		JButton applyIpBtn = new JButton("应用 IP");
+		applyIpBtn.addActionListener(e -> applyIp());
+		panel.add(applyIpBtn);
+
+		panel.add(new JLabel("刷新(ms):"));
+		intervalField = new JTextField(String.valueOf(DEFAULT_INTERVAL_MS), 5);
+		panel.add(intervalField);
+
+		startBtn = new JButton("开始监视");
+		startBtn.addActionListener(e -> startPolling());
+		panel.add(startBtn);
+
+		stopBtn = new JButton("停止");
+		stopBtn.setEnabled(false);
+		stopBtn.addActionListener(e -> stopPolling());
+		panel.add(stopBtn);
+
+		JButton onceBtn = new JButton("读一次");
+		onceBtn.addActionListener(e -> readOnceAsync());
+		panel.add(onceBtn);
+
+		return panel;
+	}
+
+	private JScrollPane buildTable() {
+		tableModel = new PointTableModel();
+		JTable table = new JTable(tableModel);
+		table.setRowHeight(24);
+		table.setFillsViewportHeight(true);
+		table.getColumnModel().getColumn(0).setPreferredWidth(70);
+		table.getColumnModel().getColumn(1).setPreferredWidth(130);
+		table.getColumnModel().getColumn(2).setPreferredWidth(200);
+		table.getColumnModel().getColumn(3).setPreferredWidth(80);
+		table.getColumnModel().getColumn(4).setPreferredWidth(280);
+
+		DefaultTableCellRenderer valueRenderer = new DefaultTableCellRenderer() {
+			@Override
+			public Component getTableCellRendererComponent(JTable tbl, Object value,
+					boolean isSelected, boolean hasFocus, int row, int column) {
+				Component c = super.getTableCellRendererComponent(tbl, value, isSelected, hasFocus, row, column);
+				PointRow pr = rows.get(row);
+				if (!isSelected) {
+					if ("true".equalsIgnoreCase(String.valueOf(value))) {
+						c.setBackground(new Color(198, 239, 206));
+					} else if ("false".equalsIgnoreCase(String.valueOf(value))) {
+						c.setBackground(new Color(255, 235, 156));
+					} else if (pr.changed) {
+						c.setBackground(new Color(210, 230, 255));
+					} else {
+						c.setBackground(Color.WHITE);
+					}
+				}
+				return c;
+			}
+		};
+		table.getColumnModel().getColumn(4).setCellRenderer(valueRenderer);
+		return new JScrollPane(table);
+	}
+
+	private JPanel buildFooter() {
+		JPanel panel = new JPanel(new BorderLayout());
+		statusLabel = new JLabel("状态: 未连接 | IP=" + currentIp);
+		statusLabel.setBorder(new EmptyBorder(4, 2, 0, 2));
+		panel.add(statusLabel, BorderLayout.CENTER);
+		JLabel tip = new JLabel("对应 FrameworkQmScheduling:进站+ST01/ST02;改 IP 后点「应用 IP」或「开始监视」。不启动 Spring/数据库。");
+		tip.setForeground(Color.GRAY);
+		panel.add(tip, BorderLayout.SOUTH);
+		return panel;
+	}
+
+	private void applyIp() {
+		String ip = ipField.getText() == null ? "" : ipField.getText().trim();
+		if (ip.isEmpty()) {
+			JOptionPane.showMessageDialog(frame, "请输入 PLC IP", "提示", JOptionPane.WARNING_MESSAGE);
+			return;
+		}
+		currentIp = ip;
+		updateStatus("已切换 IP=" + currentIp + (running.get() ? "(下一轮生效)" : ""));
+	}
+
+	private void startPolling() {
+		applyIp();
+		try {
+			intervalMs = Math.max(100, Integer.parseInt(intervalField.getText().trim()));
+		} catch (Exception e) {
+			intervalMs = DEFAULT_INTERVAL_MS;
+			intervalField.setText(String.valueOf(intervalMs));
+		}
+		if (!running.compareAndSet(false, true)) {
+			return;
+		}
+		startBtn.setEnabled(false);
+		stopBtn.setEnabled(true);
+		pollThread = new Thread(this::pollLoop, "framework-qm-point-monitor");
+		pollThread.setDaemon(true);
+		pollThread.start();
+		updateStatus("监视中 IP=" + currentIp + " 间隔=" + intervalMs + "ms");
+	}
+
+	private void stopPolling() {
+		running.set(false);
+		if (pollThread != null) {
+			pollThread.interrupt();
+			pollThread = null;
+		}
+		SwingUtilities.invokeLater(() -> {
+			if (startBtn != null) {
+				startBtn.setEnabled(true);
+			}
+			if (stopBtn != null) {
+				stopBtn.setEnabled(false);
+			}
+			updateStatus("已停止 | 上次成功=" + (lastOkMs == 0 ? "-" : timeFmt.format(new Date(lastOkMs))));
+		});
+	}
+
+	private void readOnceAsync() {
+		applyIp();
+		new Thread(() -> {
+			try {
+				readAllPoints();
+				SwingUtilities.invokeLater(() -> {
+					tableModel.fireTableDataChanged();
+					updateStatus("读一次成功 " + timeFmt.format(new Date(lastOkMs)) + " | IP=" + currentIp);
+				});
+			} catch (Exception ex) {
+				SwingUtilities.invokeLater(() ->
+						updateStatus("读一次失败: " + ex.getMessage() + " | IP=" + currentIp));
+			}
+		}, "framework-qm-read-once").start();
+	}
+
+	private void pollLoop() {
+		while (running.get()) {
+			try {
+				readAllPoints();
+				SwingUtilities.invokeLater(() -> {
+					tableModel.fireTableDataChanged();
+					updateStatus("已连接 " + timeFmt.format(new Date(lastOkMs))
+							+ " | IP=" + currentIp + " | 间隔=" + intervalMs + "ms");
+				});
+			} catch (Exception ex) {
+				SwingUtilities.invokeLater(() ->
+						updateStatus("读取失败: " + ex.getMessage() + " | IP=" + currentIp));
+			}
+			try {
+				Thread.sleep(intervalMs);
+			} catch (InterruptedException e) {
+				Thread.currentThread().interrupt();
+				break;
+			}
+		}
+	}
+
+	private void readAllPoints() throws Exception {
+		S7PLC s7 = null;
+		try {
+			s7 = new S7PLC(EPlcType.S1200, currentIp);
+			for (PointRow row : rows) {
+				String old = row.value;
+				String neu = readPoint(s7, row.def);
+				row.value = neu;
+				row.changed = old != null && !old.equals(neu) && !"-".equals(old);
+			}
+			lastOkMs = System.currentTimeMillis();
+		} finally {
+			if (s7 != null) {
+				try {
+					s7.close();
+				} catch (Exception ignore) {
+				}
+			}
+		}
+	}
+
+	private static String readPoint(S7PLC s7, PointDef def) {
+		switch (def.type) {
+			case BOOL:
+				return String.valueOf(s7.readBoolean(def.address));
+			case REAL:
+				return String.format("%.3f", s7.readFloat32(def.address));
+			case STRING60:
+				return sanitizeString(s7.readString(def.address, SN_LENGTH));
+			default:
+				return "?";
+		}
+	}
+
+	private static String sanitizeString(String raw) {
+		if (raw == null || raw.trim().isEmpty()) {
+			return "";
+		}
+		StringBuilder sb = new StringBuilder(raw.length());
+		for (int i = 0; i < raw.length(); i++) {
+			char c = raw.charAt(i);
+			if (c == 0 || c == ' ') {
+				continue;
+			}
+			if (c >= 32 && c < 127) {
+				sb.append(c);
+			}
+		}
+		return sb.toString().trim();
+	}
+
+	private void updateStatus(String text) {
+		if (statusLabel != null) {
+			statusLabel.setText("状态: " + text);
+		}
+	}
+
+	private final class PointTableModel extends AbstractTableModel {
+		private final String[] cols = {"分组", "地址", "说明", "类型", "当前值"};
+
+		@Override
+		public int getRowCount() {
+			return rows.size();
+		}
+
+		@Override
+		public int getColumnCount() {
+			return cols.length;
+		}
+
+		@Override
+		public String getColumnName(int column) {
+			return cols[column];
+		}
+
+		@Override
+		public Object getValueAt(int rowIndex, int columnIndex) {
+			PointRow row = rows.get(rowIndex);
+			switch (columnIndex) {
+				case 0:
+					return row.def.group;
+				case 1:
+					return row.def.address;
+				case 2:
+					return row.def.title;
+				case 3:
+					return row.def.type.name();
+				case 4:
+					return row.value;
+				default:
+					return "";
+			}
+		}
+	}
+}