Просмотр исходного кода

feat: 完成项目整体功能升级与优化

1. 前端:重构LED调试页面,新增串口数据显示组件;重构实时状态页面,支持终端选择、端口状态展示
2. 后端:重构串口通信模块,新增发送队列与同步接收机制;新增面板状态轮询、失败剔除逻辑;完善设备发现与同步流程
3. 接口:新增面板状态获取接口、优化存储设备获取接口
wenhongquan 9 часов назад
Родитель
Сommit
c02e6747f2

BIN
backend/__pycache__/app.cpython-313.pyc


+ 243 - 28
backend/app.py

@@ -321,6 +321,10 @@ def screen_button_event_handler(page_id, comp_id, event):
 # 面板配置(从地址配置模块加载)
 # 面板配置(从地址配置模块加载)
 panel_config = {}  # {panel_id: {'address': int, 'position': int, 'panel_uid': str}}
 panel_config = {}  # {panel_id: {'address': int, 'position': int, 'panel_uid': str}}
 
 
+# 面板连续轮询失败计数(达到阈值后从 panel_config 移除,避免对离线设备持续发查询)
+panel_fail_count = {}  # {panel_id: int}
+PANEL_FAIL_THRESHOLD = 3
+
 # 数据存储缓冲区
 # 数据存储缓冲区
 serial_data_buffer = []
 serial_data_buffer = []
 mqtt_data_buffer = []
 mqtt_data_buffer = []
@@ -436,13 +440,19 @@ def serial_status_handler(status):
     try:
     try:
         global serial_status
         global serial_status
         serial_status = status
         serial_status = status
-        
+
         # 通过WebSocket广播状态变化
         # 通过WebSocket广播状态变化
         socketio.emit('serial_status', {
         socketio.emit('serial_status', {
             'connected': status
             'connected': status
         }, namespace=SOCKETIO_NAMESPACE_STATUS)
         }, namespace=SOCKETIO_NAMESPACE_STATUS)
-        
+
         logger.info(f"串口状态更新: {'已连接' if status else '已断开'}")
         logger.info(f"串口状态更新: {'已连接' if status else '已断开'}")
+
+        # 串口重新连接后,在后台线程执行一次设备发现,避免阻塞 Flask 主线程
+        if status:
+            import threading
+            t = threading.Thread(target=run_discovery_sync, kwargs={'min_interval': 10}, daemon=True)
+            t.start()
     except Exception as e:
     except Exception as e:
         logger.error(f"处理串口状态时出错: {str(e)}")
         logger.error(f"处理串口状态时出错: {str(e)}")
 
 
@@ -1763,6 +1773,136 @@ def sync_panel_config():
         logger.error(f"同步面板配置失败: {str(e)}")
         logger.error(f"同步面板配置失败: {str(e)}")
 
 
 
 
+def sync_panel_config_from_results(results):
+    """根据本次自动配置结果同步 panel_config,只把真实发现/分配到的设备加入轮询"""
+    global panel_config
+    try:
+        panel_id_map = dtu_config.get('panel_id_mapping', {}) or {}
+        new_config = {}
+        for r in results:
+            if r.get('status') != 'assigned':
+                continue
+            uid_hex = r.get('uid')
+            addr = r.get('address')
+            if not uid_hex or not addr:
+                continue
+            custom_id = panel_id_map.get(uid_hex) or panel_id_map.get(addr)
+            if custom_id:
+                panel_id = str(custom_id)
+            else:
+                panel_id = f"PANEL_{dtu_config.get('dtu_id', 'DTU001')}_{addr}"
+            new_config[panel_id] = {
+                'address': addr,
+                'position': addr,
+                'panel_uid': uid_hex
+            }
+        panel_config = new_config
+        logger.info(f"已同步面板配置(发现模式): {len(panel_config)} 个面板")
+    except Exception as e:
+        logger.error(f"同步面板配置失败: {str(e)}")
+
+
+def check_assigned_devices_online(stored_devices, retries=3, timeout=0.5):
+    """对已有地址的设备发读卡查询指令确认是否在线
+
+    已分配地址的从机不会回复广播,必须通过 03 查询指令确认。
+    连续 retries 次无响应视为离线。
+    返回: [{'status': 'assigned', 'uid': uid_hex, 'address': addr}, ...]
+    """
+    confirmed = []
+    for idx, (uid_hex, addr) in enumerate(stored_devices.items()):
+        # 不同地址之间也留一点间隔,避免总线冲突
+        if idx > 0:
+            time.sleep(0.1)
+        ok = False
+        for attempt in range(retries):
+            try:
+                resp = modbus_client.read_antenna_card(addr, 1, timeout=timeout)
+                if 'error' not in resp:
+                    logger.info(f"地址 {addr} 查询有响应(第{attempt + 1}次),加入轮询")
+                    confirmed.append({'status': 'assigned', 'uid': uid_hex, 'address': addr})
+                    ok = True
+                    break
+                logger.debug(f"地址 {addr} 第{attempt + 1}次查询无响应")
+            except Exception as e:
+                logger.debug(f"地址 {addr} 第{attempt + 1}次查询异常: {e}")
+            # 从机可能需要一点时间切换收发态,重试间隔不要太短
+            time.sleep(0.1)
+        if not ok:
+            logger.info(f"地址 {addr} 连续{retries}次查询无响应,视为离线")
+    return confirmed
+
+
+# 上一次执行发现同步的时间,用于去抖
+_last_discovery_sync_time = 0
+
+
+def run_discovery_sync(min_interval=10):
+    """执行一次完整的设备发现并同步 panel_config
+
+    包括:广播发现新设备 + 对已分配地址发查询指令确认在线。
+    min_interval 为两次发现之间的最小间隔(秒),防止重复触发。
+    """
+    global panel_config, _last_discovery_sync_time
+    try:
+        now = time.time()
+        if now - _last_discovery_sync_time < min_interval:
+            logger.debug(f"发现同步间隔不足 {min_interval} 秒,跳过")
+            return
+        _last_discovery_sync_time = now
+
+        _st = serial_client.get_status()
+        if not (isinstance(_st, dict) and _st.get('connected', False)):
+            logger.debug("串口未连接,跳过发现同步")
+            return
+        if not dtu_config.get('enabled'):
+            logger.debug("DTU 未启用,跳过发现同步")
+            return
+
+        logger.info("执行设备发现与地址同步...")
+
+        # 1. 先查询地址表中已有地址是否在线(已分配地址的设备不回复广播)
+        stored_devices = address_config.get_stored_devices()
+        confirmed = check_assigned_devices_online(stored_devices, retries=3, timeout=0.5)
+
+        # 2. 再广播发现未配置地址的新设备并分配地址
+        time.sleep(0.2)
+        result = address_config.auto_configure(timeout=2.0)
+        discovered = result.get('discovered', 0)
+        assigned = result.get('assigned', 0)
+        if discovered > 0 or assigned > 0:
+            logger.info(f"广播发现: discovered={discovered}, assigned={assigned}")
+            save_device_config()
+
+        # 3. 合并已有在线设备和广播新分配设备,去重
+        combined = confirmed + result.get('results', [])
+        seen_uids = set()
+        unique_results = []
+        for r in combined:
+            uid = r.get('uid')
+            if uid and uid not in seen_uids:
+                seen_uids.add(uid)
+                unique_results.append(r)
+
+        if unique_results:
+            logger.info(f"在线设备同步: 广播结果={discovered}, 已分配在线={len(confirmed)}, 合计={len(unique_results)}")
+            sync_panel_config_from_results(unique_results)
+        else:
+            panel_config.clear()
+            panel_fail_count.clear()
+            port_state.clear()
+            logger.info("未发现任何在线设备,已清空轮询列表")
+
+        # 更新最近在线时间
+        for r in unique_results:
+            if r.get('status') == 'assigned':
+                uid = r.get('uid')
+                if uid:
+                    device_last_seen[uid] = now
+    except Exception as e:
+        logger.error(f"设备发现同步失败: {e}")
+
+
 def confirm_loop():
 def confirm_loop():
     """后台确认线程:每10秒对所有已存储设备发送 confirm_address"""
     """后台确认线程:每10秒对所有已存储设备发送 confirm_address"""
     global confirm_loop_running
     global confirm_loop_running
@@ -2630,7 +2770,7 @@ def modbus_broadcast_query():
         if responses:
         if responses:
             assign_results = address_config.process_responses(responses)
             assign_results = address_config.process_responses(responses)
             save_device_config()
             save_device_config()
-            sync_panel_config()
+            sync_panel_config_from_results(assign_results)
         return jsonify({
         return jsonify({
             'success': True,
             'success': True,
             'responses': responses,
             'responses': responses,
@@ -2656,7 +2796,7 @@ def modbus_auto_configure():
         result = address_config.auto_configure(timeout)
         result = address_config.auto_configure(timeout)
         if result.get('success') and (result.get('discovered', 0) > 0 or result.get('assigned', 0) > 0):
         if result.get('success') and (result.get('discovered', 0) > 0 or result.get('assigned', 0) > 0):
             save_device_config()
             save_device_config()
-            sync_panel_config()
+            sync_panel_config_from_results(result.get('results', []))
         return jsonify(result)
         return jsonify(result)
 
 
     except Exception as e:
     except Exception as e:
@@ -2666,8 +2806,30 @@ def modbus_auto_configure():
 
 
 @app.route('/api/modbus/stored_devices', methods=['GET'])
 @app.route('/api/modbus/stored_devices', methods=['GET'])
 def get_stored_devices():
 def get_stored_devices():
-    """获取已存储的设备列表"""
-    return jsonify({'success': True, 'devices': address_config.get_stored_devices()})
+    """获取已存储的设备列表,并附带当前在线状态"""
+    devices = address_config.get_stored_devices()
+    now = time.time()
+    online_threshold = 60  # 60 秒内被轮询到视为在线
+    online_uids = set()
+    for pid, cfg in panel_config.items():
+        if device_last_seen.get(pid, 0) > now - online_threshold:
+            uid = cfg.get('panel_uid')
+            if uid:
+                online_uids.add(uid)
+
+    panels = []
+    for uid, addr in devices.items():
+        panels.append({
+            'uid': uid,
+            'address': addr,
+            'online': uid in online_uids
+        })
+
+    return jsonify({
+        'success': True,
+        'devices': devices,
+        'panels': panels
+    })
 
 
 
 
 @app.route('/api/modbus/stored_devices', methods=['POST'])
 @app.route('/api/modbus/stored_devices', methods=['POST'])
@@ -2774,7 +2936,7 @@ device_last_seen = {}
 
 
 @app.route('/api/panel/status', methods=['GET'])
 @app.route('/api/panel/status', methods=['GET'])
 def get_panel_status():
 def get_panel_status():
-    """获取所有面板状态"""
+    """获取所有面板状态,包含每个端口的连接状态"""
     now = time.time()
     now = time.time()
     PANEL_OFFLINE_TIMEOUT = 60
     PANEL_OFFLINE_TIMEOUT = 60
 
 
@@ -2783,12 +2945,32 @@ def get_panel_status():
         port_count = len(ports)
         port_count = len(ports)
         alarm_count = sum(1 for p in ports.values() if p.get('alarm_count', 0) > 0)
         alarm_count = sum(1 for p in ports.values() if p.get('alarm_count', 0) > 0)
         connected_count = sum(1 for p in ports.values() if p.get('last_uid'))
         connected_count = sum(1 for p in ports.values() if p.get('last_uid'))
+        panel_ports = {}
+        for port_id, p in ports.items():
+            last_uid = p.get('last_uid')
+            expected_uid = p.get('expected_uid')
+            last_polled_at = p.get('last_polled_at')
+            if not last_polled_at:
+                status = 'UNKNOWN'
+            elif not last_uid:
+                status = 'DISCONNECTED'
+            elif expected_uid and last_uid != expected_uid:
+                status = 'ILLEGAL'
+            else:
+                status = 'CONNECTED'
+            panel_ports[port_id] = {
+                'status': status,
+                'jumper_uid': last_uid or None,
+                'expected_uid': expected_uid or None,
+                'alarm_count': p.get('alarm_count', 0)
+            }
         panel_status[panel_id] = {
         panel_status[panel_id] = {
             'panel_id': panel_id,
             'panel_id': panel_id,
             'port_count': port_count,
             'port_count': port_count,
             'connected_count': connected_count,
             'connected_count': connected_count,
             'alarm_count': alarm_count,
             'alarm_count': alarm_count,
-            'status': 'online' if connected_count > 0 else 'offline'
+            'status': 'online' if connected_count > 0 else 'offline',
+            'ports': panel_ports
         }
         }
     for panel_id, cfg in panel_config.items():
     for panel_id, cfg in panel_config.items():
         last_seen = device_last_seen.get(panel_id, 0)
         last_seen = device_last_seen.get(panel_id, 0)
@@ -2809,7 +2991,8 @@ def get_panel_status():
                 'port_count': 0,
                 'port_count': 0,
                 'connected_count': 0,
                 'connected_count': 0,
                 'alarm_count': 0,
                 'alarm_count': 0,
-                'status': 'online' if is_online else 'offline'
+                'status': 'online' if is_online else 'offline',
+                'ports': {}
             }
             }
     return jsonify({'success': True, 'panels': panel_status})
     return jsonify({'success': True, 'panels': panel_status})
 
 
@@ -3676,11 +3859,27 @@ if __name__ == '__main__':
         # 加载DTU配置
         # 加载DTU配置
         load_dtu_config()
         load_dtu_config()
 
 
-        # 加载设备配置
+        # 加载设备配置(地址表,用于给已发现的设备分配原地址)
         loaded = load_device_config()
         loaded = load_device_config()
         logger.info(f"已加载 {len(loaded)} 个设备配置")
         logger.info(f"已加载 {len(loaded)} 个设备配置")
-        sync_panel_config()
-        logger.info(f"已加载 {len(panel_config)} 个面板配置")
+
+        # 自动连接上次使用的串口(RS485),必须先连接才能做地址配置/发现
+        logger.info("尝试自动连接串口...")
+        auto_connect_serial()
+
+        # 串口连接成功后会通过 status_callback 触发 run_discovery_sync(),
+        # 先查询地址表中已有地址是否在线,再广播发现新设备并分配地址。
+        # 这里只做兜底:如果串口未连接则清空轮询列表,避免轮询离线设备。
+        try:
+            _st = serial_client.get_status()
+            if not (isinstance(_st, dict) and _st.get('connected', False)) or not dtu_config.get('enabled'):
+                panel_config.clear()
+                logger.info("串口未连接或 DTU 未启用,已清空轮询列表")
+        except Exception as e:
+            logger.error(f"启动前检查串口状态失败: {e}")
+
+        logger.info(f"当前轮询面板数: {len(panel_config)}")
+
         # 启动自动发现循环
         # 启动自动发现循环
         def auto_discover_loop():
         def auto_discover_loop():
             while True:
             while True:
@@ -3691,18 +3890,9 @@ if __name__ == '__main__':
                         continue
                         continue
                     if not dtu_config.get('enabled'):
                     if not dtu_config.get('enabled'):
                         continue
                         continue
-                    result = address_config.auto_configure(timeout=2.0)
-                    assigned = result.get('assigned', 0)
-                    discovered = result.get('discovered', 0)
-                    if discovered > 0 or assigned > 0:
-                        logger.info(f"自动发现: discovered={discovered}, assigned={assigned}")
-                        save_device_config()
-                        sync_panel_config()
-                    now = time.time()
-                    for uid in address_config.get_stored_devices():
-                        device_last_seen[uid] = now
-                    for pid in panel_config:
-                        device_last_seen[pid] = now
+
+                    # 执行完整发现同步:先查询已有地址,再广播发现新设备
+                    run_discovery_sync(min_interval=10)
                 except Exception as e:
                 except Exception as e:
                     logger.error(f"自动发现异常: {str(e)}")
                     logger.error(f"自动发现异常: {str(e)}")
         import threading
         import threading
@@ -3738,6 +3928,18 @@ if __name__ == '__main__':
                         offline = 0
                         offline = 0
                         for panel_id, cfg in panel_config.items():
                         for panel_id, cfg in panel_config.items():
                             addr = cfg.get('address', 1)
                             addr = cfg.get('address', 1)
+
+                            # 先快速探测面板是否在线,不在线则跳过 24 个端口的详细轮询
+                            try:
+                                probe = modbus_client.read_antenna_card(addr, 1, timeout=0.5)
+                            except Exception:
+                                probe = {'error': 'exception'}
+                            if 'error' in probe:
+                                offline += 1
+                                panel_fail_count[panel_id] = panel_fail_count.get(panel_id, 0) + 1
+                                logger.warning(f"面板 {panel_id}(地址={addr}) 快速探测无响应,连续失败 {panel_fail_count[panel_id]} 次")
+                                continue
+
                             panel_ok = False
                             panel_ok = False
                             panel_ports = []
                             panel_ports = []
                             for port_id in range(1, 25):
                             for port_id in range(1, 25):
@@ -3786,6 +3988,9 @@ if __name__ == '__main__':
 
 
                             if panel_ok:
                             if panel_ok:
                                 online += 1
                                 online += 1
+                                if panel_fail_count.get(panel_id, 0) > 0:
+                                    panel_fail_count[panel_id] = 0
+                                    logger.info(f"面板 {panel_id}(地址={addr}) 恢复在线")
                                 device_last_seen[panel_id] = time.time()
                                 device_last_seen[panel_id] = time.time()
                                 device_last_seen[cfg.get('panel_uid', '')] = time.time()
                                 device_last_seen[cfg.get('panel_uid', '')] = time.time()
                                 dtu_publish_panel_status(panel_id, addr, panel_ports)
                                 dtu_publish_panel_status(panel_id, addr, panel_ports)
@@ -3802,6 +4007,20 @@ if __name__ == '__main__':
                                         logger.error(f"刷新屏幕失败: {e}")
                                         logger.error(f"刷新屏幕失败: {e}")
                             else:
                             else:
                                 offline += 1
                                 offline += 1
+                                panel_fail_count[panel_id] = panel_fail_count.get(panel_id, 0) + 1
+                                logger.warning(f"面板 {panel_id}(地址={addr}) 轮询无响应,连续失败 {panel_fail_count[panel_id]} 次")
+
+                        # 移除连续失败达到阈值的面板,停止无效查询;后续由自动发现重新分配地址
+                        panels_to_remove = [
+                            pid for pid, count in panel_fail_count.items()
+                            if count >= PANEL_FAIL_THRESHOLD and pid in panel_config
+                        ]
+                        for pid in panels_to_remove:
+                            removed_cfg = panel_config.pop(pid, {})
+                            panel_fail_count.pop(pid, None)
+                            port_state.pop(pid, None)
+                            device_last_seen.pop(pid, None)
+                            logger.info(f"面板 {pid}(地址={removed_cfg.get('address')}) 连续 {PANEL_FAIL_THRESHOLD} 次无响应,已从轮询列表移除,等待自动发现")
 
 
                         dtu_publish_status(force=True)
                         dtu_publish_status(force=True)
                     finally:
                     finally:
@@ -3817,10 +4036,6 @@ if __name__ == '__main__':
             dht11_sensor.on_data = dht11_data_callback
             dht11_sensor.on_data = dht11_data_callback
             dht11_sensor.start()
             dht11_sensor.start()
 
 
-        # 自动连接上次使用的串口
-        logger.info("尝试自动连接串口...")
-        auto_connect_serial()
-
         # 连接 Nextion 串口屏
         # 连接 Nextion 串口屏
         if NEXTION_ENABLED:
         if NEXTION_ENABLED:
             try:
             try:

BIN
backend/modules/__pycache__/serial_port.cpython-313.pyc


+ 2 - 20
backend/modules/modbus_rtu.py

@@ -134,28 +134,10 @@ class AddressConfigProtocol:
             self.serial.flush_input()
             self.serial.flush_input()
             time.sleep(0.05)
             time.sleep(0.05)
 
 
-            # 发送广播并等待响应;部分设备需要 2~3 秒才应答
+            # 发送广播并等待响应;所有串口操作统一走发送队列,
+            # 这里不再额外直接读串口,避免在收集窗口内把后续队列命令的响应抢走。
             response = self.serial.send_and_wait(request, timeout=timeout, min_response_bytes=16)
             response = self.serial.send_and_wait(request, timeout=timeout, min_response_bytes=16)
 
 
-            # 部分从机应答会被后台读取线程滞后收到,再额外等待一段收集窗口
-            # 注意:这里不能再次调用 send_and_wait,因为它会清空缓冲区
-            time.sleep(0.8)
-            extra = b''
-            with self.serial.lock:
-                while self.serial.raw_response_buffer:
-                    try:
-                        extra += bytes.fromhex(self.serial.raw_response_buffer.pop(0))
-                    except Exception:
-                        self.serial.raw_response_buffer.pop(0)
-                if self.serial.ser and self.serial.ser.is_open and self.serial.ser.in_waiting > 0:
-                    extra += self.serial.ser.read(self.serial.ser.in_waiting)
-            if extra:
-                logger.info(f"broadcast extra response ({len(extra)}B): {extra.hex()}")
-                if response:
-                    response += extra
-                else:
-                    response = extra
-
             responses = []
             responses = []
             if response and len(response) >= 16:
             if response and len(response) >= 16:
                 logger.info(f"got raw response ({len(response)}B): {response.hex()}")
                 logger.info(f"got raw response ({len(response)}B): {response.hex()}")

+ 365 - 158
backend/modules/serial_port.py

@@ -5,6 +5,7 @@ import time
 import logging
 import logging
 import platform
 import platform
 import glob
 import glob
+import queue
 from dataclasses import dataclass
 from dataclasses import dataclass
 
 
 # 配置日志
 # 配置日志
@@ -44,7 +45,188 @@ class SerialPort:
         self.reconnect_attempts = 0
         self.reconnect_attempts = 0
         self.max_reconnect_attempts = 3
         self.max_reconnect_attempts = 3
         self.raw_response_buffer = []
         self.raw_response_buffer = []
-    
+        self._reconnect_monitor_thread = None
+        # 同步接收模式:send_and_wait 活动时暂停后台读取线程,防止响应被读线程抢走
+        self._sync_receive_active = False
+        self._sync_lock = threading.Lock()
+        # 串口发送队列:所有发送指令统一排队,按最小间隔逐个发送,避免总线冲突
+        self._cmd_queue = queue.Queue()
+        self._cmd_min_interval = 0.10  # 相邻指令最小间隔 100ms
+        self._last_cmd_time = 0
+        self._cmd_worker_thread = threading.Thread(target=self._cmd_worker, daemon=True)
+        self._cmd_worker_thread.start()
+        # 启动后台重连监控线程,确保断线后可持续自动恢复
+        self._start_reconnect_monitor()
+
+    def _cmd_worker(self):
+        """串口发送队列工作线程:统一按最小间隔逐个发送指令"""
+        logger.info("启动串口发送队列工作线程")
+        while True:
+            try:
+                item = self._cmd_queue.get()
+                if item is None:
+                    break
+
+                # 确保相邻指令最小间隔
+                elapsed = time.time() - self._last_cmd_time
+                if elapsed < self._cmd_min_interval:
+                    time.sleep(self._cmd_min_interval - elapsed)
+
+                cmd_type = item.get('type')
+                try:
+                    if cmd_type == 'send_and_wait':
+                        result = self._do_send_and_wait(
+                            item['data'],
+                            timeout=item.get('timeout', 2.0),
+                            min_response_bytes=item.get('min_response_bytes', 1)
+                        )
+                    elif cmd_type == 'send_raw':
+                        result = self._do_send_raw(item['data'])
+                    elif cmd_type == 'send_data':
+                        result = self._do_send_data(item['data'], item.get('encoding', 'utf-8'))
+                    else:
+                        result = {'error': f'未知命令类型: {cmd_type}'}
+                except Exception as e:
+                    logger.error(f"执行串口命令失败: {e}")
+                    result = {'error': str(e)}
+
+                self._last_cmd_time = time.time()
+
+                callback = item.get('callback')
+                if callback:
+                    callback(result)
+            except Exception as e:
+                logger.error(f"串口发送队列工作线程异常: {e}")
+
+        logger.info("串口发送队列工作线程结束")
+
+    def _do_send_raw(self, data: bytes):
+        """实际执行 send_raw(在队列工作线程中调用)"""
+        try:
+            with self.lock:
+                if not self.is_connected or not self.ser or not self.ser.is_open:
+                    return False, "serial port not connected"
+                bytes_sent = self.ser.write(data)
+                self.ser.flush()
+            if self.send_callback:
+                self.send_callback(data.hex())
+            return True, "send ok"
+        except Exception as e:
+            error_msg = f"send raw failed: {str(e)}"
+            logger.error(error_msg)
+            if self.error_callback:
+                self.error_callback(error_msg)
+            return False, error_msg
+
+    def _do_send_data(self, data, encoding='utf-8'):
+        """实际执行 send_data(在队列工作线程中调用)"""
+        try:
+            with self.lock:
+                if not self.is_connected or not self.ser or not self.ser.is_open:
+                    return False, "串口未连接"
+
+                # 确保数据以换行符结束
+                if isinstance(data, str):
+                    if not data.endswith('\n'):
+                        data += '\n'
+                    bytes_data = data.encode(encoding)
+                elif isinstance(data, bytes):
+                    if not data.endswith(b'\n'):
+                        bytes_data = data + b'\n'
+                    else:
+                        bytes_data = data
+                else:
+                    raise TypeError("数据必须是字符串或字节类型")
+
+                bytes_sent = self.ser.write(bytes_data)
+                self.ser.flush()
+
+                logger.debug(f"发送数据到串口: {bytes_data.hex()[:50]}... (共{bytes_sent}字节)")
+                return True, "发送成功"
+        except Exception as e:
+            error_msg = f"发送失败: {str(e)}"
+            logger.error(error_msg)
+            if self.error_callback:
+                self.error_callback(error_msg)
+            return False, error_msg
+
+    def _emit_received_data(self, data: bytes):
+        """把接收到的原始数据推送给 data_callback(与后台读线程行为一致)"""
+        if not data or not self.data_callback:
+            return
+        hex_data = data.hex()
+        self.data_callback(hex_data)
+        try:
+            decoded = data.decode('utf-8').strip()
+            if decoded:
+                self.data_callback(decoded)
+        except Exception:
+            pass
+
+    def _do_send_and_wait(self, data: bytes, timeout: float = 2.0, min_response_bytes: int = 1) -> bytes:
+        """实际执行 send_and_wait(在队列工作线程中调用)"""
+        # 进入同步接收模式,暂停后台读取线程
+        with self._sync_lock:
+            self._sync_receive_active = True
+
+        try:
+            # 阶段 1:发送数据并清空缓冲区
+            with self.lock:
+                if not self.ser or not self.ser.is_open:
+                    return b''
+                self.raw_response_buffer.clear()
+                self.ser.reset_input_buffer()
+                self.ser.write(data)
+                self.ser.flush()
+                # 485 半双工:等最后字节发完并给收发器一点切换方向的时间
+                time.sleep(0.03)
+
+                if self.send_callback:
+                    self.send_callback(data.hex())
+
+            # 阶段 2:等待响应。由于后台读线程已暂停,这里独占串口读取。
+            response = b''
+            start = time.time()
+            while time.time() - start < timeout:
+                # 先清掉缓冲区内可能残留的报文(正常情况下 sync 模式下读线程不会写入)
+                with self.lock:
+                    while self.raw_response_buffer:
+                        try:
+                            response += bytes.fromhex(self.raw_response_buffer.pop(0))
+                        except Exception:
+                            pass
+
+                try:
+                    if self.ser and self.ser.is_open and self.ser.in_waiting > 0:
+                        response += self.ser.read(self.ser.in_waiting)
+                except Exception:
+                    pass
+
+                if len(response) >= min_response_bytes:
+                    # 再等一小段时间收集可能的后续数据
+                    time.sleep(0.05)
+                    with self.lock:
+                        while self.raw_response_buffer:
+                            try:
+                                response += bytes.fromhex(self.raw_response_buffer.pop(0))
+                            except Exception:
+                                pass
+                    try:
+                        if self.ser and self.ser.is_open and self.ser.in_waiting > 0:
+                            response += self.ser.read(self.ser.in_waiting)
+                    except Exception:
+                        pass
+                    self._emit_received_data(response)
+                    return response
+
+                time.sleep(0.01)
+            self._emit_received_data(response)
+            return response
+        finally:
+            # 退出同步接收模式,恢复后台读取线程
+            with self._sync_lock:
+                self._sync_receive_active = False
+
     def list_ports(self):
     def list_ports(self):
         """列出系统中可用的串口"""
         """列出系统中可用的串口"""
         ports = []
         ports = []
@@ -109,43 +291,49 @@ class SerialPort:
                 timeout=timeout,
                 timeout=timeout,
                 **kwargs
                 **kwargs
             )
             )
-            
+
             with self.lock:
             with self.lock:
                 if self.is_connected:
                 if self.is_connected:
+                    # RLock 允许递归,避免死锁
                     self.disconnect()
                     self.disconnect()
-                
-                logger.info(f"尝试连接串口: {port}, 波特率: {baudrate}")
-                self.ser = serial.Serial(
-                    port=config.port,
-                    baudrate=config.baudrate,
-                    bytesize=config.bytesize,
-                    parity=config.parity,
-                    stopbits=config.stopbits,
-                    timeout=config.timeout,
-                    xonxoff=config.xonxoff,
-                    rtscts=config.rtscts,
-                    dsrdtr=config.dsrdtr
-                )
-                
-                # 检查连接是否成功
-                if not self.ser.is_open:
-                    raise Exception("串口打开失败")
-                
+
+            # serial.Serial() 可能阻塞(端口被占用等),不要在持有锁的情况下调用,
+            # 否则 disconnect() 会长时间等待锁而无法响应。
+            logger.info(f"尝试连接串口: {port}, 波特率: {baudrate}")
+            ser = serial.Serial(
+                port=config.port,
+                baudrate=config.baudrate,
+                bytesize=config.bytesize,
+                parity=config.parity,
+                stopbits=config.stopbits,
+                timeout=config.timeout,
+                xonxoff=config.xonxoff,
+                rtscts=config.rtscts,
+                dsrdtr=config.dsrdtr
+            )
+
+            # 检查连接是否成功
+            if not ser.is_open:
+                ser.close()
+                raise Exception("串口打开失败")
+
+            with self.lock:
+                self.ser = ser
                 self.is_connected = True
                 self.is_connected = True
                 self.stop_event.clear()
                 self.stop_event.clear()
                 self.current_config = config
                 self.current_config = config
                 self.reconnect_attempts = 0
                 self.reconnect_attempts = 0
-                
+
                 # 启动读取线程
                 # 启动读取线程
                 self.read_thread = threading.Thread(target=self._read_loop, daemon=True)
                 self.read_thread = threading.Thread(target=self._read_loop, daemon=True)
                 self.read_thread.start()
                 self.read_thread.start()
-                
+
                 if self.status_callback:
                 if self.status_callback:
                     self.status_callback(True)
                     self.status_callback(True)
-                
+
                 logger.info(f"已连接到 {port},波特率 {baudrate}")
                 logger.info(f"已连接到 {port},波特率 {baudrate}")
                 return True, f"已连接到 {port},波特率 {baudrate}"
                 return True, f"已连接到 {port},波特率 {baudrate}"
-                
+
         except Exception as e:
         except Exception as e:
             error_msg = f"连接失败: {str(e)}"
             error_msg = f"连接失败: {str(e)}"
             logger.error(error_msg)
             logger.error(error_msg)
@@ -158,30 +346,41 @@ class SerialPort:
     def disconnect(self):
     def disconnect(self):
         """断开串口连接"""
         """断开串口连接"""
         try:
         try:
+            logger.info("断开串口连接")
+
+            # 阶段 0:清空发送队列中待执行的指令,避免断开后再发数据
+            try:
+                while not self._cmd_queue.empty():
+                    self._cmd_queue.get_nowait()
+            except Exception:
+                pass
+
+            # 阶段 1:通知读取线程退出,不要在持有锁的情况下 join,
+            # 否则读取线程异常时调用 _close_on_error 会拿不到锁而死锁。
             with self.lock:
             with self.lock:
-                logger.info("断开串口连接")
                 self.stop_event.set()
                 self.stop_event.set()
-                if self.read_thread and self.read_thread.is_alive():
-                    self.read_thread.join(timeout=2.0)
-                    if self.read_thread.is_alive():
-                        logger.warning("读取线程未能正常终止")
-                
+
+            if self.read_thread and self.read_thread.is_alive():
+                self.read_thread.join(timeout=2.0)
+                if self.read_thread.is_alive():
+                    logger.warning("读取线程未能正常终止")
+
+            # 阶段 2:关闭串口并清理状态
+            with self.lock:
                 if self.ser and self.ser.is_open:
                 if self.ser and self.ser.is_open:
                     try:
                     try:
                         self.ser.close()
                         self.ser.close()
                     except Exception as e:
                     except Exception as e:
                         logger.error(f"关闭串口时出错: {str(e)}")
                         logger.error(f"关闭串口时出错: {str(e)}")
-                    finally:
-                        self.ser = None
-                
+                self.ser = None
                 self.is_connected = False
                 self.is_connected = False
                 self.current_config = None
                 self.current_config = None
-                
+
                 if self.status_callback:
                 if self.status_callback:
                     self.status_callback(False)
                     self.status_callback(False)
-                
+
             return True, "已断开连接"
             return True, "已断开连接"
-            
+
         except Exception as e:
         except Exception as e:
             error_msg = f"断开连接失败: {str(e)}"
             error_msg = f"断开连接失败: {str(e)}"
             logger.error(error_msg)
             logger.error(error_msg)
@@ -192,9 +391,17 @@ class SerialPort:
     def _read_loop(self):
     def _read_loop(self):
         """读取串口数据的循环"""
         """读取串口数据的循环"""
         logger.info("启动串口读取线程")
         logger.info("启动串口读取线程")
-        
+
         while not self.stop_event.is_set():
         while not self.stop_event.is_set():
             try:
             try:
+                # send_and_wait 正在同步接收时,后台读线程让出总线,
+                # 避免读线程把响应抢走导致同步调用超时。
+                with self._sync_lock:
+                    sync_active = self._sync_receive_active
+                if sync_active:
+                    time.sleep(0.001)
+                    continue
+
                 if self.ser and self.ser.is_open:
                 if self.ser and self.ser.is_open:
                     # 使用in_waiting提高效率
                     # 使用in_waiting提高效率
                     if self.ser.in_waiting > 0:
                     if self.ser.in_waiting > 0:
@@ -218,72 +425,97 @@ class SerialPort:
                 logger.error(error_msg)
                 logger.error(error_msg)
                 if self.error_callback:
                 if self.error_callback:
                     self.error_callback(error_msg)
                     self.error_callback(error_msg)
-                
-                # 尝试重连
-                if self._should_reconnect():
-                    logger.warning(f"尝试重连串口... (第{self.reconnect_attempts}次)")
-                    if self.current_config:
-                        # 等待一段时间后重连
-                        time.sleep(2)
-                        self.connect(
-                            port=self.current_config.port,
-                            baudrate=self.current_config.baudrate,
-                            timeout=self.current_config.timeout,
-                            bytesize=self.current_config.bytesize,
-                            parity=self.current_config.parity,
-                            stopbits=self.current_config.stopbits,
-                            xonxoff=self.current_config.xonxoff,
-                            rtscts=self.current_config.rtscts,
-                            dsrdtr=self.current_config.dsrdtr
-                        )
+                # 读取线程只负责关闭当前连接并通知,重连由独立监控线程负责,
+                # 避免在读取线程内调用 connect() 造成自连接/自 join 的问题
+                self._close_on_error()
                 break
                 break
-        
+
         # 线程结束时清理资源
         # 线程结束时清理资源
         logger.info("串口读取线程结束")
         logger.info("串口读取线程结束")
-        # 只有错误退出才断开连接,stop_event触发的暂停不重置
-        if not self.stop_event.is_set():
-            with self.lock:
-                self.is_connected = False
-                if self.ser:
-                    try:
-                        self.ser.close()
-                    except:
-                        pass
-                    self.ser = None
-                if self.status_callback:
+
+    def _close_on_error(self):
+        """读取异常时关闭串口并触发状态回调,但不直接重连"""
+        with self.lock:
+            if self.ser and self.ser.is_open:
+                try:
+                    self.ser.close()
+                except Exception:
+                    pass
+            self.ser = None
+            self.is_connected = False
+            if self.status_callback:
+                try:
                     self.status_callback(False)
                     self.status_callback(False)
-    
-    def send_data(self, data, encoding='utf-8'):
-        """发送数据到串口"""
-        try:
-            with self.lock:
-                if not self.is_connected or not self.ser or not self.ser.is_open:
-                    return False, "串口未连接"
-                
-                # 确保数据以换行符结束
-                if isinstance(data, str):
-                    if not data.endswith('\n'):
-                        data += '\n'
-                    bytes_data = data.encode(encoding)
-                elif isinstance(data, bytes):
-                    if not data.endswith(b'\n'):
-                        bytes_data = data + b'\n'
+                except Exception:
+                    pass
+        logger.warning("串口因读取错误已关闭,等待重连监控线程恢复")
+
+    def _start_reconnect_monitor(self):
+        """启动独立后台线程,在串口断开时持续尝试重连"""
+        if getattr(self, '_reconnect_monitor_thread', None) and self._reconnect_monitor_thread.is_alive():
+            return
+        self._reconnect_monitor_thread = threading.Thread(target=self._reconnect_monitor, daemon=True)
+        self._reconnect_monitor_thread.start()
+
+    def _reconnect_monitor(self):
+        """后台重连监控:只要保存过配置就无限重试,成功则重置计数"""
+        logger.info("启动串口重连监控线程")
+        while True:
+            try:
+                with self.lock:
+                    connected = self.is_connected
+                    config = self.current_config
+                if not connected and config is not None:
+                    self.reconnect_attempts += 1
+                    attempt = self.reconnect_attempts
+                    logger.warning(f"重连监控尝试连接串口... (第{attempt}次)")
+                    success, msg = self.connect(
+                        port=config.port,
+                        baudrate=config.baudrate,
+                        timeout=config.timeout,
+                        bytesize=config.bytesize,
+                        parity=config.parity,
+                        stopbits=config.stopbits,
+                        xonxoff=config.xonxoff,
+                        rtscts=config.rtscts,
+                        dsrdtr=config.dsrdtr
+                    )
+                    if not success:
+                        # 指数退避,最长 30 秒
+                        delay = min(30, 2 + attempt * 2)
+                        logger.warning(f"重连失败: {msg},{delay}秒后再次尝试")
+                        time.sleep(delay)
                     else:
                     else:
-                        bytes_data = data
+                        logger.info("串口重连成功")
                 else:
                 else:
-                    raise TypeError("数据必须是字符串或字节类型")
-                
-                bytes_sent = self.ser.write(bytes_data)
-                self.ser.flush()  # 确保数据被发送
-                
-                logger.debug(f"发送数据到串口: {bytes_data.hex()[:50]}... (共{bytes_sent}字节)")
-                return True, "发送成功"
-        except Exception as e:
-            error_msg = f"发送失败: {str(e)}"
-            logger.error(error_msg)
-            if self.error_callback:
-                self.error_callback(error_msg)
-            return False, error_msg
+                    # 已连接或未保存配置时,重置失败计数并降低检查频率
+                    if connected:
+                        self.reconnect_attempts = 0
+                    time.sleep(3)
+            except Exception as e:
+                logger.error(f"重连监控线程异常: {e}")
+                time.sleep(5)
+    
+    def send_data(self, data, encoding='utf-8'):
+        """发送数据到串口(入队,由队列工作线程统一发送)"""
+        result_container = {}
+        event = threading.Event()
+
+        def callback(result):
+            result_container['result'] = result
+            event.set()
+
+        self._cmd_queue.put({
+            'type': 'send_data',
+            'data': data,
+            'encoding': encoding,
+            'callback': callback
+        })
+
+        # 最多等待 5 秒,避免队列卡死导致调用方永远阻塞
+        if not event.wait(5):
+            return False, "串口发送队列超时"
+        return result_container.get('result', (False, "未知错误"))
     
     
     def set_data_callback(self, callback):
     def set_data_callback(self, callback):
         """设置数据接收回调函数"""
         """设置数据接收回调函数"""
@@ -318,22 +550,24 @@ class SerialPort:
 
 
 
 
     def send_raw(self, data: bytes):
     def send_raw(self, data: bytes):
-        """send raw binary data without adding newline"""
-        try:
-            with self.lock:
-                if not self.is_connected or not self.ser or not self.ser.is_open:
-                    return False, "serial port not connected"
-                bytes_sent = self.ser.write(data)
-                self.ser.flush()
-            if self.send_callback:
-                self.send_callback(data.hex())
-            return True, "send ok"
-        except Exception as e:
-            error_msg = "send raw failed: " + str(e)
-            logger.error(error_msg)
-            if self.error_callback:
-                self.error_callback(error_msg)
-            return False, error_msg
+        """send raw binary data without adding newline(入队,由队列工作线程统一发送)"""
+        result_container = {}
+        event = threading.Event()
+
+        def callback(result):
+            result_container['result'] = result
+            event.set()
+
+        self._cmd_queue.put({
+            'type': 'send_raw',
+            'data': data,
+            'callback': callback
+        })
+
+        # 最多等待 5 秒,避免队列卡死导致调用方永远阻塞
+        if not event.wait(5):
+            return False, "串口发送队列超时"
+        return result_container.get('result', (False, "未知错误"))
 
 
     def flush_input(self):
     def flush_input(self):
         """清空输入缓冲区"""
         """清空输入缓冲区"""
@@ -362,52 +596,25 @@ class SerialPort:
             return False, error_msg
             return False, error_msg
 
 
     def send_and_wait(self, data: bytes, timeout: float = 2.0, min_response_bytes: int = 1) -> bytes:
     def send_and_wait(self, data: bytes, timeout: float = 2.0, min_response_bytes: int = 1) -> bytes:
-        """sync send and wait (collect response from both serial port and read-thread buffer)"""
-        import time
+        """sync send and wait(入队,由队列工作线程统一发送并等待响应)"""
+        result_container = {}
+        event = threading.Event()
 
 
-        with self.lock:
-            if not self.ser or not self.ser.is_open:
-                return b''
-            self.raw_response_buffer.clear()
-            self.ser.reset_input_buffer()
-            self.ser.write(data)
-            self.ser.flush()
+        def callback(result):
+            result_container['result'] = result
+            event.set()
 
 
-            if self.send_callback:
-                self.send_callback(data.hex())
+        self._cmd_queue.put({
+            'type': 'send_and_wait',
+            'data': data,
+            'timeout': timeout,
+            'min_response_bytes': min_response_bytes,
+            'callback': callback
+        })
 
 
-            response = b''
-            start = time.time()
-            # 同时从后台读取线程缓冲区和串口直接读取,防止响应被读线程抢走
-            while time.time() - start < timeout:
-                consumed = False
-                with self.lock:
-                    while self.raw_response_buffer:
-                        try:
-                            response += bytes.fromhex(self.raw_response_buffer.pop(0))
-                            consumed = True
-                        except Exception:
-                            pass
-                if self.ser.in_waiting > 0:
-                    with self.lock:
-                        if self.ser.in_waiting > 0:
-                            response += self.ser.read(self.ser.in_waiting)
-                            consumed = True
-                if len(response) >= min_response_bytes:
-                    # 再等一小段时间收集可能的后续数据
-                    time.sleep(0.05)
-                    with self.lock:
-                        while self.raw_response_buffer:
-                            try:
-                                response += bytes.fromhex(self.raw_response_buffer.pop(0))
-                            except Exception:
-                                pass
-                        if self.ser.in_waiting > 0:
-                            response += self.ser.read(self.ser.in_waiting)
-                    return response
-                if not consumed:
-                    time.sleep(0.01)
-            return response
+        # 最多等待 timeout + 队列处理余量
+        wait_time = timeout + 5
+        if not event.wait(wait_time):
+            return b''
+        return result_container.get('result', b'')
 
 
-# 创建全局串口实例
-global_serial = SerialPort()

+ 7 - 0
frontend/src/views/LedDebug.vue

@@ -137,6 +137,12 @@
         </a-card>
         </a-card>
       </a-col>
       </a-col>
     </a-row>
     </a-row>
+
+    <a-row :gutter="[24, 24]" style="margin-top: 16px;">
+      <a-col :span="24">
+        <SerialDataDisplay />
+      </a-col>
+    </a-row>
   </div>
   </div>
 </template>
 </template>
 
 
@@ -144,6 +150,7 @@
 import { ref, computed, onMounted, onUnmounted } from 'vue'
 import { ref, computed, onMounted, onUnmounted } from 'vue'
 import apiService from '../api/apiService'
 import apiService from '../api/apiService'
 import { useDataStore } from '../stores/dataStore'
 import { useDataStore } from '../stores/dataStore'
+import SerialDataDisplay from '../components/SerialDataDisplay.vue'
 
 
 const dataStore = useDataStore()
 const dataStore = useDataStore()
 
 

+ 103 - 56
frontend/src/views/RealTimeStatus.vue

@@ -35,7 +35,10 @@
           <a-empty v-else-if="terminals.length === 0" description="暂无终端" />
           <a-empty v-else-if="terminals.length === 0" description="暂无终端" />
           <a-list v-else size="small" :data-source="terminals">
           <a-list v-else size="small" :data-source="terminals">
             <template #renderItem="{ item }">
             <template #renderItem="{ item }">
-              <a-list-item>
+              <a-list-item
+                :class="['terminal-item', { 'terminal-item-selected': selectedTerminal?.address === item.address }]"
+                @click="selectTerminal(item)"
+              >
                 <a-list-item-meta>
                 <a-list-item-meta>
                   <template #title>
                   <template #title>
                     <span>地址 {{ item.address }}</span>
                     <span>地址 {{ item.address }}</span>
@@ -50,30 +53,35 @@
         </a-card>
         </a-card>
       </a-col>
       </a-col>
 
 
-      <!-- 24 LED 面板 -->
+      <!-- 选中终端的 24 路端口状态 -->
       <a-col :xs="24" :lg="16">
       <a-col :xs="24" :lg="16">
-        <a-card title="24 路 LED 状态">
+        <a-card :title="selectedTerminal ? `地址 ${selectedTerminal.address} 端口状态` : '端口状态'">
           <div v-if="!dataStore.serialConnected" class="disconnected-overlay">
           <div v-if="!dataStore.serialConnected" class="disconnected-overlay">
-            <a-alert message="串口未连接,无法获取 LED 状态" type="warning" show-icon />
+            <a-alert message="串口未连接,无法获取端口状态" type="warning" show-icon />
           </div>
           </div>
-          <div class="led-grid">
-            <div
-              v-for="led in 24"
-              :key="led"
-              class="led-cell"
-              :title="`LED ${led}: ${ledStatusText[ledStates[led] || 0]}`"
-            >
-              <div class="led-indicator" :style="{ backgroundColor: ledColors[ledStates[led] || 0] }">
-                <span class="led-number">{{ led }}</span>
+          <div v-else-if="!selectedTerminal" class="disconnected-overlay">
+            <a-alert message="请点击左侧终端查看状态" type="info" show-icon />
+          </div>
+          <div v-else>
+            <div class="port-grid">
+              <div
+                v-for="port in 24"
+                :key="port"
+                class="port-cell"
+                :title="`端口 ${port}: ${portStatusText[portStates[port] || 'UNKNOWN']}`"
+              >
+                <div class="port-indicator" :style="{ backgroundColor: portStatusColors[portStates[port] || 'UNKNOWN'] }">
+                  <span class="port-number">{{ port }}</span>
+                </div>
               </div>
               </div>
             </div>
             </div>
-          </div>
-          <!-- 图例 -->
-          <div class="led-legend">
-            <div class="legend-item"><span class="dot" :style="{ background: ledColors[0] }"></span> 关闭</div>
-            <div class="legend-item"><span class="dot" :style="{ background: ledColors[1] }"></span> 红色闪烁</div>
-            <div class="legend-item"><span class="dot" :style="{ background: ledColors[2] }"></span> 绿色闪烁</div>
-            <div class="legend-item"><span class="dot" :style="{ background: ledColors[3] }"></span> 蓝色闪烁</div>
+            <!-- 图例 -->
+            <div class="led-legend">
+              <div class="legend-item"><span class="dot" :style="{ background: portStatusColors.CONNECTED }"></span> 已连接</div>
+              <div class="legend-item"><span class="dot" :style="{ background: portStatusColors.DISCONNECTED }"></span> 未连接</div>
+              <div class="legend-item"><span class="dot" :style="{ background: portStatusColors.ILLEGAL }"></span> 非法</div>
+              <div class="legend-item"><span class="dot" :style="{ background: portStatusColors.UNKNOWN }"></span> 未知</div>
+            </div>
           </div>
           </div>
         </a-card>
         </a-card>
       </a-col>
       </a-col>
@@ -99,34 +107,52 @@ const dataStore = useDataStore()
 const terminals = ref([])
 const terminals = ref([])
 const discovering = ref(false)
 const discovering = ref(false)
 const lastUpdate = ref('')
 const lastUpdate = ref('')
-const ledStates = ref({})
-const ledColors = {
-  0: '#d9d9d9',
-  1: '#ff4d4f',
-  2: '#52c41a',
-  3: '#1890ff'
+const selectedTerminal = ref(null)
+const panelStatus = ref({})
+
+const portStatusColors = {
+  CONNECTED: '#52c41a',
+  DISCONNECTED: '#d9d9d9',
+  ILLEGAL: '#ff4d4f',
+  UNKNOWN: '#faad14'
 }
 }
-const ledStatusText = {
-  0: '关闭',
-  1: '红色闪烁',
-  2: '绿色闪烁',
-  3: '蓝色闪烁'
+const portStatusText = {
+  CONNECTED: '已连接',
+  DISCONNECTED: '未连接',
+  ILLEGAL: '非法',
+  UNKNOWN: '未知'
 }
 }
 
 
 let pollTimer = null
 let pollTimer = null
 
 
 const terminalCount = computed(() => terminals.value.length)
 const terminalCount = computed(() => terminals.value.length)
 
 
+const portStates = computed(() => {
+  if (!selectedTerminal.value) return {}
+  const address = selectedTerminal.value.address
+  // 按地址匹配面板
+  const panel = Object.values(panelStatus.value).find(p => p.address === address)
+  if (!panel || !panel.ports) return {}
+  const states = {}
+  for (let i = 1; i <= 24; i++) {
+    states[i] = panel.ports[i] || 'UNKNOWN'
+  }
+  return states
+})
+
 async function discoverTerminals() {
 async function discoverTerminals() {
   discovering.value = true
   discovering.value = true
   try {
   try {
     const res = await apiService.modbus.broadcastQuery({ timeout: 3 })
     const res = await apiService.modbus.broadcastQuery({ timeout: 3 })
     if (res.data.success && res.data.responses) {
     if (res.data.success && res.data.responses) {
-      terminals.value = res.data.responses.map((r, i) => ({
+      // 优先使用已保存的地址映射,避免应答顺序导致地址错乱
+      const storedRes = await apiService.modbus.getStoredDevices()
+      const storedDevices = storedRes.data.success ? (storedRes.data.devices || {}) : {}
+      terminals.value = res.data.responses.map((r) => ({
         uid: r.uid,
         uid: r.uid,
         uid_readable: r.uid_readable,
         uid_readable: r.uid_readable,
-        address: i + 1
-      }))
+        address: storedDevices[r.uid] || r.address || 1
+      })).filter(t => t.address)
     }
     }
   } catch (e) {
   } catch (e) {
     console.error('discover error:', e)
     console.error('discover error:', e)
@@ -139,24 +165,30 @@ async function discoverTerminals() {
 async function loadStoredDevices() {
 async function loadStoredDevices() {
   try {
   try {
     const res = await apiService.modbus.getStoredDevices()
     const res = await apiService.modbus.getStoredDevices()
-    if (res.data.success && res.data.devices) {
-      const entries = Object.entries(res.data.devices)
-      terminals.value = entries.map(([uid, addr]) => ({
-        uid,
-        uid_readable: uid.match(/.{1,2}/g)?.join(':') || uid,
-        address: addr
-      }))
+    if (res.data.success && res.data.panels) {
+      // 首页“已发现终端”只显示当前真实在线的设备
+      terminals.value = res.data.panels
+        .filter(p => p.online)
+        .map(p => ({
+          uid: p.uid,
+          uid_readable: p.uid.match(/.{1,2}/g)?.join(':') || p.uid,
+          address: p.address
+        }))
     }
     }
   } catch (e) {
   } catch (e) {
     console.error('load stored devices error:', e)
     console.error('load stored devices error:', e)
   }
   }
 }
 }
 
 
-async function pollLedStatus() {
+function selectTerminal(item) {
+  selectedTerminal.value = item
+}
+
+async function pollPanelStatus() {
   try {
   try {
-    const res = await apiService.modbus.getLedStatus()
-    if (res.data.success && res.data.leds) {
-      ledStates.value = { ...res.data.leds }
+    const res = await apiService.panel.getStatus()
+    if (res.data.success && res.data.panels) {
+      panelStatus.value = { ...res.data.panels }
     }
     }
   } catch (e) {
   } catch (e) {
     // ignore polling errors
     // ignore polling errors
@@ -167,10 +199,14 @@ onMounted(async () => {
   if (dataStore.serialConnected) {
   if (dataStore.serialConnected) {
     await loadStoredDevices()
     await loadStoredDevices()
   }
   }
-  await pollLedStatus()
-  pollTimer = setInterval(pollLedStatus, 3000)
-  if (dataStore.serialConnected) {
-    setInterval(loadStoredDevices, 5000)
+  await pollPanelStatus()
+  pollTimer = setInterval(async () => {
+    await loadStoredDevices()
+    await pollPanelStatus()
+  }, 3000)
+  // 默认选中第一个在线终端
+  if (terminals.value.length > 0 && !selectedTerminal.value) {
+    selectedTerminal.value = terminals.value[0]
   }
   }
 })
 })
 
 
@@ -203,31 +239,42 @@ onUnmounted(() => {
   font-size: 12px;
   font-size: 12px;
   word-break: break-all;
   word-break: break-all;
 }
 }
+.terminal-item {
+  cursor: pointer;
+  transition: background-color 0.2s ease;
+}
+.terminal-item:hover {
+  background-color: #f5f5f5;
+}
+.terminal-item-selected {
+  background-color: #e6f7ff;
+  border-left: 3px solid #1890ff;
+}
 .disconnected-overlay {
 .disconnected-overlay {
   margin-bottom: 16px;
   margin-bottom: 16px;
 }
 }
-.led-grid {
+.port-grid {
   display: grid;
   display: grid;
   grid-template-columns: repeat(6, 1fr);
   grid-template-columns: repeat(6, 1fr);
   gap: 12px;
   gap: 12px;
 }
 }
 @media (max-width: 768px) {
 @media (max-width: 768px) {
-  .led-grid {
+  .port-grid {
     grid-template-columns: repeat(4, 1fr);
     grid-template-columns: repeat(4, 1fr);
     gap: 8px;
     gap: 8px;
   }
   }
 }
 }
 @media (max-width: 480px) {
 @media (max-width: 480px) {
-  .led-grid {
+  .port-grid {
     grid-template-columns: repeat(2, 1fr);
     grid-template-columns: repeat(2, 1fr);
     gap: 6px;
     gap: 6px;
   }
   }
 }
 }
-.led-cell {
+.port-cell {
   display: flex;
   display: flex;
   justify-content: center;
   justify-content: center;
 }
 }
-.led-indicator {
+.port-indicator {
   width: 64px;
   width: 64px;
   height: 64px;
   height: 64px;
   border-radius: 12px;
   border-radius: 12px;
@@ -238,11 +285,11 @@ onUnmounted(() => {
   transition: background-color 0.3s ease, box-shadow 0.3s ease;
   transition: background-color 0.3s ease, box-shadow 0.3s ease;
   cursor: default;
   cursor: default;
 }
 }
-.led-indicator:hover {
+.port-indicator:hover {
   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
   transform: translateY(-2px);
   transform: translateY(-2px);
 }
 }
-.led-number {
+.port-number {
   font-size: 18px;
   font-size: 18px;
   font-weight: 700;
   font-weight: 700;
   color: #fff;
   color: #fff;