diff --git a/backend/main.py b/backend/main.py index 1aaa9bb..969dde9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -370,6 +370,10 @@ def get_opc_config(): "poll_interval": opc_service.poll_interval, "signal1_node": opc_service.signal1_node, "signal2_node": opc_service.signal2_node, + "write_counter_node": opc_service.write_counter_node, + "write_source_node": opc_service.write_source_node, + "write_target_node": opc_service.write_target_node, + "write_nodes": opc_service.write_nodes, "running": opc_service.running, "last_counter": opc_service.last_counter, "last_update": opc_service.last_update, @@ -385,6 +389,11 @@ async def save_opc_config(config: OpcConfig): opc_service.poll_interval = config.poll_interval opc_service.signal1_node = config.signal1_node opc_service.signal2_node = config.signal2_node + opc_service.write_counter_node = config.write_counter_node + opc_service.write_source_node = config.write_source_node + opc_service.write_target_node = config.write_target_node + opc_service.write_nodes = config.write_nodes + opc_service.write_counter_last = None try: opc_service.save_config() except Exception as e: @@ -402,6 +411,7 @@ def opc_status(): "last_update": opc_service.last_update, "log": opc_service.event_log[-50:], "track_state": opc_service.track_state, + "write_counter_last": opc_service.write_counter_last, } diff --git a/backend/models.py b/backend/models.py index 5a74ca9..b3ad92e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -184,3 +184,7 @@ class OpcConfig(BaseModel): trackmap_nodes: Dict[str, str] = {} signal1_node: str = "" signal2_node: str = "" + write_counter_node: str = "" + write_source_node: str = "" + write_target_node: str = "" + write_nodes: Dict[str, Dict[str, str]] = {} diff --git a/backend/opc_config.json b/backend/opc_config.json index ec480d6..a8f1a4a 100644 --- a/backend/opc_config.json +++ b/backend/opc_config.json @@ -1,8 +1,15 @@ { - "opc_url": "opc.tcp://192.168.1.100:4840", + "opc_url": "opc.tcp://127.0.0.1:49320", "counter_node": "ns=2;s=PL.TRACKMAP.COUNTER", "poll_interval": 2, "trackmap_nodes": {}, "signal1_node": "ns=2;s=PL.Signal.EntryCoil", - "signal2_node": "ns=2;s=PL.Signal.WeldDone" + "signal2_node": "ns=2;s=PL.Signal.WeldDone", + "write_counter_node": "", + "write_source_node": "", + "write_target_node": "", + "write_nodes": { + "1": {}, + "2": {} + } } \ No newline at end of file diff --git a/backend/opc_service.py b/backend/opc_service.py index d172300..82cb851 100644 --- a/backend/opc_service.py +++ b/backend/opc_service.py @@ -63,6 +63,13 @@ class OpcService: self.signal1_last: Optional[int] = None self.signal2_last: Optional[int] = None self.signal2_rise_time: Optional[datetime] = None + # 写入触发相关(三点位独立于追踪信号) + self.write_counter_node: str = os.getenv("OPC_WRITE_COUNTER_NODE", "") + self.write_source_node: str = os.getenv("OPC_WRITE_SOURCE_NODE", "") + self.write_target_node: str = os.getenv("OPC_WRITE_TARGET_NODE", "") + self.write_counter_last: Optional[Any] = None + # 写入字段映射(按目标开卷机 1/2 分组) + self.write_nodes: Dict[str, Dict[str, str]] = {} self.signal1_coils: List[Dict[str, Any]] = [] self.first_coilid: str = "" self.last_tracked_coilid: str = "" @@ -106,6 +113,10 @@ class OpcService: self.trackmap_nodes = cfg.get("trackmap_nodes", self.trackmap_nodes) or {} self.signal1_node = cfg.get("signal1_node", self.signal1_node) self.signal2_node = cfg.get("signal2_node", self.signal2_node) + self.write_counter_node = cfg.get("write_counter_node", self.write_counter_node) + self.write_source_node = cfg.get("write_source_node", self.write_source_node) + self.write_target_node = cfg.get("write_target_node", self.write_target_node) + self.write_nodes = cfg.get("write_nodes", self.write_nodes) or {} self._log(f"Loaded OPC config from {self.config_path}") except Exception as exc: logger.warning("Failed to load OPC config %s: %s", self.config_path, exc) @@ -119,6 +130,10 @@ class OpcService: "trackmap_nodes": self.trackmap_nodes, "signal1_node": self.signal1_node, "signal2_node": self.signal2_node, + "write_counter_node": self.write_counter_node, + "write_source_node": self.write_source_node, + "write_target_node": self.write_target_node, + "write_nodes": self.write_nodes, } os.makedirs(os.path.dirname(self.config_path), exist_ok=True) with open(self.config_path, "w", encoding="utf-8") as f: @@ -169,6 +184,7 @@ class OpcService: while not self._stop_event.is_set(): current_counter = await self._tick_with_counter(client) await self._check_signals(client, current_counter) + await self._check_write_trigger(client) await asyncio.sleep(self.poll_interval) finally: client.disconnect() @@ -257,25 +273,170 @@ class OpcService: elif self.track_state == "WAIT_S2": if signal2_value is not None and self.signal2_last is not None: if self.signal2_last == 0 and signal2_value == 1 and counter_changed: - self.signal2_rise_time = datetime.now() - self._log("Signal2: Weld done rising edge (0->1) with counter change") + self._log("Signal2: Weld done rising edge (0->1) with counter change, triggering update") + await self._handle_signal2() + self.track_state = "WAIT_S1" + self.signal2_rise_time = None + self.last_counter_at_state_change = current_counter elif self.signal2_last == 1 and signal2_value == 0: self.signal2_rise_time = None - if signal2_value == 1 and self.signal2_rise_time and counter_changed: - elapsed = (datetime.now() - self.signal2_rise_time).total_seconds() - if elapsed >= 2.0: - self._log(f"Signal2: Held {elapsed:.1f}s, triggering trackmap update") - await self._handle_signal2() - self.track_state = "WAIT_S1" - self.signal2_rise_time = None - self.last_counter_at_state_change = current_counter - if signal1_value is not None: self.signal1_last = signal1_value if signal2_value is not None: self.signal2_last = signal2_value + async def _check_write_trigger(self, client): + """Check write trigger counter/source/target and write PDI head coil to OPC.""" + if not (self.write_counter_node and self.write_source_node and self.write_target_node): + return + + try: + write_counter = client.get_node(self.write_counter_node).get_value() + write_source = client.get_node(self.write_source_node).get_value() + write_target = client.get_node(self.write_target_node).get_value() + except Exception as exc: + self._log(f"Write trigger read failed: {exc}") + return + + if self.write_counter_last is None: + self.write_counter_last = write_counter + return + + if write_counter == self.write_counter_last: + return + + old_counter = self.write_counter_last + self.write_counter_last = write_counter + self._log( + f"Write counter changed: {old_counter} -> {write_counter}, " + f"source={write_source}, target={write_target}" + ) + + try: + source_value = int(write_source) + target_value = int(write_target) + except (TypeError, ValueError): + self._log(f"Write trigger invalid source/target values: source={write_source}, target={write_target}") + return + + if source_value != 100 or target_value not in (1, 2): + return + + await self._write_entry_coil_to_uncoiler(client, target_value) + + async def _write_entry_coil_to_uncoiler(self, client, target_uncoiler: int): + """Write the smallest COILID plan to target uncoiler OPC nodes.""" + + def _load_next_plan(): + from database import get_connection + + conn = get_connection() + cursor = conn.cursor() + try: + cursor.execute( + """ + SELECT + COILID, + ENTRY_COIL_WEIGHT, + ENTRY_OF_COIL_LENGTH, + ENTRY_COIL_WIDTH, + ENTRY_COIL_THICKNESS, + ENTRY_OF_COIL_INNER_DIAMETER, + ENTRY_OF_COIL_OUTER_DIAMETER, + STEEL_GRADE + FROM PLTM.PDI_PLTM + ORDER BY COILID ASC + """ + ) + row = cursor.fetchone() + if not row: + return None + return { + "coilid": row[0], + "entry_coil_weight": row[1], + "entry_of_coil_length": row[2], + "entry_coil_width": row[3], + "entry_coil_thickness": row[4], + "entry_of_coil_inner_diameter": row[5], + "entry_of_coil_outer_diameter": row[6], + "alloy_code": row[7], + "material": row[7], + } + finally: + cursor.close() + conn.close() + + loop = asyncio.get_event_loop() + plan = await loop.run_in_executor(None, _load_next_plan) + if not plan: + self._log("Write trigger matched but PDI has no plan data") + return + + target_cfg = self.write_nodes.get(str(target_uncoiler), {}) or {} + if not target_cfg: + self._log(f"Write nodes for uncoiler {target_uncoiler} not configured") + return + + # setup_data_revision: 每次写入 +1 + revision_node = target_cfg.get("setup_data_revision") + if revision_node: + try: + rev_value = client.get_node(revision_node).get_value() + next_rev = int(rev_value or 0) + 1 + await self._write_node_value_by_client(client, revision_node, next_rev, "Int32") + except Exception as exc: + self._log(f"Write setup_data_revision failed (U{target_uncoiler}): {exc}") + + field_variant = { + "coilid": "String", + "entry_coil_weight": "Float", + "entry_of_coil_length": "Float", + "entry_coil_width": "Float", + "entry_coil_thickness": "Float", + "entry_of_coil_inner_diameter": "Float", + "entry_of_coil_outer_diameter": "Float", + "alloy_code": "String", + "material": "String", + } + + for field, value in plan.items(): + node_id = target_cfg.get(field) + if not node_id or value is None: + continue + try: + await self._write_node_value_by_client( + client, + node_id, + value, + field_variant.get(field), + ) + except Exception as exc: + self._log( + f"Write field failed (U{target_uncoiler}, {field}, node={node_id}): {exc}" + ) + + self._log( + f"Wrote plan COILID={plan['coilid']} to uncoiler {target_uncoiler}" + ) + + async def _write_node_value_by_client( + self, + client, + node_id: str, + value: Any, + variant_type: Optional[str] = None, + ): + """Write OPC node value using an existing client connection.""" + from opcua import ua # type: ignore + + node = client.get_node(node_id) + vt = self._normalize_variant_type(variant_type) + if vt is None: + node.set_value(value) + else: + node.set_value(ua.DataValue(ua.Variant(value, vt))) + async def _handle_signal1(self): """Handle signal1: fetch next 4 coils from Oracle PDI table and save to SQLite temp table.""" from sqlite_sync import sqlite_save_coils_to_track, sqlite_clear_coil_track diff --git a/frontend/src/views/OpcConfig.vue b/frontend/src/views/OpcConfig.vue index ab9d135..061cc05 100644 --- a/frontend/src/views/OpcConfig.vue +++ b/frontend/src/views/OpcConfig.vue @@ -7,17 +7,17 @@ opc.tcp://IP:PORT - - - 节点值变化时触发采集 - -
跟踪信号节点配置
+
追踪节点配置
+ + + 追踪流程计数器,节点值变化时触发采集 + 入口钢卷信号,0→1触发 @@ -28,6 +28,36 @@ +
计划写入触发节点配置
+ + + + 计数器变化触发写入流程(与追踪计数器分开) + + + + 读到100表示来源为计划表 + + + + 1=一号开卷机, 2=二号开卷机 + + + +
一号开卷机字段映射
+
+
{{ field.label }}
+ + +
+ +
二号开卷机字段映射
+
+
{{ field.label }}
+ + +
+
跟踪图节点映射
Oracle列名 → OPC节点ID。必须包含 position 列。 @@ -71,7 +101,29 @@ export default { data() { return { loading: false, saving: false, - form: { opc_url: '', counter_node: '', poll_interval: 2, signal1_node: '', signal2_node: '' }, + form: { + opc_url: '', + counter_node: '', + poll_interval: 2, + signal1_node: '', + signal2_node: '', + write_counter_node: '', + write_source_node: '', + write_target_node: '' + }, + writeFieldDefs: [ + { key: 'setup_data_revision', label: '设置数据修改', placeholder: 'ns=2;s=PL.Setup.DataRevision' }, + { key: 'coilid', label: '钢卷ID', placeholder: 'ns=2;s=PL.Entry1.CoilId' }, + { key: 'entry_coil_weight', label: '入口钢卷重量', placeholder: 'ns=2;s=PL.Entry1.Weight' }, + { key: 'entry_of_coil_length', label: '长度', placeholder: 'ns=2;s=PL.Entry1.Length' }, + { key: 'entry_coil_width', label: '宽度', placeholder: 'ns=2;s=PL.Entry1.Width' }, + { key: 'entry_coil_thickness', label: '厚度', placeholder: 'ns=2;s=PL.Entry1.Thickness' }, + { key: 'entry_of_coil_inner_diameter', label: '钢卷内径', placeholder: 'ns=2;s=PL.Entry1.InnerDiameter' }, + { key: 'entry_of_coil_outer_diameter', label: '钢卷外径', placeholder: 'ns=2;s=PL.Entry1.OuterDiameter' }, + { key: 'alloy_code', label: '合金代码', placeholder: 'ns=2;s=PL.Entry1.AlloyCode' }, + { key: 'material', label: '材质', placeholder: 'ns=2;s=PL.Entry1.Material' } + ], + writeNodes: { '1': {}, '2': {} }, nodeList: [], status: { running: false, last_counter: null, last_update: null, log: [], track_state: '' }, statusTimer: null @@ -93,6 +145,13 @@ export default { this.form.poll_interval = cfg.poll_interval this.form.signal1_node = cfg.signal1_node || '' this.form.signal2_node = cfg.signal2_node || '' + this.form.write_counter_node = cfg.write_counter_node || '' + this.form.write_source_node = cfg.write_source_node || '' + this.form.write_target_node = cfg.write_target_node || '' + this.writeNodes = { + '1': { ...(cfg.write_nodes?.['1'] || {}) }, + '2': { ...(cfg.write_nodes?.['2'] || {}) } + } this.nodeList = Object.entries(cfg.trackmap_nodes || {}).map(([col, node]) => ({ col, node })) } catch (e) { this.$message.error('加载失败: ' + e.message) } finally { this.loading = false } @@ -114,7 +173,7 @@ export default { } this.saving = true try { - await opcApi.saveConfig({ ...this.form, trackmap_nodes }) + await opcApi.saveConfig({ ...this.form, trackmap_nodes, write_nodes: this.writeNodes }) this.$message.success('配置已保存,OPC服务已重启') this.loadStatus() } catch (e) { this.$message.error('保存失败: ' + e.message) } @@ -134,6 +193,7 @@ export default { .hint { font-size:11px; color:#999; margin-left:10px; } .node-row { display:flex; align-items:center; gap:8px; margin-bottom:7px; padding:5px 8px; background:#fafafa; border:1px solid #eee; border-radius:2px; } .arrow { font-size:14px; color:#888; font-weight:600; } +.field-label { width: 180px; color:#666; font-size:12px; } .log-box { background:#fafafa; border:1px solid #e8e8e8; border-radius:2px; padding:8px 12px; height:180px; overflow-y:auto; font-family:monospace; } .log-line { font-size:11px; color:#555; line-height:1.7; border-bottom:1px solid #f0f0f0; } \ No newline at end of file diff --git a/frontend/src/views/TrackCoil.vue b/frontend/src/views/TrackCoil.vue index d59e7b1..98eaa42 100644 --- a/frontend/src/views/TrackCoil.vue +++ b/frontend/src/views/TrackCoil.vue @@ -7,7 +7,7 @@ 模拟信号1(入口) 模拟信号2(焊接完成) - 提示: 追踪4个位置,按钢卷号升序(最小的先进产线) + 1.出口 2.酸洗 3.入口活套 4.开卷机 提示:(最小的先进产线)