diff --git a/backend/main.py b/backend/main.py index cf61faf..1aaa9bb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,7 +5,8 @@ from contextlib import asynccontextmanager from typing import Optional from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, APIRouter +import datetime from fastapi.middleware.cors import CORSMiddleware load_dotenv() @@ -13,9 +14,14 @@ load_dotenv() from database import get_connection from models import PDIPLTMCreate, PDIPLTMUpdate, OpcConfig from opc_service import opc_service + +router = APIRouter() from sqlite_sync import ( init_db, sync_all_from_oracle, - sqlite_upsert_pdi, sqlite_delete_pdi + sqlite_upsert_pdi, sqlite_delete_pdi, + sqlite_get_coil_track, sqlite_update_coil_track_item, + sqlite_add_coil_track_item, sqlite_delete_coil_track_item, + sqlite_clear_coil_track, sqlite_get_coils_by_sequencenb_range ) logging.basicConfig(level=logging.INFO) @@ -362,6 +368,8 @@ def get_opc_config(): "counter_node": opc_service.counter_node, "trackmap_nodes": opc_service.trackmap_nodes, "poll_interval": opc_service.poll_interval, + "signal1_node": opc_service.signal1_node, + "signal2_node": opc_service.signal2_node, "running": opc_service.running, "last_counter": opc_service.last_counter, "last_update": opc_service.last_update, @@ -375,6 +383,8 @@ async def save_opc_config(config: OpcConfig): opc_service.counter_node = config.counter_node opc_service.trackmap_nodes = config.trackmap_nodes opc_service.poll_interval = config.poll_interval + opc_service.signal1_node = config.signal1_node + opc_service.signal2_node = config.signal2_node try: opc_service.save_config() except Exception as e: @@ -391,6 +401,7 @@ def opc_status(): "last_counter": opc_service.last_counter, "last_update": opc_service.last_update, "log": opc_service.event_log[-50:], + "track_state": opc_service.track_state, } @@ -471,7 +482,7 @@ def get_l2_model_grades(): conn.close() -@router.get("/api/pdi/next-numbers") +@app.get("/api/pdi/next-numbers") def get_next_numbers(): """ 获取下一个可用的批次编号和顺序号 @@ -536,3 +547,104 @@ def get_next_numbers(): cursor.close() if 'conn' in locals(): conn.close() + + +# ───────────────────────────────────────────── +# COIL_TRACK_TEMP 临时跟踪表 CRUD +# ───────────────────────────────────────────── + +@app.get("/api/track/coils") +def get_track_coils(): + """获取临时跟踪表中的钢卷列表""" + coils = sqlite_get_coil_track() + return {"data": coils} + + +@app.post("/api/track/coils") +def add_track_coil(data: dict): + """新增一个钢卷到临时跟踪表""" + coilid = data.get("coilid") + sequencenb = data.get("sequencenb") + rollprogramnb = data.get("rollprogramnb") + if not coilid: + raise HTTPException(status_code=400, detail="coilid不能为空") + sqlite_add_coil_track_item(coilid, sequencenb or 0, rollprogramnb or 0) + return {"message": "添加成功"} + + +@app.put("/api/track/coils/{id}") +def update_track_coil(id: int, data: dict): + """更新临时跟踪表中的钢卷""" + coilid = data.get("coilid") + sequencenb = data.get("sequencenb", 0) + rollprogramnb = data.get("rollprogramnb", 0) + position = data.get("position", 1) + if not coilid: + raise HTTPException(status_code=400, detail="coilid不能为空") + sqlite_update_coil_track_item(id, coilid, sequencenb, rollprogramnb, position) + return {"message": "更新成功"} + + +@app.delete("/api/track/coils/{id}") +def delete_track_coil(id: int): + """删除临时跟踪表中的钢卷""" + sqlite_delete_coil_track_item(id) + return {"message": "删除成功"} + + +@app.delete("/api/track/coils") +def clear_track_coils(): + """清空临时跟踪表""" + sqlite_clear_coil_track() + return {"message": "清空成功"} + + +@app.get("/api/track/coils/range") +def get_coils_by_range(start: int = Query(1), end: int = Query(5)): + """根据顺序号范围查询钢卷""" + coils = sqlite_get_coils_by_sequencenb_range(start, end) + return {"data": coils} + + +# ───────────────────────────────────────────── +# OPC Signal Configuration +# ───────────────────────────────────────────── + +@app.get("/api/opc/signals") +def get_signal_config(): + """获取信号节点配置""" + return { + "signal1_node": opc_service.signal1_node, + "signal2_node": opc_service.signal2_node, + } + + +@app.post("/api/opc/signals") +async def save_signal_config(data: dict): + """保存信号节点配置""" + opc_service.signal1_node = data.get("signal1_node", opc_service.signal1_node) + opc_service.signal2_node = data.get("signal2_node", opc_service.signal2_node) + try: + opc_service.save_config() + except Exception as e: + logger.warning("Save signal config failed: %s", e) + raise HTTPException(status_code=500, detail=f"配置保存失败: {e}") + return {"message": "信号节点配置已保存"} + + +# ───────────────────────────────────────────── +# 模拟信号接口 (用于测试) +# ───────────────────────────────────────────── + +@app.post("/api/track/simulate/signal1") +async def simulate_signal1(): + """模拟信号1触发 - 获取下5个钢卷""" + await opc_service._handle_signal1() + return {"message": "信号1已触发"} + + +@app.post("/api/track/simulate/signal2") +async def simulate_signal2(): + """模拟信号2触发 - 更新追踪表""" + await opc_service._handle_signal2() + return {"message": "信号2已触发"} diff --git a/backend/models.py b/backend/models.py index 816ead9..5a74ca9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -182,3 +182,5 @@ class OpcConfig(BaseModel): counter_node: str poll_interval: int = 2 trackmap_nodes: Dict[str, str] = {} + signal1_node: str = "" + signal2_node: str = "" diff --git a/backend/opc_config.json b/backend/opc_config.json new file mode 100644 index 0000000..ec480d6 --- /dev/null +++ b/backend/opc_config.json @@ -0,0 +1,8 @@ +{ + "opc_url": "opc.tcp://192.168.1.100:4840", + "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" +} \ No newline at end of file diff --git a/backend/opc_service.py b/backend/opc_service.py index 317fa2f..97a4526 100644 --- a/backend/opc_service.py +++ b/backend/opc_service.py @@ -8,6 +8,9 @@ Logic: `trackmap_nodes` (a dict: {oracle_column -> node_id}). 4. For each row returned by the trackmap nodes build an UPDATE statement and apply it to PLTM.CMPT_PL_TRACKMAP. + 5. Monitor two signal nodes: + - Signal1 (Entry): When 0->1, fetch next 5 coils from PDI and save to SQLite temp table + - Signal2 (WeldDone): When 0->1 and held for 2s, update CMPT_PL_TRACKMAP `trackmap_nodes` example (stored in .env or configured via UI): { @@ -42,6 +45,16 @@ class OpcService: "OPC_COUNTER_NODE", "ns=2;s=PL.TRACKMAP.COUNTER" ) self.poll_interval: int = int(os.getenv("OPC_POLL_INTERVAL", "2")) + self.signal1_node: str = os.getenv("OPC_SIGNAL1_NODE", "ns=2;s=PL.Signal.EntryCoil") + self.signal2_node: str = os.getenv("OPC_SIGNAL2_NODE", "ns=2;s=PL.Signal.WeldDone") + self.signal1_last: Optional[int] = None + self.signal2_last: Optional[int] = None + self.signal2_rise_time: Optional[datetime] = None + self.signal1_coils: List[Dict[str, Any]] = [] + self.current_seq_start: int = 1 + # 状态机: WAIT_S1=等待信号1, WAIT_S2=等待信号2 + self.track_state: str = "WAIT_S1" + self.last_counter_at_state_change: Optional[Any] = None # Mapping: oracle_column_name -> OPC node id # Populated from .env or via API self.trackmap_nodes: Dict[str, str] = self._load_trackmap_nodes() @@ -75,6 +88,8 @@ class OpcService: self.counter_node = cfg.get("counter_node", self.counter_node) self.poll_interval = int(cfg.get("poll_interval", self.poll_interval)) 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._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) @@ -86,6 +101,8 @@ class OpcService: "counter_node": self.counter_node, "poll_interval": self.poll_interval, "trackmap_nodes": self.trackmap_nodes, + "signal1_node": self.signal1_node, + "signal2_node": self.signal2_node, } os.makedirs(os.path.dirname(self.config_path), exist_ok=True) with open(self.config_path, "w", encoding="utf-8") as f: @@ -134,7 +151,8 @@ class OpcService: self._log(f"Connected to OPC server: {self.opc_url}") try: while not self._stop_event.is_set(): - await self._tick(client) + current_counter = await self._tick_with_counter(client) + await self._check_signals(client, current_counter) await asyncio.sleep(self.poll_interval) finally: client.disconnect() @@ -147,17 +165,17 @@ class OpcService: self._log("OPC polling stopped") # ------------------------------------------------------------------ - async def _tick(self, client): - """Read counter; if changed, fetch trackmap nodes and update Oracle.""" + async def _tick_with_counter(self, client): + """Read counter and update trackmap; return current_counter.""" try: counter_node = client.get_node(self.counter_node) current_counter = counter_node.get_value() except Exception as exc: self._log(f"Failed to read counter node: {exc}") - return + return None if current_counter == self.last_counter: - return # nothing changed + return current_counter self._log( f"Counter changed: {self.last_counter} -> {current_counter}. " @@ -168,7 +186,7 @@ class OpcService: if not self.trackmap_nodes: self._log("No trackmap nodes configured – skipping DB update") - return + return current_counter # Read all configured nodes data: Dict[str, Any] = {} @@ -180,16 +198,165 @@ class OpcService: self._log(f"Failed to read node {node_id}: {exc}") if not data: - return + return current_counter # Determine POSITION from data (must be one of the mapped columns) position = data.get("position") if position is None: self._log("'position' not in trackmap_nodes data – cannot update row") - return + return current_counter await self._update_oracle(position, data) + return current_counter + + # ------------------------------------------------------------------ + async def _check_signals(self, client, current_counter): + """Check signal1 and signal2 with state machine and counter validation.""" + try: + signal1_node = client.get_node(self.signal1_node) + signal1_value = signal1_node.get_value() + except Exception as exc: + self._log(f"Failed to read signal1 node: {exc}") + signal1_value = None + + try: + signal2_node = client.get_node(self.signal2_node) + signal2_value = signal2_node.get_value() + except Exception as exc: + self._log(f"Failed to read signal2 node: {exc}") + signal2_value = None + + counter_changed = current_counter != self.last_counter_at_state_change + + # State machine for signal processing + if self.track_state == "WAIT_S1": + if signal1_value is not None and self.signal1_last is not None: + if self.signal1_last == 0 and signal1_value == 1 and counter_changed: + self._log(f"Signal1: Entry coil triggered (0->1) with counter change, state={self.track_state}") + await self._handle_signal1() + self.track_state = "WAIT_S2" + self.last_counter_at_state_change = current_counter + + 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") + 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 _handle_signal1(self): + """Handle signal1: fetch next 5 coils from PDI and save to SQLite temp table.""" + from sqlite_sync import ( + sqlite_get_max_sequencenb, + sqlite_get_coils_by_sequencenb_range, + sqlite_save_coils_to_track + ) + + def _do_fetch(): + max_seq = sqlite_get_max_sequencenb() + if max_seq is None or max_seq < 1: + self._log("Signal1: No PDI data available") + return + + start_seq = self.current_seq_start + end_seq = min(start_seq + 4, max_seq) + + if end_seq < start_seq: + self._log(f"Signal1: Insufficient PDI data (max_seq={max_seq}, start={start_seq})") + return + + coils = sqlite_get_coils_by_sequencenb_range(start_seq, end_seq) + if len(coils) == 0: + self._log(f"Signal1: No coils found") + return + + self.signal1_coils = coils + sqlite_save_coils_to_track(coils) + + self._log(f"Signal1: Saved {len(coils)} coils (seq {start_seq}-{end_seq}) to temp table") + + self.current_seq_start = start_seq + 1 + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _do_fetch) + + async def _handle_signal2(self): + """Handle signal2: update CMPT_PL_TRACKMAP with temp table data.""" + from sqlite_sync import sqlite_get_coil_track + from database import get_connection + + def _do_update(): + coils = sqlite_get_coil_track() + if not coils: + self._log("Signal2: No coils in temp track table") + return + + try: + conn = get_connection() + cursor = conn.cursor() + try: + coil_count = len(coils) + for i in range(5): + target_position = i + 1 + coil_index = target_position - 1 + if coil_index >= 0 and coil_index < coil_count: + coil = coils[coil_index] + cursor.execute("SELECT COUNT(*) FROM PLTM.CMPT_PL_TRACKMAP WHERE POSITION = :pos", {"pos": target_position}) + exists = cursor.fetchone()[0] > 0 + if exists: + cursor.execute(""" + UPDATE PLTM.CMPT_PL_TRACKMAP + SET COILID = :coilid, TOM = SYSDATE + WHERE POSITION = :position + """, {"coilid": coil["coilid"], "position": target_position}) + else: + cursor.execute(""" + INSERT INTO PLTM.CMPT_PL_TRACKMAP (POSITION, COILID, TOM) + VALUES (:position, :coilid, SYSDATE) + """, {"position": target_position, "coilid": coil["coilid"]}) + else: + cursor.execute("SELECT COUNT(*) FROM PLTM.CMPT_PL_TRACKMAP WHERE POSITION = :pos", {"pos": target_position}) + exists = cursor.fetchone()[0] > 0 + if exists: + cursor.execute(""" + UPDATE PLTM.CMPT_PL_TRACKMAP + SET COILID = NULL, TOM = SYSDATE + WHERE POSITION = :position + """, {"position": target_position}) + else: + cursor.execute(""" + INSERT INTO PLTM.CMPT_PL_TRACKMAP (POSITION, COILID, TOM) + VALUES (:position, NULL, SYSDATE) + """, {"position": target_position}) + conn.commit() + self._log(f"Signal2: Updated 5 positions (coils: {coil_count})") + finally: + cursor.close() + conn.close() + except Exception as exc: + self._log(f"Signal2: Oracle update failed: {exc}") + import traceback + self._log(f"Signal2 traceback: {traceback.format_exc()}") + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _do_update) + # ------------------------------------------------------------------ async def _update_oracle(self, position: Any, data: Dict[str, Any]): """Write fetched OPC values into PLTM.CMPT_PL_TRACKMAP.""" diff --git a/backend/sqlite_sync.py b/backend/sqlite_sync.py index b421742..8c6e765 100644 --- a/backend/sqlite_sync.py +++ b/backend/sqlite_sync.py @@ -102,6 +102,12 @@ CREATE TABLE IF NOT EXISTS PDI_PLTM ( C REAL, SI REAL, MN REAL, P REAL, S REAL, CU REAL, NI REAL, CR REAL, MO REAL, V REAL, TI REAL, SOL_AL REAL, FE REAL, NB REAL, N REAL, B REAL, + MG REAL, PB REAL, SN REAL, ZN REAL, ZR REAL, + NA REAL, LI REAL, GA REAL, CA REAL, + BE REAL, BI REAL, W REAL, + TA REAL, O REAL, H REAL, + AR REAL, AG REAL, AS1 REAL, CD REAL, CL REAL, + CO REAL, K REAL, SB REAL, SE REAL, SEND_FLAG TEXT, SEND_DATE TEXT, TRANSACTION_ID TEXT, @@ -152,6 +158,16 @@ CREATE TABLE IF NOT EXISTS CMPT_PL_TRACKMAP ( ) """ +COIL_TRACK_DDL = """ +CREATE TABLE IF NOT EXISTS COIL_TRACK_TEMP ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + COILID TEXT NOT NULL, + SEQUENCENB INTEGER NOT NULL, + ROLLPROGRAMNB INTEGER NOT NULL, + CREATED_DT TEXT DEFAULT (datetime('now')), + POSITION INTEGER DEFAULT 0 +) +""" def init_db(): """Create tables if they don't exist.""" @@ -159,6 +175,7 @@ def init_db(): try: conn.execute(PDI_DDL) conn.execute(TRACKMAP_DDL) + conn.execute(COIL_TRACK_DDL) conn.commit() logger.info("SQLite schema ready: %s", DB_PATH) finally: @@ -301,3 +318,107 @@ def sqlite_upsert_trackmap(row: Dict[str, Any]): sc.commit() finally: sc.close() + + +def sqlite_get_max_sequencenb() -> Optional[int]: + sc = get_sqlite() + try: + cursor = sc.execute("SELECT MAX(SEQUENCENB) FROM PDI_PLTM") + row = cursor.fetchone() + return row[0] if row and row[0] is not None else None + finally: + sc.close() + + +def sqlite_get_coils_by_sequencenb_range(start_seq: int, end_seq: int) -> List[Dict[str, Any]]: + sc = get_sqlite() + try: + cursor = sc.execute(""" + SELECT COILID, SEQUENCENB, ROLLPROGRAMNB + FROM PDI_PLTM + WHERE SEQUENCENB >= ? AND SEQUENCENB <= ? + ORDER BY SEQUENCENB DESC + """, (start_seq, end_seq)) + rows = cursor.fetchall() + return [{"coilid": r[0], "sequencenb": r[1], "rollprogramnb": r[2]} for r in rows] + finally: + sc.close() + + +def sqlite_save_coils_to_track(coils: List[Dict[str, Any]]): + sc = get_sqlite() + try: + sc.execute("DELETE FROM COIL_TRACK_TEMP") + reversed_coils = list(reversed(coils)) + for i, coil in enumerate(reversed_coils): + sc.execute(""" + INSERT INTO COIL_TRACK_TEMP (COILID, SEQUENCENB, ROLLPROGRAMNB, POSITION) + VALUES (?, ?, ?, ?) + """, (coil["coilid"], coil["sequencenb"], coil["rollprogramnb"], i + 1)) + sc.commit() + logger.info(f"Saved {len(coils)} coils to track temp") + finally: + sc.close() + + +def sqlite_get_coil_track() -> List[Dict[str, Any]]: + sc = get_sqlite() + try: + cursor = sc.execute(""" + SELECT ID, COILID, SEQUENCENB, ROLLPROGRAMNB, CREATED_DT, POSITION + FROM COIL_TRACK_TEMP + ORDER BY POSITION ASC + """) + rows = cursor.fetchall() + return [ + {"id": r[0], "coilid": r[1], "sequencenb": r[2], "rollprogramnb": r[3], "created_dt": r[4], "position": r[5]} + for r in rows + ] + finally: + sc.close() + + +def sqlite_update_coil_track_item(id: int, coilid: str, sequencenb: int, rollprogramnb: int, position: int): + sc = get_sqlite() + try: + sc.execute(""" + UPDATE COIL_TRACK_TEMP + SET COILID = ?, SEQUENCENB = ?, ROLLPROGRAMNB = ?, POSITION = ? + WHERE ID = ? + """, (coilid, sequencenb, rollprogramnb, position, id)) + sc.commit() + finally: + sc.close() + + +def sqlite_add_coil_track_item(coilid: str, sequencenb: int, rollprogramnb: int): + sc = get_sqlite() + try: + cursor = sc.execute("SELECT MAX(POSITION) FROM COIL_TRACK_TEMP") + row = cursor.fetchone() + max_pos = (row[0] or 0) + 1 + sc.execute(""" + INSERT INTO COIL_TRACK_TEMP (COILID, SEQUENCENB, ROLLPROGRAMNB, POSITION) + VALUES (?, ?, ?, ?) + """, (coilid, sequencenb, rollprogramnb, max_pos)) + sc.commit() + finally: + sc.close() + + +def sqlite_delete_coil_track_item(id: int): + sc = get_sqlite() + try: + sc.execute("DELETE FROM COIL_TRACK_TEMP WHERE ID = ?", (id,)) + sc.commit() + finally: + sc.close() + + +def sqlite_clear_coil_track(): + sc = get_sqlite() + try: + sc.execute("DELETE FROM COIL_TRACK_TEMP") + sc.commit() + finally: + sc.close() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f55a76c..063c4fb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -41,6 +41,7 @@ export default { nav: [ { path: '/pdi', label: 'PDI 计划管理', icon: 'el-icon-document' }, { path: '/trackmap', label: '跟踪图监控', icon: 'el-icon-monitor' }, + { path: '/trackcoil', label: '钢卷跟踪管理', icon: 'el-icon-rank' }, { path: '/opc', label: 'OPC 配置', icon: 'el-icon-setting' } ] } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 5eca9a5..42a6e83 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -43,3 +43,15 @@ export const gradeApi = { getL2ModelGrades: () => http.get('/grades/l2model'), getNextNumbers: () => http.get('/pdi/next-numbers') } + +// 跟踪钢卷管理 API +export const trackApi = { + getCoils: () => http.get('/track/coils'), + addCoil: (data) => http.post('/track/coils', data), + updateCoil: (id, data) => http.put(`/track/coils/${id}`, data), + deleteCoil: (id) => http.delete(`/track/coils/${id}`), + clearCoils: () => http.delete('/track/coils'), + getCoilsByRange: (start, end) => http.get('/track/coils/range', { params: { start, end } }), + simulateSignal1: () => http.post('/track/simulate/signal1'), + simulateSignal2: () => http.post('/track/simulate/signal2') +} diff --git a/frontend/src/router.js b/frontend/src/router.js index f17ce66..86833a0 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -3,6 +3,7 @@ import VueRouter from 'vue-router' import PdiList from './views/PdiList.vue' import TrackMap from './views/TrackMap.vue' import OpcConfig from './views/OpcConfig.vue' +import TrackCoil from './views/TrackCoil.vue' Vue.use(VueRouter) @@ -11,6 +12,7 @@ export default new VueRouter({ { path: '/', redirect: '/pdi' }, { path: '/pdi', component: PdiList, meta: { title: 'PDI计划管理' } }, { path: '/trackmap', component: TrackMap, meta: { title: '跟踪图监控' } }, + { path: '/trackcoil', component: TrackCoil, meta: { title: '钢卷跟踪管理' } }, { path: '/opc', component: OpcConfig, meta: { title: 'OPC配置' } } ] }) diff --git a/frontend/src/views/OpcConfig.vue b/frontend/src/views/OpcConfig.vue index f63f272..ab9d135 100644 --- a/frontend/src/views/OpcConfig.vue +++ b/frontend/src/views/OpcConfig.vue @@ -16,6 +16,18 @@ +